Graph Algorithms & Search
Navigate complex relationships using BFS, DFS, and shortest path algorithms
SPACED REPETITION Β· 20 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 20Most graph problems never say "graph"
A lock has four wheels, each showing a digit from 0 to 9. It starts at 0000. One move turns one wheel one notch up or down, and 9 wraps around to 0. A few combinations are dead ends: land on one and the lock jams. What is the fewest number of moves to reach 0202? This is LeetCode 752: Open the Lock.
The obvious plan tries move sequences, shortest first. Every position offers 8 moves (4 wheels Γ 2 directions), so there are 8α΅ sequences of length d. The farthest combination, 5555, is 20 moves from 0000. Trying every sequence up to length 20 means about 1.3 Γ 10ΒΉβΈ sequences. At a billion per second, that takes over 40 years.
But the lock has only 10,000 positions. Almost every one of those sequences revisits a position that another sequence already reached. Up on wheel 1, then down on wheel 1 comes straight back to 0000. Wheel 1 then wheel 2 lands exactly where wheel 2 then wheel 1 does. The slow plan keeps asking "how far away is this position?" about positions it has already answered. Treat each position as a node and each move as an edge, expand each node once, and the whole search costs at most 10,000 Γ 8 = 80,000 neighbor checks.
That is the lesson in two sentences. Decide what a node is and what an edge is. Then read the question again, because the question picks the algorithm. The cost depends on how many nodes and edges your model has, not on how many paths exist.
| The problem asks | Tool | Signature problem |
|---|---|---|
| Can I get from here to there? Which things belong together? | Any traversal (DFS or BFS) | Number of Islands |
| What is the fewest number of moves? | BFS | Open the Lock |
| What is the cheapest route, when moves cost different amounts? | Dijkstra (costs β₯ 0) | Network Delay Time |
| In what order can I do these tasks, and is it possible at all? | Topological sort | Course Schedule II |
| Edges keep arriving: are these two connected yet? | Union-find | Redundant Connection |
You should be comfortable with Python sets, dictionaries and tuples as keys (Hash Maps & Sets), and with recursion and the call stack (Advanced Recursion & Backtracking). This lesson is the map: modelling, storage, choosing the algorithm, and union-find in full. Graph Traversal takes DFS and BFS apart in depth, and Shortest Path Algorithms covers Dijkstra and its relatives.
Step 1: Model it β what is a node, what is an edge?
Smell: the statement talks about things and the moves or relationships between them. Cells you can walk between, words one letter apart, courses that unlock other courses, cables between computers.
Three shapes of graph
- Explicit. You are handed
nand a list of pairs, such asedges = [[0, 1], [1, 2]]. Course Schedule and Redundant Connection look like this. - Grid. Each cell is a node, and edges join neighboring cells, usually in 4 directions and only through open cells. Number of Islands and Rotting Oranges look like this.
- Implicit, or state graph. Nodes are configurations you generate on the fly, and edges are legal moves. You never store the whole graph. Open the Lock, Word Ladder and sliding-tile puzzles look like this.
A little vocabulary, used throughout:
| Term | Meaning | Example |
|---|---|---|
| Directed / undirected | An edge goes one way / both ways | Prerequisites / friendships |
| Weighted | Edges carry costs | Road lengths in minutes |
| Degree | Number of edges at a node (in- and out-degree when directed) | Every lock position has degree 8 |
| Path, cycle | A chain of edges; a path that returns to its start | 0 β 1 β 2 β 0 is a cycle |
| Connected component | A largest group of nodes that can all reach each other (undirected) | One island |
| DAG | A directed graph with no cycle | A course plan that can actually be finished |
| V, E | Number of nodes, number of edges | The lock: V = 10,000, E = 40,000 |
The lock has 10,000 Γ 8 / 2 = 40,000 edges, because each undirected edge touches two positions. A simple undirected graph has at most V(V β 1)/2 edges. Graphs with E close to V are sparse; graphs with E close to VΒ² are dense. Most LeetCode graph inputs are sparse, with E a small multiple of V.
Translate before you code
Before touching the keyboard, fill in one row of this table:
| Problem | Node | Edge | Question | Tool |
|---|---|---|---|---|
| Open the Lock | A 4-digit string | One notch on one wheel, never into a dead end | Fewest moves | BFS |
| Number of Islands | A land cell | Two land cells side by side | How many groups | Any traversal |
| Course Schedule II | A course | Prerequisite β course | An order that respects every edge | Topological sort |
| Redundant Connection | A node | A given edge | First edge joining already-connected nodes | Union-find |
If you cannot fill in a column, you are not ready to pick an algorithm yet.
Arrows have a direction, and you choose it
In LeetCode 210: Course Schedule II, prerequisites[i] = [a, b] means "take b before a". Draw the edge b β a, read as "b unlocks a". A valid plan then lists every edge's tail before its head.
You could draw a β b ("a needs b") instead. That graph is just as correct, but the same algorithm then produces the plan backwards, with dependents first. Neither direction is wrong. Mixing them is. Pick one, write it in a comment, and build every edge the same way.
Predict first: with prerequisites = [[1, 0], [2, 1]] and edges drawn b β a, which course has no incoming edge, and what does that tell you?
Check your answer
The edges are 0 β 1 and 1 β 2. Course 0 has no incoming edge: nothing must come before it, so it can be taken first. The plan is 0, 1, 2. With the edges drawn the other way, the course without an incoming edge would be 2, and "take 2 first" is exactly backwards.
A node is everything the future depends on
Smell: the same place can be good or bad depending on something you carry, such as keys collected, walls you may still break, or the direction you face.
Take LeetCode 1293: Shortest Path in a Grid with Obstacles Elimination. Walk from the top-left to the bottom-right of a grid of 0 (open) and 1 (wall). You may break at most k walls. What is the fewest number of steps? The tempting node is the cell (r, c). But two arrivals at the same cell are not interchangeable: one that can still break a wall may smash through later, and one that cannot is stuck.
Here is a 4 Γ 2 grid with k = 1:
col 0 col 1
row 0 S .
row 1 # .
row 2 # #
row 3 # T
The only route that works breaks the wall at (2, 1): S β (0, 1) β (1, 1) β (2, 1) β T, 4 steps. But (1, 1) can also be reached in 2 steps by breaking (1, 0). A search that looks at "down" before "right" gets there that way first, with no breaks left. If its seen set holds bare cells, it marks (1, 1) as done. The later arrival through (0, 1), which still has its break, is thrown away, and the search reports β1. The fix is to make the node (row, col, breaks left). That multiplies the node count by (k + 1), and it is exactly the information the future needs. Challenge 3 of the final round solves it in full.
The rule cuts both ways. Put everything that affects future moves into the node, and nothing else. In particular, don't put the step count in the node. Two arrivals at the same place after the same number of steps would still merge, but arrivals after different numbers of steps would not, so every position gets re-explored once per distance. On the lock, the first 20 moves produce 57,500 (position, steps) pairs where plain positions number 10,000, and the count keeps growing: if the target is unreachable, the search never ends.
Your turn: model it. LeetCode 433: Minimum Genetic Mutation gives gene strings of 8 characters from A, C, G, T. One mutation changes one character, and every gene you pass through, including the end, must be in bank (at most 10 strings). Find the fewest mutations from start to end, or β1. Before writing code: what is a node, what is an edge, how many candidate neighbors does one gene have, how many nodes can the search ever visit, and which tool fits?
Check your answer
A node is a gene string. An edge changes one character, and the result must be in the bank. Each gene has 8 Γ 3 = 24 real neighbors (any position, any of the 3 other letters). The loop below also rebuilds the gene itself once per position; seen filters those 8 copies out. There are 4βΈ = 65,536 possible genes, but the search only enters bank genes, so it visits at most 11 nodes: the bank plus the start. Every mutation counts 1 and the question says "fewest", so the tool is BFS:
from collections import deque
def min_mutation(start, end, bank):
allowed = set(bank)
if end not in allowed:
return -1
seen = {start}
queue = deque([(start, 0)])
while queue:
gene, steps = queue.popleft()
if gene == end:
return steps
for i in range(len(gene)):
for ch in "ACGT":
nxt = gene[:i] + ch + gene[i + 1:]
if nxt in allowed and nxt not in seen:
seen.add(nxt)
queue.append((nxt, steps + 1))
return -1
print(min_mutation("AACCGGTT", "AAACGGTA", ["AACCGGTA", "AACCGCTA", "AAACGGTA"])) # 2
The neighbor generator builds candidates from the rule and keeps only bank genes. It never builds a graph.
Step 2: Store it β representations and what they cost
Smell: the input is a list of pairs, but your search keeps asking "who are the neighbors of u?"
Four ways to hold a graph
- Edge list:
[(u, v), ...], the way input usually arrives. Finding u's neighbors means scanning every edge. - Adjacency list:
graph[u]is a list of u's neighbors. This is the default for searching. - Adjacency matrix:
M[u][v]is 1 (or a weight) when the edge exists. You get an O(1) edge check at the price of VΒ² cells. - Neighbor function: a function that generates u's neighbors when asked, as in the lock. Nothing is stored.
| Edge list | Adjacency list | Adjacency matrix | Neighbor function | |
|---|---|---|---|---|
| Space | O(E) | O(V + E) | O(VΒ²) | nothing stored |
| Neighbors of u | O(E) | O(deg u) | O(V) | cost of generating them |
| Is uβv an edge? | O(E) | O(deg u) | O(1) | depends on the rule |
| One full traversal | β | O(V + E) | O(VΒ²) | nodes Γ cost per expansion |
Put numbers on it. With V = 100,000 and E = 200,000, a matrix needs 10ΒΉβ° cells, which is 10 GB even at one byte per cell. The adjacency list holds 400,000 neighbor entries (each undirected edge is stored twice, once at each end) in 100,000 lists.
A matrix is fine when V is small (1,000 nodes is 10βΆ cells) or the graph is dense. Sometimes the input already is a matrix. Then scanning a node's row costs V no matter how few neighbors it has, so any traversal costs Ξ(VΒ²).
Building the list, and two Python traps
def build_graph(n, edges, directed=False):
graph = [[] for _ in range(n)] # one fresh list per node
for u, v in edges:
graph[u].append(v)
if not directed:
graph[v].append(u)
return graph
print(build_graph(4, [[0, 1], [0, 2], [1, 2]])) # [[1, 2], [0, 2], [0, 1], []]
Node 3 has no edges, and it still gets an empty list. That matters.
Predict first: what does this print?
graph = [[]] * 3
graph[0].append(1)
print(graph)
Check your answer
[[1], [1], [1]]. [[]] * 3 makes three references to one list, so appending to "node 0's" list appends to everybody's. The comprehension [[] for _ in range(n)] builds a fresh list per node. This bug gives no error; every node just seems adjacent to everything anyone ever touched.
β οΈ Isolated nodes vanish from edge-built dictionaries. If you build graph = defaultdict(list) from the edges alone, a node with no edges never becomes a key, and for u in graph skips it. A component count then comes out one short per isolated node. When nodes are 0..n-1, build from range(n) as above. When labels are words or coordinates, a defaultdict is right, but keep the full node list separately.
Grids: the graph you never build
A grid is already an adjacency structure. Generate neighbors with direction vectors:
DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1)] # down, up, right, left
def grid_neighbors(grid, r, c):
rows, cols = len(grid), len(grid[0])
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols: # both bounds, before indexing
yield nr, nc
β οΈ Negative indexes don't crash in Python. Check only nr < rows and nr = -1 slips through: grid[-1] quietly reads the last row. There is no IndexError, just a wrong answer, and it shows up only when an edge cell's wrap-around partner happens to be open. Check both bounds, every time.
Size a grid before you search it. An R Γ C grid has V = RΒ·C nodes. With 4-directional moves it has R(C β 1) horizontal and (R β 1)C vertical edges, fewer than 2RC in total. A 300 Γ 300 grid is 90,000 nodes and 179,400 edges, so any O(V + E) search is O(RΒ·C).
Your turn: a 3 Γ 4 grid with every cell open. How many nodes, edges and adjacency-list entries? How many cells have 2, 3 and 4 neighbors?
Check your answer
12 nodes. Edges: 3 Γ 3 = 9 horizontal and 2 Γ 4 = 8 vertical, so 17, and 34 adjacency entries. Degrees: the 4 corners have 2 neighbors, the 6 other border cells have 3, and the 2 inner cells have 4. Check: 4Β·2 + 6Β·3 + 2Β·4 = 34 = 2 Γ 17. The degrees always add up to twice the edge count, because every edge has two ends. That makes a quick sanity check for any adjacency list you build.
Step 3: Search it β one loop, and the container decides
Smell: "can I reachβ¦", "how many groupsβ¦", "what is the fewestβ¦".
Reachability: any container works
Every graph search is the same loop. A seen set remembers who has been discovered, and a frontier holds discovered nodes whose neighbors haven't been looked at yet.
def reachable(graph, start):
seen = {start}
frontier = [start]
while frontier:
node = frontier.pop()
for nxt in graph[node]:
if nxt not in seen:
seen.add(nxt) # mark when discovered
frontier.append(nxt)
return seen
Invariant: everything in seen is reachable from start, and each node enters the frontier at most once. So the loop pops at most V nodes and reads each adjacency list once: O(V + E) time, O(V) extra space. In an undirected graph each edge is read twice, once from each end, which is still O(E).
For reachability the container doesn't matter. Pop from the end (a stack) or from the front (a queue), and you get the same set of nodes in a different order. DFS dives deep first and is most naturally written with recursion. The stack loop above dives too, although its visiting order can differ from a true DFS; that matters only when the order itself is the answer. BFS takes the oldest discovery first.
Counting groups means restarting the loop from every node that no earlier search reached:
def count_components(n, edges):
graph = build_graph(n, edges)
seen = [False] * n
count = 0
for s in range(n):
if seen[s]:
continue
count += 1 # s starts a component nobody reached yet
seen[s] = True
stack = [s]
while stack:
u = stack.pop()
for v in graph[u]:
if not seen[v]:
seen[v] = True
stack.append(v)
return count
print(count_components(5, [[0, 1], [1, 2], [3, 4]])) # 2
print(count_components(6, [[0, 1], [1, 2], [3, 4]])) # 3: node 5 is alone
Every node is pushed once over all the restarts, so the total is still O(V + E).
Fewest moves: the queue earns its keep
Now the question is "how many edges from the start?" Here the container matters.
from collections import deque
def bfs_distances(graph, start):
dist = {start: 0}
queue = deque([start])
while queue:
node = queue.popleft() # oldest discovery first
for nxt in graph[node]:
if nxt not in dist: # dist doubles as the seen set
dist[nxt] = dist[node] + 1
queue.append(nxt)
return dist
Run it from 0 on this six-node ring, built with build_graph(6, [[0, 1], [1, 2], [0, 3], [3, 4], [4, 5], [2, 5]]):
0 --- 1 --- 2
| |
3 --- 4 --- 5
| Pop | Its distance | Newly discovered | Queue afterward |
|---|---|---|---|
| 0 | 0 | 1 (dist 1), 3 (dist 1) | [1, 3] |
| 1 | 1 | 2 (dist 2) | [3, 2] |
| 3 | 1 | 4 (dist 2) | [2, 4] |
| 2 | 2 | 5 (dist 3) | [4, 5] |
| 4 | 2 | β | [5] |
| 5 | 3 | β | [] |
The result is {0: 0, 1: 1, 3: 1, 2: 2, 4: 2, 5: 3}.
Predict first: change queue.popleft() to queue.pop(), so the deque behaves as a stack. Which distance comes out wrong?
Check your answer
dist[2] becomes 4 instead of 2. After 0, the stack pops 3 (the newest discovery), then 4, then 5. Node 5 discovers 2 and labels it 5's distance + 1 = 4 before node 1, sitting at the bottom of the stack, ever gets a turn. The set of keys is still all six nodes: reachability survived, distance did not.
Why the queue gives fewest edges. Invariant: the distances in the queue never decrease from front to back, and they span at most two values, d and d + 1. Nodes therefore leave the queue in order of distance. Suppose w's true distance is d + 1. Then w has a neighbor at distance d, and that neighbor leaves the queue before any node at distance d + 1 or more. So the first node to discover w is at distance d, and w gets label d + 1. The first label is final.
That argument needs every edge to count the same. With costs on the edges, "fewest edges" and "cheapest" are different questions (Step 4).
Mark on discovery. Both functions mark a node when it is added, not when it is popped. Mark at pop time instead, and a node can sit in the queue once per incoming edge. If you also skip already-marked nodes as they are popped, the answers stay right, but the queue grows from O(V) to O(E) entries. Graph Traversal covers when to mark, and why DFS order is different, in depth.
Depth, not size, breaks recursion. A recursive DFS uses one Python frame per level of depth, and the default limit is 1,000 frames (sys.getrecursionlimit()). A 100,000-node star is only two levels deep and runs fine. A path of 1,000 nodes already raises RecursionError. Grids get deep fast: a recursive flood fill on an all-land 300 Γ 300 grid goes 90,000 calls deep. sys.setrecursionlimit raises the ceiling, but the Python docs warn that a limit set too high can crash the interpreter. An explicit stack, as above, or a queue avoids the question.
β οΈ Marking a grid in place (overwriting visited land '1' with '0') replaces the seen set. It also changes the caller's grid, so check the output contract. And it does not make the search O(1) space: the frontier can still hold a large share of the cells. An explicit-stack flood fill on an all-land 300 Γ 300 grid peaks at 44,851 stack entries, about half the grid.
Back to the lock
Here is the hook problem, with a neighbor function instead of a stored graph:
from collections import deque
def open_lock(deadends, target):
seen = set(deadends) # dead ends act as already seen: never entered
if "0000" in seen:
return -1
seen.add("0000")
queue = deque([("0000", 0)])
while queue:
state, moves = queue.popleft()
if state == target:
return moves
for i in range(4):
digit = int(state[i])
for step in (1, -1):
nxt = state[:i] + str((digit + step) % 10) + state[i + 1:]
if nxt not in seen:
seen.add(nxt)
queue.append((nxt, moves + 1))
return -1
print(open_lock(["0201", "0101", "0102", "1212", "2002"], "0202")) # 6
Without dead ends 0202 is 4 moves away; the dead ends force a detour of 6. Three details decide whether it passes:
- Dead ends are pre-marked as seen, so the search never enters one. LeetCode promises the target is not a dead end. Drop that promise and this version still answers β1 for a dead target, while a version that compares
nxt == targetbefore checking the dead ends returns a number for a lock that can never open. - The target test happens at pop time, so
target == "0000"returns 0 without a special case. (digit + step) % 10wraps 9 β 0 and 0 β 9. Python's%never returns a negative for a positive modulus, so β1 becomes 9.
Cost: at most 10,000 states, each generating 8 neighbors, and each neighbor is a 4-character string built in O(4). That is about 320,000 character copies, with expected O(1) set lookups.
π‘ Two searches that meet in the middle. When both ends are known, you can run BFS from the start and from the target, expanding the smaller frontier each round, and stop when they touch. With b moves per state and the goal d moves away, one search can touch on the order of
b**dstates, and two half-depth searches about2 * b**(d/2). For b = 10 and d = 6, that is 1,000,000 against 2,000. The backward search must follow edges backwards. Lock turns and one-letter word changes are reversible, so the same neighbor function works. For one-way edges, build the reversed edges first. On the lock itself the gain is small, since only 10,000 states exist anyway.
Many starts, one queue
Smell: "the distance to the nearest X" for every cell, or something spreading from many places at once.
LeetCode 542: 01 Matrix asks, for every cell, the number of steps to the nearest 0. Running one BFS per 0 works, but with 1,000 zeros on a 100 Γ 100 grid that is 1,000 Γ 10,000 = 10β· cell visits, against 10β΄ for a single pass. Instead, put every source in the queue at distance 0 before the loop starts:
from collections import deque
def nearest_zero(mat):
rows, cols = len(mat), len(mat[0])
dist = [[-1] * cols for _ in range(rows)]
queue = deque()
for r in range(rows):
for c in range(cols):
if mat[r][c] == 0:
dist[r][c] = 0 # every source starts at distance 0
queue.append((r, c))
while queue:
r, c = queue.popleft()
for nr, nc in grid_neighbors(mat, r, c): # from Step 2
if dist[nr][nc] == -1:
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
return dist
print(nearest_zero([[0, 0, 0], [0, 1, 0], [1, 1, 1]])) # [[0, 0, 0], [0, 1, 0], [1, 2, 1]]
Why it works: picture one invisible super-source joined to every 0 by an edge. A BFS from it reaches all the zeros at distance 1 and everything else one step later than its true distance. Starting with all zeros at distance 0 is that same BFS with the invisible first step removed. The queue starts sorted (all zeros), so the invariant from the last section holds. One pass, O(RΒ·C).
Your turn: predict nearest_zero([[1, 1, 1, 1], [1, 1, 1, 1], [0, 1, 1, 0]]). Then say what a single-source BFS from the bottom-left 0 would get wrong.
Check your answer
[[2, 3, 3, 2],
[1, 2, 2, 1],
[0, 1, 1, 0]]
The two waves start at both bottom corners and meet in the middle columns. A BFS from the bottom-left 0 alone reports the distance to that zero: the top-right cell would get 5 instead of 2, because the other zero never took part.
Step 4: Let the question pick the algorithm
You have nodes, edges and a representation. Now read the question once more.
When moves have prices
BFS minimizes the number of edges. When edges carry costs, that is no longer the question:
A --1-- B --1-- C
\_______5_____/
BFS labels C with distance 1, via the direct edge, which costs 5. The cheapest route is A β B β C, costing 2. Pick the tool by the kind of costs:
| Edge costs | Tool | Time | Why |
|---|---|---|---|
| All equal | BFS | O(V + E) | Edge count is the cost |
| Only 0 or 1 | 0-1 BFS | O(V + E) | A deque: push a 0-cost neighbor at the front, a 1-cost neighbor at the back |
| Any value β₯ 0 | Dijkstra | O((V + E) log V) | A min-heap replaces the queue; the closest unfinished node is popped next |
| Some negative | Bellman-Ford | O(V Β· E) | Relax every edge in rounds; no "popped means final" |
| "At most K edges" | Bellman-Ford rounds, or Dijkstra over (node, edges used) states | varies | The limit belongs in the state |
0-1 BFS keeps the queue invariant from Step 3. A 0-cost neighbor has the same distance as the node being expanded, so it goes to the front, and the deque stays sorted. One rule from Step 3 does not carry over: don't mark on discovery. A node first reached through a 1-cost edge may later be reached through a 0-cost edge, so lower its distance (and push it again) whenever you find a cheaper route. A node's distance is final when it is popped.
Dijkstra rests on one promise: when a node leaves the heap, its distance is final. That is true when costs are β₯ 0, because every later route only adds cost. A negative edge breaks the promise. With A β B cost 2, A β C cost 3 and C β B cost β2, B leaves the heap at 2. The route A β C β B costs 1, but it only appears after C is popped, too late for an implementation that treats popped nodes as final. Shortest Path Algorithms builds all of these properly; Heaps & Priority Queues explains the heap.
Predict first: a delivery app stores roads with travel times in minutes, all positive. It wants the fewest minutes from the depot to every address. A teammate proposes BFS "because it's a shortest-path problem". What do you say?
Check your answer
BFS would minimize the number of roads, not minutes. A route of three 2-minute roads beats a single 10-minute road, but BFS prefers the single road. With positive costs, use Dijkstra.
"In what order?" β topological sort
Smell: prerequisites, build steps, "X must happen before Y".
A topological order lists the nodes so that every edge points forward. It exists exactly when the directed graph has no cycle: on a cycle, every node waits for the one before it, forever. Kahn's algorithm builds the order greedily. Repeatedly take a node with in-degree 0 (nothing left must come before it), output it, and delete its outgoing edges.
from collections import deque
def course_order(n, prerequisites):
graph = [[] for _ in range(n)]
indegree = [0] * n
for course, before in prerequisites: # [a, b] means: take b before a
graph[before].append(course) # edge b -> a
indegree[course] += 1
ready = deque(c for c in range(n) if indegree[c] == 0)
order = []
while ready:
c = ready.popleft()
order.append(c)
for nxt in graph[c]:
indegree[nxt] -= 1 # one prerequisite of nxt is done
if indegree[nxt] == 0:
ready.append(nxt)
return order if len(order) == n else [] # short output: a cycle blocked the rest
Trace course_order(4, [[1, 0], [2, 0], [3, 1], [3, 2]]). The edges are 0 β 1, 0 β 2, 1 β 3 and 2 β 3:
0
/ \
v v
1 2
\ /
v
3
| Step | Order so far | indegree of 0, 1, 2, 3 | Ready |
|---|---|---|---|
| start | [] | [0, 1, 1, 2] | [0] |
| take 0 | [0] | [0, 0, 0, 2] | [1, 2] |
| take 1 | [0, 1] | [0, 0, 0, 1] | [2] |
| take 2 | [0, 1, 2] | [0, 0, 0, 0] | [3] |
| take 3 | [0, 1, 2, 3] | [0, 0, 0, 0] | [] |
Course 3 waits until both 1 and 2 are done: its in-degree drops to 1, then to 0. Swap the pairs by mistake, building a β b, and the same code returns [3, 1, 2, 0]: the plan backwards.
Why it works. A node enters ready only when every one of its prerequisites is already in order, so every edge points forward. If a cycle exists, no node on it ever reaches in-degree 0, and neither does anything downstream of it. The output comes up short, and the function returns []. That also answers LeetCode 207: Course Schedule ("can you finish?"): return len(order) == n. Cost: O(V + E) time, O(V + E) space.
π‘ The order is also a schedule for computing. When a node comes out of
ready, every predecessor has already come out. So Kahn's order is the right order to fill in any value that depends on all of a node's predecessors, such as the longest chain of prerequisites leading to each course. That is dynamic programming on a DAG, and it costs nothing beyond the sort itself.
β οΈ Seen twice is not a cycle. In the diagram above, node 3 is reached from 1 and from 2, and there is no cycle. A directed cycle check that says "already seen, so cycle!" reports a false cycle on this diamond. Kahn's algorithm counts in-degrees instead. The DFS version tracks which nodes are on the current path, which Graph Traversal builds step by step.
Your turn: predict course_order(4, [[1, 0], [2, 1], [3, 2], [1, 3]]). Which courses get taken, and which are blocked?
Check your answer
The edges are 0 β 1, 1 β 2, 2 β 3 and 3 β 1. Only course 0 starts ready. Taking it drops course 1's in-degree from 2 to 1, not to 0, because 1 also waits for 3. Courses 1, 2 and 3 form the cycle 1 β 2 β 3 β 1, and each waits for the one before it. order is [0], shorter than 4, so the function returns [].
"Every path" is a different question
"List every route" or "every arrangement" asks for output that can be exponential in size. That calls for backtracking DFS (Advanced Recursion & Backtracking): unmark a node on the way back so other paths may use it. A seen set that never forgets would find each node once, which is right for reachability and wrong here.
The map
| The question | Check first | Tool | Time |
|---|---|---|---|
| Can I reach it? Which group? (once) | β | DFS or BFS | O(V + E) |
| Fewest moves | Every move costs the same | BFS | O(V + E) |
| Fewest moves from the nearest of many sources | Same | Multi-source BFS | O(V + E) |
| Cheapest route | Costs 0/1; β₯ 0; negative | 0-1 BFS; Dijkstra; Bellman-Ford | see above |
| An order respecting "before" rules | Directed; cycle means impossible | Topological sort (Kahn) | O(V + E) |
| Connected yet, while edges keep arriving | Undirected; edges only added | Union-find | O(Ξ±(n)) amortized per operation |
| Connect everything at least total cost | Undirected, weighted | Minimum spanning tree (Kruskal) | O(E log E) |
| List every path or arrangement | Output can be exponential | Backtracking DFS | output-sized |
Step 5: Union-find β groups that keep merging
Smell: edges arrive one at a time, and in between you're asked "are these two connected now?" Or: "which edge is the first to join two things that were already joined?"
The slow way: search again after every cable
A network has 100,000 computers. Then 100,000 cables are plugged in one at a time, and after each one someone asks whether two given machines can talk. Re-running a traversal per question costs up to O(V + E) = 200,000 steps, and 100,000 questions make that 2 Γ 10ΒΉβ°. The graph only ever grows, yet every search starts from nothing and rediscovers groups it has already found.
Parent pointers and roots
Keep one representative (a root) per group. Every node stores a parent, and following parents always ends at its group's root. Two nodes are connected exactly when they reach the same root. A new edge merges two groups by pointing one root at the other.
n = 6
parent = list(range(n)) # everyone starts as their own root
def find(x):
while parent[x] != x:
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b) # root of a's group now hangs under b's root
Link the roots, not the nodes. parent[a] = b would tear a, and whatever hangs below it, away from the rest of its old group.
Plot twist: the forest grows a beanstalk
Call union(0, i) for i = 1, 2, β¦, n β 1. Each call hangs the old root under the new node, so the tree becomes a single chain, and every find(0) walks the whole chain. That adds up to exactly (n β 1)(n β 2)/2 parent hops. For n = 100,000 that is 4,999,850,001, five billion hops to process a hundred thousand edges. The slow search is back, hiding inside find.
Two fixes: union by size and path halving
- Union by size: always hang the smaller tree under the larger root. A node gets one level deeper only when its tree is merged into one at least as large, so the size of its tree at least doubles. That can happen at most logβ n times, so no node is ever deeper than logβ n: 16 for n = 100,000.
- Path halving: while walking up in
find, point each visited node at its grandparent. Every walk makes the next one shorter.
With both, a long sequence of operations costs O(Ξ±(n)) amortized per operation. Ξ± is the inverse Ackermann function, which is at most 4 for any input that fits in memory. That is "about constant" with a proof behind it.
class DSU:
def __init__(self, n):
self.parent = list(range(n)) # every node starts as its own root
self.size = [1] * n
self.groups = n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # make ra the bigger root
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.groups -= 1
return True
find is a loop, not a recursion, so no chain can hit the recursion limit. On the beanstalk sequence above, this version makes zero parent hops: every union(0, i) hangs the single node i under the big root.
Trace DSU(6) through six unions:
| Call | Returns | parent | size | groups |
|---|---|---|---|---|
| start | β | [0, 1, 2, 3, 4, 5] | [1, 1, 1, 1, 1, 1] | 6 |
| union(0, 1) | True | [0, 0, 2, 3, 4, 5] | [2, 1, 1, 1, 1, 1] | 5 |
| union(2, 3) | True | [0, 0, 2, 2, 4, 5] | [2, 1, 2, 1, 1, 1] | 4 |
| union(1, 3) | True | [0, 0, 0, 2, 4, 5] | [4, 1, 2, 1, 1, 1] | 3 |
| union(4, 5) | True | [0, 0, 0, 2, 4, 4] | [4, 1, 2, 1, 2, 1] | 2 |
| union(0, 2) | False | [0, 0, 0, 2, 4, 4] | [4, 1, 2, 1, 2, 1] | 2 |
| union(3, 5) | True | [0, 0, 0, 0, 0, 4] | [6, 1, 2, 1, 2, 1] | 1 |
Before the last call, the forest looks like this:
0 4
/ \ |
1 2 5
|
3
In union(3, 5), find(3) walks 3 β 2 β 0, and halving re-points 3 straight at 0 on the way. The size-4 root 0 then adopts the size-2 root 4. Only a root's size means anything: size[2] still says 2, a stale leftover like the cells past the returned length in Foundation: Arrays & Strings.
Invariants: each root's size is the number of nodes in its tree, and two nodes have the same root exactly when the edges so far connect them.
Predict first: after the table, call find(5). What does it return, and what does parent look like afterward?
Check your answer
It returns 0. The walk is 5 β 4 β 0, and halving points 5 at its grandparent 0, so parent becomes [0, 0, 0, 0, 0, 0]. Every node now hangs directly under the root, and the next find on any non-root node takes one hop.
The edge that closes a loop
union returning False means a and b were already connected. Adding an edge between them creates a second route, so the new edge closes a cycle. That makes undirected cycle detection three lines. LeetCode 684: Redundant Connection gives a tree on nodes 1..n plus one extra edge, and asks for the edge to delete. If several would work, return the one that appears last in the input.
def find_redundant_connection(edges):
dsu = DSU(len(edges) + 1) # labels 1..n, and n == len(edges)
for a, b in edges:
if not dsu.union(a, b):
return [a, b] # a and b were already connected
print(find_redundant_connection([[1, 2], [1, 3], [2, 3]])) # [2, 3]
print(find_redundant_connection([[1, 2], [2, 3], [3, 4], [1, 4], [1, 5]])) # [1, 4]
Why the first failure is the right answer. A tree plus one edge contains exactly one cycle, and deleting any edge of that cycle leaves a tree. Processing edges in input order, the first union that fails is the edge that completes the cycle, which is the cycle edge that appears last in the input. Edges off the cycle never fail. Time O(n Ξ±(n)), space O(n). The second example shows why "just return the last edge" is wrong: [1, 5] is last, but it is not on the cycle 1β2β3β4.
Your turn: a graph is a tree when it is connected and has no cycle. Using DSU, write is_tree(n, edges). Predict it for n = 4 with [[0, 1], [0, 2], [0, 3]], and with [[0, 1], [1, 2], [2, 0]].
Check your answer
def is_tree(n, edges):
if len(edges) != n - 1:
return False
dsu = DSU(n)
return all(dsu.union(a, b) for a, b in edges)
print(is_tree(4, [[0, 1], [0, 2], [0, 3]])) # True
print(is_tree(4, [[0, 1], [1, 2], [2, 0]])) # False
The first is a star: 3 edges, and every union succeeds. The second also has 3 edges, but union(2, 0) fails because the edge closes the triangle, and node 3 is left alone. Why n β 1 edges plus no failed union is enough: every successful union reduces groups by one, so n β 1 successes leave exactly one group, and that group is connected with no cycle.
Level up: wire everything for the least cost
LeetCode 1584: Min Cost to Connect All Points gives points on a plane. Connecting two points costs their Manhattan distance, |xβ β xβ| + |yβ β yβ|. Connect all points, directly or through others, at the least total cost. The cheapest connected network is a minimum spanning tree (MST). Kruskal's algorithm sorts every possible edge by cost and takes each one that joins two different groups. That "different groups" test is union returning True.
def min_cost_connect_points(points):
n = len(points)
edges = []
for i in range(n):
for j in range(i + 1, n):
(x1, y1), (x2, y2) = points[i], points[j]
edges.append((abs(x1 - x2) + abs(y1 - y2), i, j))
edges.sort() # cheapest first
dsu = DSU(n)
total = used = 0
for cost, i, j in edges:
if dsu.union(i, j): # skip edges inside one group
total += cost
used += 1
if used == n - 1:
break
return total
print(min_cost_connect_points([[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]])) # 20
With the points numbered 0β4 in that order:
| Edge | Cost | Action | Total |
|---|---|---|---|
| 1β3 | 3 | take | 3 |
| 0β1 | 4 | take | 7 |
| 3β4 | 4 | take | 11 |
| 0β3 | 7 | skip (same group) | 11 |
| 0β4 | 7 | skip (same group) | 11 |
| 1β4 | 7 | skip (same group) | 11 |
| 1β2 | 9 | take | 20 |
Four edges connect five points, so the loop stops at 20.
Why taking the cheapest edge between two groups is safe. Say Kruskal takes e = uβv, and X is u's group at that moment. Take a cheapest network N that contains Kruskal's earlier edges; it never needs a cycle, so it is a tree. N has a path from u to v, and that path leaves X through some edge f. Every edge cheaper than e has already been considered and now lies inside a group, so f, which leaves X, costs at least as much as e. For the same reason f is not one of Kruskal's earlier edges. Removing f splits N into two pieces, with u on one side and v on the other, and e joins them again. So N β f + e is connected, costs no more than N, and contains every edge Kruskal has taken so far. Repeat for each edge Kruskal takes, and Kruskal's result is a cheapest network.
Cost: n points give n(n β 1)/2 edges: 499,500 for n = 1,000. Sorting them is O(E log E), about 9.5 million comparisons, plus memory for half a million tuples. For complete graphs like this, Prim's algorithm in its O(nΒ²) array form grows one tree from a start point and never builds the edge list. Kruskal shines when the edges are given and sparse.
What union-find cannot do
- Delete an edge. Unions only merge. Splitting a group is not supported. If all the deletions are known in advance, a common trick processes them backwards as additions.
- Give a path or its length. It knows whether two nodes are connected, not how.
- Directed reachability. "a reaches b" does not mean "b reaches a", so reachability in a directed graph does not split nodes into groups. Union-find is for undirected connectivity.
Final round: no label on the problem
Real problems don't say which tool they want. Translate first (node, edge, question), then pick.
Challenge 1: Rotting Oranges
A grid holds 0 (empty), 1 (fresh orange) and 2 (rotten orange). Every minute, each fresh orange next to a rotten one (up, down, left, right) turns rotten. Return the minutes until no fresh orange is left, or β1 if some can never rot. This is LeetCode 994: Rotting Oranges.
Before peeking:
- What is a node, what is an edge, and which tool fits?
- What should
[[2, 1, 1], [0, 1, 1], [1, 0, 1]]and[[0, 2]]return? - Where does the answer come from, and when is it β1?
Check your answer
Cells are nodes and side-by-side cells are edges. "Spreads from all rotten oranges at once, one step per minute" is multi-source BFS: every rotten orange starts in the queue at minute 0.
from collections import deque
def oranges_rotting(grid):
rows, cols = len(grid), len(grid[0])
queue = deque()
fresh = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2:
queue.append((r, c, 0))
elif grid[r][c] == 1:
fresh += 1
minutes = 0
while queue:
r, c, t = queue.popleft()
minutes = t # queue order: t never decreases
for nr, nc in grid_neighbors(grid, r, c):
if grid[nr][nc] == 1:
grid[nr][nc] = 2 # mark on discovery (mutates the input)
fresh -= 1
queue.append((nr, nc, t + 1))
return minutes if fresh == 0 else -1
print(oranges_rotting([[2, 1, 1], [1, 1, 0], [0, 1, 1]])) # 4
print(oranges_rotting([[2, 1, 1], [0, 1, 1], [1, 0, 1]])) # -1
print(oranges_rotting([[0, 2]])) # 0
The answer is the minute of the last orange to rot. Because the queue is sorted by time, that is the t of the last pop. The second grid returns β1: the orange at the bottom-left is walled off by empty cells, so fresh never reaches 0. The third has nothing to rot, so it takes 0 minutes. O(RΒ·C) time and space. Rotting a fresh cell in place doubles as the seen mark and changes the caller's grid; copy it first if the contract forbids that.
Challenge 2: Most Stones Removed
Stones sit at distinct integer points. You may remove a stone if another stone still on the board shares its row or its column. What is the largest number of stones you can remove? [[0, 0], [0, 1], [1, 0], [1, 2], [2, 1], [2, 2]] gives 5. This is LeetCode 947: Most Stones Removed with Same Row or Column. Up to 1,000 stones.
Before peeking: what are the nodes and edges? In one connected group of stones, how many can you remove, and in what order?
Hint
Stones are nodes, and two stones sharing a row or a column are joined. Think about a spanning tree of one group, and remove its leaves first.
Check your answer
In a connected group of s stones you can remove s β 1. Take any spanning tree of the group and repeatedly remove a leaf: it still shares a row or column with its tree neighbor, which stays on the board. The last stone of a group can never go, because nothing in another group shares its row or column. So the answer is stones β groups:
def remove_stones(stones):
dsu = DSU(len(stones)) # DSU from Step 5
first_in_row, first_in_col = {}, {}
for i, (r, c) in enumerate(stones):
if r in first_in_row:
dsu.union(i, first_in_row[r])
else:
first_in_row[r] = i
if c in first_in_col:
dsu.union(i, first_in_col[c])
else:
first_in_col[c] = i
return len(stones) - dsu.groups
print(remove_stones([[0, 0], [0, 1], [1, 0], [1, 2], [2, 1], [2, 2]])) # 5
print(remove_stones([[0, 0], [0, 2], [1, 1], [2, 0], [2, 2]])) # 3
Joining each stone to the first stone seen in its row and in its column gives the same groups as joining every sharing pair, with O(n) unions instead of up to n(n β 1)/2. Time O(n Ξ±(n)), space O(n). The second example has two groups: the four corners, and the lone center stone.
Challenge 3 (boss level): walls you may break
Return to LeetCode 1293 from Step 1: the fewest steps from top-left to bottom-right, moving in 4 directions, breaking at most k walls, or β1. The grid is at most 40 Γ 40. Write it.
Check your answer
Every step costs 1, so the tool is BFS, and the node is (row, col, breaks left):
from collections import deque
def shortest_path_with_eliminations(grid, k):
rows, cols = len(grid), len(grid[0])
start = (0, 0, k) # (row, col, eliminations left)
seen = {start}
queue = deque([(start, 0)])
while queue:
(r, c, left), steps = queue.popleft()
if (r, c) == (rows - 1, cols - 1):
return steps
for nr, nc in grid_neighbors(grid, r, c):
nleft = left - grid[nr][nc] # a wall uses one elimination
state = (nr, nc, nleft)
if nleft >= 0 and state not in seen:
seen.add(state)
queue.append((state, steps + 1))
return -1
grid = [[0, 0, 0], [1, 1, 0], [0, 0, 0], [0, 1, 1], [0, 0, 0]]
print(shortest_path_with_eliminations(grid, 1)) # 6
print(shortest_path_with_eliminations(grid, 0)) # 10
With one break allowed, two 6-step routes exist: one breaks the wall at (3, 2), the other the wall at (1, 0). Without a break, the search has to snake around both walls. There are at most RΒ·CΒ·(k + 1) states, each with 4 moves, so time and space are O(RΒ·CΒ·(k + 1)). On the 4 Γ 2 grid from Step 1, this version returns 4, where the bare-cell version returned β1. One more observation saves time on big k. A staircase path from corner to corner passes through R + C β 3 cells between the two ends, so once k β₯ R + C β 3, the answer is simply R + C β 2.
Quick fire
Name the tool for each, then check.
- LeetCode 743: Network Delay Time: a signal leaves node k along directed edges with non-negative travel times. When has every node heard it?
- LeetCode 785: Is Graph Bipartite?: can the nodes be split into two sides with every edge crossing between them?
- LeetCode 721: Accounts Merge: accounts that share any email belong to the same person. Merge them.
- LeetCode 787: Cheapest Flights Within K Stops: the cheapest price from src to dst using at most k stops.
- LeetCode 127: Word Ladder: the fewest one-letter changes from one word to another, where every word must be in a dictionary.
Check your answer
- Weighted, costs β₯ 0, cheapest arrival at every node: Dijkstra. The answer is the largest of the distances, or β1 if some node is never reached.
- Any traversal that colors nodes as it goes, giving each neighbor the opposite color and failing on a clash. Run it from every uncolored node, because the graph may be disconnected. Graph Traversal builds it.
- Groups under a "shares something" relation: union-find, with emails as the nodes, or a traversal on an email graph.
- Cheapest, with an edge limit: Bellman-Ford for k + 1 rounds, or Dijkstra over (city, stops used) states. Plain Dijkstra on cities alone can discard a pricier route that uses fewer stops.
- Fewest moves where every move costs 1, on an implicit graph of words: BFS. Watch the contract: it counts the words in the sequence, so
hit β hot β dot β dog β cogis 5 words but 4 changes.
Cheat sheet
| When the problem says⦠| Model it as | Reach for | Key invariant |
|---|---|---|---|
| "can reach", "how many groups" | Nodes + edges, any storage | DFS or BFS, restarting per component | seen = discovered; each node enters the frontier once |
| "fewest moves", every move equal | State graph or grid | BFS | queue distances are sorted, at most d and d + 1 |
| "nearest X" for every cell | Grid, all X as sources | Multi-source BFS | all sources start at distance 0 |
| costs on edges | Weighted graph | 0-1 BFS / Dijkstra / Bellman-Ford | popped means final, if costs β₯ 0 |
| "before", prerequisites | Directed edge before β after | Kahn's topological sort | ready = in-degree 0; short output = cycle |
| edges arriving, "connected now?" | Undirected, additions only | Union-find | same root β connected |
| "connect all, least total" | Weighted undirected | Kruskal (sort + union-find) | take the cheapest edge between two groups |
| same cell, different situation | Node = (place, what you carry) | BFS on the bigger state | a node holds everything the future depends on |
Before moving on, take one problem from this lesson and explain it aloud from a blank editor. Say what a node is and what an edge is, which question is being asked and why that picks the algorithm, what the seen set (or parent array) means at any moment, what it costs in terms of V and E, and which change to the problem would break your choice: weights, negative costs, deletions, or an extra thing to carry.
Next: Graph Traversal for DFS and BFS mechanics in depth, including cycle detection, bipartite checks and cloning a graph. Then Shortest Path Algorithms for Dijkstra, Bellman-Ford and weighted routing.