Graph Theory Basics

Graph theory provides the mathematical foundation for modeling relationships and connections in software systems. From network routing to social media analysis, understanding graphs and their algorithms is essential for modern software engineers.

What is Graph Theory?

A graph is a collection of vertices (also called nodes) and edges that connect pairs of vertices. Graphs can be directed (edges have a direction) or undirected (edges are bidirectional), and they may be weighted (edges carry a cost or value).

Key Terminology

  • Vertex (Node): The fundamental unit representing an entity.
  • Edge (Link): A connection between two vertices.
  • Degree: Number of edges incident to a vertex (out-degree/in-degree for directed graphs).
  • Path: A sequence of edges connecting a series of vertices.
  • Cycle: A path that starts and ends at the same vertex without repeating edges.
  • Connected Graph: A graph where there is a path between every pair of vertices.
  • Tree: A connected acyclic graph.

Graph Representations

Choosing an appropriate representation affects both the time and space complexity of graph algorithms.

RepresentationDescriptionWhen to UseSpace Complexity
Adjacency Matrix2‑D array where cell (i,j) indicates presence/weight of edge i→j.Dense graphs (many edges).O(V²)
Adjacency ListArray of lists; each list contains neighboring vertices (and optionally weights).Sparse graphs (few edges).O(V + E)
Edge ListFlat list of (source, destination, weight) tuples.When edges need to be processed independently, e.g., in Kruskal's algorithm.O(E)

Fundamental Graph Algorithms

Breadth‑First Search (BFS)

BFS explores vertices layer by layer, guaranteeing the shortest path (in terms of edge count) from the start vertex to every reachable vertex in an unweighted graph.

from collections import deque

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    order = []
    while queue:
        vertex = queue.popleft()
        order.append(vertex)
        for neighbour in graph[vertex]:
            if neighbour not in visited:
                visited.add(neighbour)
                queue.append(neighbour)
    return order

# Example usage
graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D', 'E'],
    'C': ['A', 'F'],
    'D': ['B'],
    'E': ['B', 'F'],
    'F': ['C', 'E']
}
print(bfs(graph, 'A'))
import java.util.*;

public class BFS {
    public static List<String> bfs(Map<String, List<String>> graph, String start) {
        Set<String> visited = new HashSet<>();
        Queue<String> queue = new LinkedList<>();
        List<String> order = new ArrayList<>();
        visited.add(start);
        queue.add(start);
        while (!queue.isEmpty()) {
            String vertex = queue.poll();
            order.add(vertex);
            for (String neighbour : graph.getOrDefault(vertex, Collections.emptyList())) {
                if (!visited.contains(neighbour)) {
                    visited.add(neighbour);
                    queue.add(neighbour);
                }
            }
        }
        return order;
    }
    public static void main(String[] args) {
        Map<String, List<String>> graph = new HashMap<>();
        graph.put("A", Arrays.asList("B", "C"));
        graph.put("B", Arrays.asList("A", "D", "E"));
        graph.put("C", Arrays.asList("A", "F"));
        graph.put("D", Arrays.asList("B"));
        graph.put("E", Arrays.asList("B", "F"));
        graph.put("F", Arrays.asList("C", "E"));
        System.out.println(bfs(graph, "A"));
    }
}

Depth‑First Search (DFS)

DFS explores as far as possible along each branch before backtracking. It can be implemented recursively or with an explicit stack.

def dfs(graph, start, visited=None, order=None):
    if visited is None:
        visited = set()
    if order is None:
        order = []
    visited.add(start)
    order.append(start)
    for neighbour in graph[start]:
        if neighbour not in visited:
            dfs(graph, neighbour, visited, order)
    return order

# Example usage
graph = {
    'A': ['B', 'C'],
    'B': ['D', 'E'],
    'C': ['F'],
    'D': [],
    'E': ['F'],
    'F': []
}
print(dfs(graph, 'A'))
import java.util.*;

public class DFS {
    public static List<String> dfs(Map<String, List<String>> graph, String start) {
        Set<String> visited = new HashSet<>();
        List<String> order = new ArrayList<>();
        dfsHelper(graph, start, visited, order);
        return order;
    }

    private static void dfsHelper(Map<String, List<String>> graph, String vertex,
                                  Set<String> visited, List<String> order) {
        visited.add(vertex);
        order.add(vertex);
        for (String neighbour : graph.getOrDefault(vertex, Collections.emptyList())) {
            if (!visited.contains(neighbour)) {
                dfsHelper(graph, neighbour, visited, order);
            }
        }
    }

    public static void main(String[] args) {
        Map<String, List<String>> graph = new HashMap<>();
        graph.put("A", Arrays.asList("B", "C"));
        graph.put("B", Arrays.asList("D", "E"));
        graph.put("C", Arrays.asList("F"));
        graph.put("D", Collections.emptyList());
        graph.put("E", Arrays.asList("F"));
        graph.put("F", Collections.emptyList());
        System.out.println(dfs(graph, "A"));
    }
}

Shortest Path – Dijkstra’s Algorithm

Dijkstra’s algorithm finds the minimum‑cost path from a source vertex to all other vertices in a weighted graph with non‑negative edge weights.

import heapq

def dijkstra(graph, start):
    # graph: {vertex: [(neighbour, weight), ...]}
    distances = {v: float('inf') for v in graph}
    distances[start] = 0
    pq = [(0, start)]  # priority queue of (distance, vertex)
    while pq:
        current_dist, u = heapq.heappop(pq)
        if current_dist > distances[u]:
            continue
        for v, weight in graph[u]:
            distance = current_dist + weight
            if distance < distances[v]:
                distances[v] = distance
                heapq.heappush(pq, (distance, v))
    return distances

# Example usage
graph = {
    'A': [('B', 5), ('C', 1)],
    'B': [('C', 2), ('D', 1)],
    'C': [('B', 3), ('D', 4), ('E', 8)],
    'D': [('E', 2)],
    'E': []
}
print(dijkstra(graph, 'A'))

Choosing the Right Representation

📝 Note: The choice of graph representation directly influences the performance of traversal and path‑finding algorithms.
💡 Tip: Use an adjacency list for large, sparse graphs to keep memory usage close to O(V + E).
⚠ Warning: Avoid an adjacency matrix for graphs with millions of vertices when the edge density is low; the O(V²) space quickly becomes prohibitive.

Practical Example: Social Network Friend Recommendations

Imagine a social media platform where we want to suggest "friends of friends" to a user. This can be modeled as an undirected, unweighted graph where each vertex is a user and each edge represents a friendship.

  1. Represent the social network as an adjacency list.
  2. Run BFS starting from the target user up to depth 2.
  3. Collect all vertices reached at depth 2 that are not already direct friends.
  4. Return the collected vertices as recommendation candidates.
def recommend_friends(graph, user):
    visited = {user}
    queue = [(user, 0)]  # (vertex, depth)
    recommendations = set()
    while queue:
        vertex, depth = queue.pop(0)
        if depth == 2:
            recommendations.add(vertex)
            continue
        for neighbour in graph[vertex]:
            if neighbour not in visited:
                visited.add(neighbour)
                queue.append((neighbour, depth + 1))
    # Remove direct friends and the user itself
    recommendations -= set(graph[user])
    recommendations.discard(user)
    return recommendations

# Sample social graph
social = {
    'Alice': ['Bob', 'Carol'],
    'Bob': ['Alice', 'Dave'],
    'Carol': ['Alice', 'Eve'],
    'Dave': ['Bob', 'Eve'],
    'Eve': ['Carol', 'Dave']
}
print(recommend_friends(social, 'Alice'))  # Output: {'Dave', 'Eve'}
Graphs are the lingua franca of many complex systems; mastering them unlocks powerful problem‑solving tools.

Read more on Graph Theory (Wikipedia)

🎥 Video
📘 Summary: Graph theory equips software engineers with models and algorithms for handling relational data. By selecting the appropriate representation and applying fundamental algorithms such as BFS, DFS, and Dijkstra, developers can efficiently solve problems ranging from network routing to recommendation systems.

Q: When should I use an adjacency matrix instead of an adjacency list?
A: An adjacency matrix is preferable for dense graphs where the number of edges approaches V², or when you need constant‑time edge existence checks.


Q: Can Dijkstra’s algorithm handle negative edge weights?
A: No. Dijkstra assumes non‑negative weights. For graphs with negative edges (but no negative cycles), use the Bellman‑Ford algorithm.


Q: Is BFS guaranteed to find the shortest path in a weighted graph?
A: BFS finds the shortest path in terms of the number of edges only for unweighted graphs. For weighted graphs, algorithms like Dijkstra or A* are required.


Q. Which graph representation offers O(1) time complexity for checking if an edge exists between two vertices?
  • Adjacency List
  • Edge List
  • Adjacency Matrix
  • Incidence List

Answer: Adjacency Matrix
In an adjacency matrix, the cell at (i, j) directly tells whether an edge i→j exists, giving O(1) lookup.

Q. What is the time complexity of BFS on a graph represented with an adjacency list?
  • O(V²)
  • O(V + E)
  • O(E log V)
  • O(V log V)

Answer: O(V + E)
BFS visits every vertex once and traverses each edge once, leading to linear complexity in the size of the graph.

References