LearnAbout

Graph Fundamentals

On this page
Quick reference
Adjacency list β€” spaceβ€”
Adjacency matrix β€” spaceβ€”
DFS / BFS traversalO(V + E)
Connected componentsO(V + E)
Cycle detection (directed or undirected)O(V + E)
The Idea

Every tree so far has had two rules built in: exactly one root with nothing that points at it, and exactly one path between any two nodes. A graph is what is left when you drop both rules. Nodes (usually called vertices) connect to each other through edges. But there is no single start point that everything hangs off. A node can connect to many other nodes. Two nodes can even point back and forth at each other. A tree is a graph. Most graphs are not trees.

The everyday version of this shape is a friendship map, not a family tree. A family tree has one ancestor at the top and a strict direction: parent to child, never child to parent. A friendship map has no top. Alice and Bob can connect to each other. A whole group of people can know each other with no single person that everyone traces back to. Someone can even belong to two separate friend groups that have no connection to each other.

That last part matters more than it sounds. A tree is always one connected piece by definition. Every node is reachable from the root. A graph has no such rule. It might be one connected piece, or it might be several separate pieces in the same data structure with no edges between them. Nothing about the graph itself tells you which one you have.

A tree is a graph with two extra promises: no cycles, and exactly one path between any two nodes. If you drop both promises, you get a graph. Everything below is about what breaks once those guarantees are gone, and what you must add back by hand to compensate.
Build It
Representing a graph
structure Graph (adjacency list):
    adj: map from node -> list of the neighbors of that node

structure Graph (adjacency matrix):
    matrix: n x n grid
    matrix[i][j] = 1 if an edge connects i and j, else 0
DFS β€” recursive, with a visited set
function dfs(node, adj, visited):
    if node in visited:            // already explored from here: stop
        return
    visited.add(node)
    process(node)
    for neighbor in adj[node]:
        dfs(neighbor, adj, visited)
This is Linked List Cycle again, generalized. There, only a bug could put a cycle in your chain. Here, a cycle is a completely ordinary, valid graph. Two nodes that point at each other is not a mistake. It is just what a graph is allowed to do. If you skip the visited check, dfs() calls itself on the same few nodes forever, exactly like a walk of a cyclic linked list with no slow/fast pointers.
BFS β€” queue-based, with a visited set
function bfs(start, adj):
    visited = {start}
    queue = Queue()
    enqueue(queue, start)
    while queue is not empty:
        node = dequeue(queue)
        process(node)
        for neighbor in adj[node]:
            if neighbor not in visited:
                visited.add(neighbor)     // mark as visited at enqueue time
                enqueue(queue, neighbor)
This is the same queue idea as Phase 3, and the same level-by-level spirit as the level-order traversal in Binary Trees. It is just no longer limited to two children per node. Any number of neighbors works. One detail trips people up: mark a node as visited the moment it is enqueued, not when it is dequeued. If you wait until dequeue, the same neighbor can be enqueued by several different nodes before any of them process it. This wastes work and, on some graphs, never terminates.
Connected components β€” restart DFS/BFS from every unvisited node
function countComponents(nodes, adj):
    visited = {}
    count = 0
    for node in nodes:
        if node not in visited:
            count += 1
            dfs(node, adj, visited)    // or bfs: either one fully explores one component
    return count
A single dfs(start) sees only the component that start belongs to, the disconnected-friend-group case from The Idea. If you loop over every node and start a fresh traversal only when you reach one that is not yet visited, you guarantee that every component is found, not only the first one.
Cycle detection β€” undirected: track the parent
function hasCycleUndirected(node, parent, adj, visited):
    visited.add(node)
    for neighbor in adj[node]:
        if neighbor not in visited:
            if hasCycleUndirected(neighbor, node, adj, visited):
                return true
        elif neighbor != parent:      // visited, and NOT where you just came from
            return true
    return false
In an undirected graph, the edge that you just walked in always points back at you. A connects to B means B connects to A, so B will always see A in its visited set. That is not a cycle. That is just the edge that you arrived on. If you pass parent along and exclude it, you tell apart "I am looking at where I came from" from "I found a real second way back to a node that I have already seen".
Cycle detection β€” directed: track the current recursion path
function hasCycleDirected(node, adj, visited, onPath):
    visited.add(node)
    onPath.add(node)                  // the nodes on THIS call's path, right now
    for neighbor in adj[node]:
        if neighbor in onPath:        // still inside the call for this node: a cycle
            return true
        if neighbor not in visited:
            if hasCycleDirected(neighbor, adj, visited, onPath):
                return true
    onPath.remove(node)                // backtracking out: no longer on the path
    return false
The undirected trick does not carry over. In a directed graph, an edge points only one way. So a "visited" neighbor does not mean that you just walked in from there. It might be a completely different, already-finished branch with no relationship to the current one. onPath tracks only the nodes that the recursion has not returned from yet. A node stays on it from the moment dfs enters it until the moment that call returns. If you reach a neighbor that is still on that list, the graph has looped back into its own in-progress path: a real cycle, not a coincidence of order.
Know It

Both representations store the same graph. They only trade space for lookup speed in different ways. An adjacency list stores only the edges that exist. An adjacency matrix reserves a cell for every possible pair, whether an edge is there or not. Every traversal below (DFS, BFS, components, both cycle checks) visits each node one time and each edge one time (two times on an undirected graph, one time per direction it is stored). So they all share the same O(V+E) time bound, for any one that you run.

OperationTimeSpaceWhy
Adjacency list β€” spaceβ€”O(V + E)One entry per node, plus one entry per edge in the neighbor list of that node. Nothing is stored for pairs that are not connected.
Adjacency matrix β€” spaceβ€”O(VΒ²)A cell for every possible pair of nodes, whether an edge exists there or not. This wastes space on a sparse graph, but it is O(1) to check if two specific nodes are connected.
DFS / BFS traversalO(V + E)O(V)Every node is visited one time. Every edge is examined one time from each endpoint that stores it. The visited set (and the DFS call stack, or the BFS queue) holds at most every node one time.
Connected componentsO(V + E)O(V)The outer loop over every node adds only O(V). The real traversal work is still one DFS/BFS pass in total, split across the number of components that exist.
Cycle detection (directed or undirected)O(V + E)O(V)This is the same traversal shape as plain DFS, with one extra set (parent tracking, or onPath) that never holds more than one entry per node.
Break It

Forgetting the visited set entirely

the graph has any cycle at all

Without a visited check, dfs() or bfs() walks back onto a node that it already processed, follows the edges of that node again, and never runs out of places to go. This is an infinite loop, not a slow answer. A tree traversal never needed this, because a tree structurally cannot cycle back on itself. A graph can. And unlike the linked-list case, a cycle here is not a bug to detect. It is a normal input to survive.

Using the undirected cycle check on a directed graph, or the reverse

the parent-exclusion trick is applied to a directed graph, or the onPath trick is applied to an undirected one

These are genuinely different algorithms, not one algorithm with a small change. On a directed graph, a check of "visited and not the parent" gives false positives often. Two separate edges that point into the same node from different, unrelated branches trip it even with zero real cycle. On an undirected graph, onPath alone gives false positives too, because the edge back to the immediate parent is always technically "on the path" and is not a cycle. Match the check to the edge direction of the graph, every time.

A disconnected graph β€” one traversal from one start node

the graph has more than one component and code calls dfs(someStartNode) only one time

dfs or bfs from a single node explores exactly the component that the node lives in, and nothing else. Every node in a separate component is never visited, never counted, never processed, with no error. There is no crash, only a wrong answer that looks complete, because the traversal ran to the end without a failure. The fix from Build It (loop over every node and restart the traversal on anything still unvisited) is not an optimization. It is the difference between a correct answer and a quietly wrong one.

Self-loops and multi-edges

a node has an edge to itself, or two nodes are connected by more than one edge

Neither one is invalid input for a graph, even though neither one can happen in a tree. A self-loop (a node connects to itself) means that the node appears in its own neighbor list. A naive undirected cycle check can misfire on it, because the neighbor-equals-parent exclusion does not apply to a self-edge. Multi-edges (the same pair connected two times) mean that a node might appear more than once in the adjacency list of another node. The traversal still visits it only one time, thanks to the visited set. But a naive edge count (for example, to count components or validate a tree) can count too many if it does not remove duplicates.

Use It
Number of Islands
The grid is the graph. Each land cell is a node, and adjacent land cells are edges. Use countComponents(), but the neighbors come from grid coordinates instead of an adjacency list.
Medium
Flood Fill
Use dfs() from Build It on a grid. Visit a cell. Recurse into its 4 neighbors that match the starting color. Use the color change itself as the visited marker.
Easy
Max Area of Island
Use the same traversal as Number of Islands, but have dfs() return a count of the cells visited instead of nothing. Track the largest count that you see.
Medium
Find if Path Exists in Graph
Use dfs() or bfs() from Build It, starting at source. Return true the moment you reach destination or find it in visited.
Easy
Pacific Atlantic Water Flow
Use multi-source BFS/DFS. Start a traversal from every border cell that touches each ocean at the same time, and walk uphill instead of downhill. Then intersect the two visited sets.
Medium
Clone Graph
Use dfs() from Build It, but visited maps each original node to its clone instead of only a true/false mark. Check the map before you recurse, to avoid a clone of the same node two times.
Medium
Keys and Rooms
Rooms are nodes. Keys found in a room are edges to other rooms. Use dfs()/bfs() from room 0. Then check whether visited covers every room.
Medium
Is Graph Bipartite?
Use bfs()/dfs() from Build It, but instead of a visited set alone, give each node one of two colors. Alternate the color with every edge that you cross. A conflict (both endpoints of an edge share a color) means that no valid split exists.
Medium
Course Schedule
A prerequisite is a directed edge. Use hasCycleDirected() from Build It, exactly. A cycle means that the schedule is impossible.
Medium
Employee Importance
The subordinates of each employee are their neighbors. Use dfs()/bfs() from the target employee. Sum the importance across everyone visited.
Medium