Graphs, BFS and DFS

What a graph is, and how it's stored

Nodes (vertices, V) connected by edges (E). A tree is a graph with no cycles and exactly V − 1 edges; everything in 7a_basics_and_traversals.md is the special case where each node has at most two neighbours and there is no way to revisit a node.

Edges may be directed (one-way) or undirected, weighted or not. Those two axes decide which algorithm applies, so establish them before writing anything.

Adjacency list — the default

graph = {0: [1, 2], 1: [2], 2: [0], 3: []}
0 ──→ 1
│ ↖   │
↓   ╲ ↓
2 ────┘        3  (isolated)

Space O(V + E). Listing a node's neighbours is O(degree). This is the right representation for essentially every interview problem, because real graphs are sparse.

Adjacency matrix

     0  1  2  3
 0 [ 0  1  1  0 ]
 1 [ 0  0  1  0 ]
 2 [ 1  0  0  0 ]
 3 [ 0  0  0  0 ]

Space O(V²). Checking "is there an edge u→v" is O(1), but listing a node's neighbours costs O(V) even if it has one. Worth it only when the graph is dense or when edge lookups dominate.

Edge list

[(0,1), (0,2), (1,2), (2,0)]. Useless for traversal, but it is the input format problems usually hand you, and it is what Kruskal's algorithm and union-find consume directly. Converting an edge list to an adjacency list is the standard first three lines:

from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)      # omit this line for a directed graph

Complexity: O(V + E), and why it isn't O(V·E)

Both traversals visit each node once and examine each edge once (twice for undirected, once from each end). So

BFS = DFS = O(V + E) time, O(V) space

The V term covers nodes with no edges; the E term covers the neighbour scanning. Writing O(E) alone is wrong because isolated nodes still cost a visit. With an adjacency matrix both become O(V²), since finding neighbours is a full row scan.

For a grid of R × C cells, V = R·C and each cell has at most 4 edges, so E ≤ 4·R·C and the traversal is O(R·C) — the constant 4 vanishes.

The visited set is what makes this linear rather than infinite. A graph has cycles; without marking, traversal loops forever. Mark a node when you enqueue it in BFS, not when you dequeue it, or the same node gets added by several neighbours and the queue grows quadratically.

DFS — go deep

Follow one path as far as it goes, then back up and try the next branch.

        0
       / \        DFS from 0:  0 → 1 → 3 → 4 → 2
      1   2       (fully explores 1's subtree before touching 2)
     / \
    3   4
def dfs(node, seen):
    seen.add(node)
    for nxt in graph[node]:
        if nxt not in seen:
            dfs(nxt, seen)

Recursive is the natural form; the call stack is the path back. Iterative uses an explicit stack (see 2d_stacks.md) and is the fix when depth may exceed the recursion limit — O(V) frames on a path graph.

Use DFS for: connected components, cycle detection, topological sort, flood fill, anything asking about existence of a path, and anything where a node's answer depends on its subtree.

BFS — go wide

Visit all nodes at distance 1, then all at distance 2, and so on.

        0
       / \        BFS from 0:  0 | 1, 2 | 3, 4
      1   2       (level by level)
     / \
    3   4
from collections import deque

def bfs(start):
    seen = {start}
    q = deque([start])
    dist = 0
    while q:
        for _ in range(len(q)):        # one full level
            node = q.popleft()
            for nxt in graph[node]:
                if nxt not in seen:
                    seen.add(nxt)      # mark on enqueue
                    q.append(nxt)
        dist += 1

BFS finds shortest paths in unweighted graphs, and DFS does not. The reason is the visiting order: BFS reaches every node in order of increasing distance from the source, so the first time it arrives at a node it has arrived by a shortest route. DFS may reach the same node by a long detour first and mark it visited, locking in the wrong distance.

That guarantee dies the moment edges carry different weights — three cheap edges can beat one expensive one, and BFS counts hops, not cost. Weighted shortest paths need Dijkstra, which is BFS with a min-heap in place of the queue so that the cheapest frontier node is expanded next rather than the nearest in hops (see 2g_heaps_and_priority_queues.md).

Use BFS for: shortest path / minimum steps in an unweighted graph or grid, level-order processing, and multi-source spread (rotting oranges, walls and gates — seed the queue with all sources before the loop and the level count is the answer for every one of them at once).

Choosing between them

Question asked Use
Is there a path? Are these connected? either — DFS is shorter to write
Fewest steps / shortest unweighted path BFS
Shortest path with weights Dijkstra (min-heap)
All nodes in a component, component count either
Cycle detection, topological order DFS
Something per level / per distance BFS
Deep graph, recursion limit a risk BFS, or iterative DFS

Space differs in the worst case: DFS holds one path (O(V) on a chain, O(log V) on a balanced tree), BFS holds a whole level (O(V) on a wide graph). Neither dominates.

The patterns worth recognising

Connected components / islands. Loop over every node; when you find an unvisited one, run a traversal from it and increment a counter. The traversal consumes the whole component, so the outer loop stays O(V) overall.

count = 0
for node in range(V):
    if node not in seen:
        dfs(node, seen)
        count += 1

Grids are graphs. A cell (r, c) has neighbours (r±1, c) and (r, c±1). No adjacency list is needed — generate neighbours on the fly and bounds-check.

for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
    nr, nc = r + dr, c + dc
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == '1':
        ...

Mutating the grid in place (grid[r][c] = '0') is a legitimate O(1)-space substitute for a visited set when the problem permits destroying the input.

Cycle detection. In an undirected graph, a cycle exists if DFS reaches an already-visited node that is not the parent it came from. In a directed graph that test is wrong — a visited node may simply be in a finished branch. Directed cycles need three states:

white = unvisited,  grey = on the current recursion path,  black = fully explored

reaching a grey node → back edge → cycle
reaching a black node → fine, that subgraph is already done

Topological sort. A linear ordering of a DAG where every edge points forward; the answer to "course schedule", "build order", "task dependencies". Two implementations:

  • Kahn's (BFS): compute in-degrees, enqueue every node with in-degree 0, and on dequeue decrement each neighbour's in-degree, enqueueing it when it hits 0. If fewer than V nodes come out, the graph has a cycle. O(V + E).
  • DFS: push a node onto an output list after its recursion finishes, then reverse the list. Post-order guarantees dependents are emitted before dependencies.

Union-find (disjoint set union). Not a traversal — a structure for "are these two in the same group" under a stream of merges. Near O(1) per operation with path compression and union by rank. Reach for it when connectivity queries interleave with edge additions, or for Kruskal's MST; a plain DFS suffices when the edges are all known up front.

Bipartite / 2-colouring. BFS or DFS assigning alternating colours; a conflict with an already-coloured neighbour means an odd cycle, so not bipartite.

Pitfalls

  • Marking visited on dequeue instead of enqueue. The node gets queued once per incoming edge; correctness survives, the complexity does not.
  • Treating a directed graph as undirected, or the reverse, when building the adjacency list. One missing line changes every answer.
  • Using BFS for weighted shortest paths. It returns hop count, which looks like a plausible distance and is wrong.
  • Forgetting isolated nodes. Traversing from node 0 only visits node 0's component; component counting needs the outer loop over all nodes.
  • Undirected cycle detection without the parent check. Every single edge looks like a 2-cycle otherwise, since u → v → u.
  • Recursion depth. A 10⁵-node path graph overflows Python's default limit of 1000 frames.