Graph Traversal
Explore nodes systematically using depth-first and breadth-first search patterns
SPACED REPETITION Β· 18 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 18A traversal is a walk with a memory
Your build tool must list every module that main depends on, directly or through other modules. The import graph has no cycles, so a tempting plan is to recursively follow every import from every module. No cycles means no infinite loop, so why keep track of anything?
Here is why. Picture 30 "diamonds" in a row. Module m0 imports two helpers, both helpers import m1, m1 imports two more helpers that both import m2, and so on up to m30:
ββ> a1 ββ ββ> a2 ββ
m0 ββ€ ββ> m1 ββ€ ββ> m2 Β·Β·Β· m30
ββ> b1 ββ ββ> b2 ββ
That is 91 modules and 120 imports. The memoryless walk reaches m1 twice, m2 four times, and m30 along 2Β³β° = 1,073,741,824 different paths. In total it enters a module 4,294,967,293 times. Add one set that remembers which modules you have already entered, skip those, and the same walk enters 91 modules and looks at 120 imports.
That is the whole lesson in one sentence: a traversal is a walk that admits each node once. Every algorithm below is the same loop. Take a node from the frontier (the nodes found but not yet explored), look at its neighbors, and admit each neighbor the first time you see it. Admitting each node once is what keeps the cost at O(V + E). The shape of the frontier decides the order: a stack gives depth-first, a queue gives breadth-first. And the order decides what else you learn. Depth-first order tells you when a node is finished, which exposes cycles and dependency orders. Breadth-first order tells you distance.
| # | Smell in the problem | Move | Signature problem |
|---|---|---|---|
| 1 | "Can I get from here to there?" | Depth-first search | Find if Path Exists in Graph |
| 2 | "How many separate regions?" | Flood fill inside an outer loop | Number of Islands |
| 3 | "Fewest moves", every move costs the same | Breadth-first search, by layers | Rotting Oranges |
| 4 | "Can everything be finished?" with one-way dependencies | Three-color DFS | Course Schedule |
| 5 | "In what order?" with prerequisites | Topological order | Course Schedule II |
| 6 | "Split into two groups so every conflict crosses" | Two-coloring | Is Graph Bipartite? |
| 7 | "Copy a structure that points back at itself" | Traversal plus an oldβnew map | Clone Graph |
This lesson builds on Graph Algorithms & Search, which covers modeling problems as graphs and choosing a representation. Here we use adjacency lists throughout. It also reuses the "seen" set from Foundation: Arrays & Strings and the call-stack picture of recursion from Advanced Recursion & Backtracking.
Before any move: name the nodes, the edges and the question
Graph problems rarely hand you a graph. They hand you a grid, a list of pairs, or a class with pointers. Before choosing a traversal, answer three questions out loud:
| Problem | Nodes | Edges | What the output asks |
|---|---|---|---|
| Number of Islands | land cells (r, c) |
up/down/left/right land neighbors | how many groups |
| Course Schedule | courses 0..n-1 |
[a, b] means b before a: b β a |
is there a cycle |
| Rotting Oranges | non-empty cells | 4-neighbors, one minute each | the largest, over fresh oranges, of the distance to the nearest rotten one (β1 if unreachable) |
| Clone Graph | Node objects |
each node's .neighbors list |
a copy with the same shape |
Direction is part of the edge. In Course Schedule, [1, 0] means "take 0 before 1", so the edge runs 0 β 1. Read the pair order off the statement every time, because another problem in the same family flips it.
When the input is a list of pairs, build an adjacency list: one list of neighbors per node.
def build_graph(n, edges, directed=False):
graph = [[] for _ in range(n)] # every node gets a list, even with no edges
for u, v in edges:
graph[u].append(v)
if not directed:
graph[v].append(u)
return graph
graph = build_graph(6, [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4)])
print(graph) # [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], []]
Allocating all n lists up front matters. Node 5 has no edges, and a dictionary built only from the pairs would never hear of it. An undirected edge is stored twice, once from each end, so the lists hold 2E entries.
Grids are graphs whose neighbor lists you compute on the fly. The neighbors of (r, c) are (r + dr, c + dc) for each offset in ((1, 0), (-1, 0), (0, 1), (0, -1)), kept only if the cell is inside the grid and passes the problem's test ("is land", "is fresh").
Cost vocabulary. V is the number of nodes and E the number of edges. For an r Γ c grid, V = rc, and E is at most 2rc. Set and dictionary operations are expected O(1).
Move 1: Depth-first search, go deep then back up
Smell: you need everything reachable from a start, or a yes/no "is there a path from A to B?"
Here is the graph we just built. It has five connected nodes and one loner:
0 βββ 1
β β
2 βββ 3 βββ 4 5
Predict first: a depth-first search (DFS) from 0 tries neighbors in list order. Which node does it enter third? Does it ever enter 5?
Check your answer
It enters 0, then 1, then 3, not 2. From 1, DFS dives into 1's first unvisited neighbor before it comes back to try 0's second neighbor. It never enters 5: no edge leads there. A traversal answers "what can I reach from here?" and nothing more.
The recursive version
def dfs_order(graph, start):
visited = set()
order = []
def dfs(u):
visited.add(u) # mark on entry, before looking at neighbors
order.append(u) # pre-order: record on the way down
for v in graph[u]:
if v not in visited:
dfs(v)
dfs(start)
return order
print(dfs_order(graph, 0)) # [0, 1, 3, 2, 4]
This trace comes from an instrumented run. The path column is the call stack: the nodes whose dfs call is still open.
| Step | Event | Path after the event | Neighbors skipped (already visited) |
|---|---|---|---|
| 1 | enter 0 | 0 | |
| 2 | enter 1 | 0 β 1 | 0 |
| 3 | enter 3 | 0 β 1 β 3 | 1 |
| 4 | enter 2 | 0 β 1 β 3 β 2 | 0, 3 |
| 5 | finish 2 | 0 β 1 β 3 | |
| 6 | enter 4 | 0 β 1 β 3 β 4 | 3 |
| 7 | finish 4 | 0 β 1 β 3 | |
| 8 | finish 3 | 0 β 1 | |
| 9 | finish 1 | 0 | |
| 10 | finish 0 | (empty) | 2, checked back at 0 |
Each node has two moments. It is entered on the way down and finished on the way back up, after everything below it is done. Recording at entry gives pre-order: [0, 1, 3, 2, 4]. Recording at finish gives post-order (finish order): [2, 4, 3, 1, 0]. Pre-order is enough for "what is reachable". Finish order drives Moves 4 and 5.
Why it works, and what it costs
It reaches everything reachable. Suppose some node reachable from the start were missed. Walk along a path from the start to it and stop at the first missed node. Its predecessor on the path was entered, and entering a node scans all of its neighbors, so the missed node would have been entered too. That is a contradiction.
It enters nothing twice. A node joins visited the moment it is entered, and dfs(v) is only called when v is not in visited.
So each reachable node is entered once and its neighbor list is scanned once. The total is O(V + E) time: the scans cover 2E list entries for an undirected graph, E for a directed one. Extra space is O(V) for visited, plus the call stack, which is as deep as the longest path DFS walks. On a straight line of V nodes, that is V frames.
β οΈ Mark on entry, not on exit. Move visited.add(u) below the loop and the edge 0β1 alone breaks it: dfs(0) calls dfs(1), which sees that 0 is not marked yet and calls dfs(0) again, until Python raises RecursionError.
Python's recursion ceiling
CPython stops recursion at 1,000 frames by default (sys.getrecursionlimit()). On a straight path of just 999 nodes, dfs_order raises RecursionError. sys.setrecursionlimit raises the ceiling, but whether the process has enough real stack behind it depends on the Python version and the platform. The portable fix is not to recurse. Keep the frontier in your own list:
def dfs_iterative(graph, start):
visited = set()
order = []
stack = [start]
while stack:
u = stack.pop()
if u in visited:
continue # u was pushed more than once
visited.add(u)
order.append(u)
for v in reversed(graph[u]): # reversed, so neighbors pop in list order
if v not in visited:
stack.append(v)
return order
print(dfs_iterative(graph, 0)) # [0, 1, 3, 2, 4]
This version marks a node when it is popped, not when it is pushed. So a node can sit in the stack several times, once for each neighbor that saw it before it was entered, and the continue throws the extra copies away. It visits in exactly the recursive pre-order, but the stack can hold up to O(E) entries rather than O(V). On a complete graph with 200 nodes, the stack peaked at 19,702 entries.
Why not mark on push, which keeps the stack at O(V)? Because then the order stops being depth-first. On [[1, 2], [2, 3], [], []], recursion visits 0, 1, 2, 3. Mark-on-push visits 0, 1, 3, 2: node 2 was claimed by 0, so 1 can no longer dive into it. That is fine when you only need the set of reachable nodes (Move 2 does exactly that). It is wrong when you need DFS structure, such as finish order or "is this node on the current path".
Optional: an iterative DFS that also gives finish order
Store each node together with a bookmark into its neighbor list. The stack then holds exactly the current path, like the call stack, so it is O(V). A node finishes when its bookmark runs out.
def dfs_finish_order(graph, start, visited):
finished = []
visited.add(start)
stack = [(start, iter(graph[start]))] # the current path, with a bookmark per node
while stack:
u, neighbors = stack[-1]
for v in neighbors: # resumes where u left off
if v not in visited:
visited.add(v)
stack.append((v, iter(graph[v])))
break # go deeper first
else: # u has no unvisited neighbors left
stack.pop()
finished.append(u)
return finished
print(dfs_finish_order(graph, 0, set())) # [2, 4, 3, 1, 0]
The for ... else branch runs only when the loop ends without break, which means u has no unvisited neighbors left. Use this shape when a finish-order algorithm (Moves 4 and 5) must survive deep inputs.
Your turn: run dfs_iterative(graph, 0) by hand. Write the stack after each of the first four pops. Which node gets pushed twice, and what happens to its second copy?
Check your answer
| Pop | Stack after pushing |
|---|---|
| 0 | [2, 1] |
| 1 | [2, 3] (0 is already visited) |
| 3 | [2, 4, 2] (1 is visited, but 2 is not yet) |
| 2 | [2, 4] |
Node 2 is pushed twice: by 0 at the start, and by 3 before anyone had entered it. After 4 is popped, the old copy of 2 comes off the stack and hits continue. The visit order is still [0, 1, 3, 2, 4].
Move 2: Flood fill, one launch per region
Smell: "how many separate islands / groups / provinces?" or "how big is the largest one?"
LeetCode 200: Number of Islands gives a grid of "1" (land) and "0" (water). An island is a group of land cells joined up, down, left or right. Count the islands.
Predict first: a classmate counts land cells whose upper and left neighbors are both water (or off the grid), reasoning that each island has exactly one such "top-left corner". What does that give on this grid, and what is the right answer?
1 0 1
1 1 1
Check your answer
It gives 2: (0, 0) qualifies, and so does (0, 2), because its left neighbor (0, 1) is water. The right answer is 1. The two arms of the U are joined along the bottom row. Connectivity is not a local property. You cannot decide it by looking at a cell's neighbors; you have to follow it.
Fill, then count the launches
def num_islands(grid):
rows, cols = len(grid), len(grid[0])
seen = set()
def fill(r, c):
seen.add((r, c))
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] == "1" and (nr, nc) not in seen):
fill(nr, nc)
islands = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1" and (r, c) not in seen:
fill(r, c) # swallows the whole island
islands += 1 # one launch = one island
return islands
grid = [list(row) for row in ["11000", "11011", "00010", "01000"]]
print(num_islands(grid)) # 3
The outer loop scans the grid in reading order. Each launch starts at the first cell of an island that nothing has touched yet:
| Launch | Start | Cells filled, in fill order |
|---|---|---|
| 1 | (0, 0) | (0, 0), (1, 0), (1, 1), (0, 1) |
| 2 | (1, 3) | (1, 3), (2, 3), (1, 4) |
| 3 | (3, 1) | (3, 1) |
Why the count is right. Before each step of the outer loop, every land cell is either in seen together with its whole island, or its island is untouched. A launch therefore happens exactly once per island: at that island's first cell in reading order. Each cell is entered once and checks four neighbors, so the cost is O(rows Β· cols) time. Extra space is O(rows Β· cols) for seen plus the recursion.
The same shape counts components of any graph. Only the neighbor generation changes. Launch only once, from the first land cell, and you count one island. Drop the not in seen test in the outer loop and you count land cells.
Traps that cost submissions
β οΈ Check bounds before you index. In Python, grid[-1][c] does not raise an error; it silently reads the last row. And grid[rows][c] raises IndexError. The condition 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == "1" works because and stops at the first false part, so the grid is only indexed once the cell is known to be inside. Write the out-of-bounds test as nr < 0 or nr >= rows. With nr > rows, the value nr == rows slips through and crashes.
β οΈ Sinking the island is a contract decision. Many solutions skip seen and write grid[r][c] = "0" instead. That saves the set but destroys the caller's grid. LeetCode 200 accepts it. A caller who needs the grid afterwards does not. Either way, the recursion or stack is still O(rows Β· cols) in the worst case, so sinking does not make the traversal O(1) space.
β οΈ Recursion depth is the island's size, not its width. On an all-land 300 Γ 300 grid (the size LeetCode 200 allows), fill recurses 90,000 deep: the fill snakes through every cell before returning once. Under stock Python, num_islands already fails at 32 Γ 32 (1,024 cells). An explicit stack avoids that. Here, marking on push is fine, because only the set of cells matters:
def num_islands_iterative(grid):
rows, cols = len(grid), len(grid[0])
seen = set()
islands = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] != "1" or (r, c) in seen:
continue
islands += 1
seen.add((r, c))
stack = [(r, c)]
while stack:
cr, cc = stack.pop()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = cr + dr, cc + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] == "1" and (nr, nc) not in seen):
seen.add((nr, nc)) # mark on push: fine for "which cells"
stack.append((nr, nc))
return islands
print(num_islands_iterative([["1"] * 300 for _ in range(300)])) # 1
The same move on an edge list
"There are n nodes and a list of undirected edges; how many connected components?" is the same algorithm. The outer loop must run over all n nodes, because a node with no edges is a component of its own:
def count_components(n, edges):
graph = build_graph(n, edges)
seen = set()
count = 0
for s in range(n): # every node, including ones with no edges
if s in seen:
continue
count += 1
seen.add(s)
stack = [s]
while stack:
u = stack.pop()
for v in graph[u]:
if v not in seen:
seen.add(v)
stack.append(v)
return count
print(count_components(6, [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4)])) # 2
The answer is 2: {0, 1, 2, 3, 4} and the lonely {5}. Loop over the keys of a dictionary built from the edges instead, and node 5 is never counted. LeetCode 547: Number of Provinces asks the same question with an adjacency matrix as input. (Union-find, from Graph Algorithms & Search, is the other standard tool for counting components, especially when edges arrive one at a time.)
Your turn: LeetCode 695: Max Area of Island uses integer cells 1/0 and asks for the size of the largest island, or 0 if there is none. Change the fill so it returns the size of what it swallowed. What should it return for [[1, 1, 0, 0], [1, 0, 0, 1], [0, 1, 1, 1]]?
Check your answer
def max_area_of_island(grid):
rows, cols = len(grid), len(grid[0])
def area(r, c):
if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != 1:
return 0
grid[r][c] = 0 # sink it: marks visited by changing the input
return 1 + area(r + 1, c) + area(r - 1, c) + area(r, c + 1) + area(r, c - 1)
return max(area(r, c) for r in range(rows) for c in range(cols))
print(max_area_of_island([[1, 1, 0, 0], [1, 0, 0, 1], [0, 1, 1, 1]])) # 4
The islands have sizes 3 (top-left) and 4 (the bottom-right hook), so the answer is 4. Here the base case does the bounds and "is land" checks at the top of the call, so every call is safe to make. Sinking the cell before the four recursive calls is what stops two neighbors from counting each other. This version mutates the grid. Pass it a copy if the caller needs the original, and prefer the stack version for very large islands.
Move 3: Breadth-first search, ripples give distances
Smell: "fewest moves", "minimum steps", "how many minutes untilβ¦", where every move costs the same.
Predict first: a teammate runs DFS on the 5-cycle 0β1β2β3β4β0 from node 0. They store the depth at which each node was first entered and call it the distance. The adjacency lists are [[1, 4], [0, 2], [1, 3], [2, 4], [3, 0]]. What do they record for node 4, and what is the real distance?
Check your answer
DFS dives 0 β 1 β 2 β 3 β 4 and records 4. The real distance is 1, because 4 is a direct neighbor of 0. DFS depth is the length of some path, whichever one the dive happened to take. It is not the shortest.
The queue version
Breadth-first search (BFS) keeps the frontier in a FIFO queue. The start comes out first, then everything one edge away, then everything two edges away, like ripples from a stone.
from collections import deque
def bfs_distances(graph, source):
dist = {source: 0} # doubles as the visited set
queue = deque([source])
while queue:
u = queue.popleft()
for v in graph[u]:
if v not in dist: # first discovery = fewest edges
dist[v] = dist[u] + 1
queue.append(v)
return dist
print(bfs_distances(graph, 0)) # {0: 0, 1: 1, 2: 1, 3: 2, 4: 3}
On the Move 1 graph from node 0:
| Pop | Popped | Newly discovered | Queue after |
|---|---|---|---|
| 1 | 0 (d=0) | 1 (d=1), 2 (d=1) | [1, 2] |
| 2 | 1 (d=1) | 3 (d=2) | [2, 3] |
| 3 | 2 (d=1) | none | [3] |
| 4 | 3 (d=2) | 4 (d=3) | [4] |
| 5 | 4 (d=3) | none | [] |
Node 5 is missing from dist because it is unreachable. Check for that before you read dist[target].
Why the first discovery is the shortest
Invariant: the queue always holds nodes in non-decreasing distance order, and its front and back differ by at most 1. It starts as [source]. Popping a node at distance d appends nodes at distance d + 1 to the back, which keeps the invariant true. So every node at distance d is popped before any node at distance d + 1.
Now take a node v whose true distance is d + 1. One of its neighbors, u, sits at distance d. That u is popped before any node at distance d + 1, so v is discovered no later than from u, and gets label d + 1. A smaller label would need a neighbor closer than d, and v has none. Hence first discovery = fewest edges, in any graph where every edge counts as 1. For weighted edges, see Shortest Path Algorithms.
Cost: each node is enqueued once and its list scanned once, so O(V + E) time and O(V) extra space.
When to mark: three versions of one loop
Where you mark a node as visited changes the cost, and in one case the cost explodes. All numbers below come from real runs:
| Version | Correct? | Work and memory |
|---|---|---|
| Mark when enqueued (above) | Yes | Each node enqueued once; queue β€ V (199 on a complete graph of 200 nodes) |
| Mark when dequeued, skip it if already visited | Yes, same order | Up to one push per edge; queue O(E) (19,702 on the same graph) |
| Mark when dequeued, no skip | Terminates, but repeats work | A node is expanded once per copy: from a corner, 184,755 pops on a 10 Γ 10 open grid, 2,704,155 on 12 Γ 12 |
The third version is the classic beginner loop: u = queue.popleft(); visited.add(u), then push every unvisited neighbor. The second version is the same loop with if u in visited: continue right after the pop. Two neighbors that both see an unmarked cell both push it, each copy is expanded, and the copies multiply layer by layer. It never loops forever, since a node that is marked is never pushed again, but it can take exponential time. Mark on enqueue and the problem cannot arise.
β οΈ Use deque, not list.pop(0). pop(0) shifts every remaining element left, so it costs O(length of the queue). A BFS whose queue grows to about V entries then costs O(VΒ²). A star graph does exactly that. In one run on a laptop, a star with 100,000 nodes took 0.74 s with a list and 0.008 s with a deque. Doubling to 200,000 nodes made the list version about four times slower (2.9 s), which is the quadratic signature. deque.popleft() is O(1).
Many sources at once: Rotting Oranges
LeetCode 994: Rotting Oranges: cells are 0 (empty), 1 (fresh) or 2 (rotten). Every minute, each rotten orange rots its fresh 4-neighbors. Return the minutes until no fresh orange is left, or β1 if some can never rot.
The slow approach simulates minute by minute, rescanning the whole grid each time: O((rowsΒ·cols)Β²) in the worst case. The fast approach sees that every orange's rotting time is its BFS distance to the nearest rotten orange. Put all rotten oranges in the queue at distance 0 and run one BFS. This is multi-source BFS. It is the same as adding an imaginary start node joined to every source, so the queue invariant and the proof still hold.
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)) # every rotten orange starts at minute 0
elif grid[r][c] == 1:
fresh += 1
minutes = 0
while queue and fresh:
minutes += 1 # one full level = one minute
for _ in range(len(queue)): # snapshot: only this minute's sources
r, c = queue.popleft()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 2 # mark on enqueue
fresh -= 1
queue.append((nr, nc))
return minutes if fresh == 0 else -1
print(oranges_rotting([[2, 1, 1], [1, 1, 0], [0, 1, 1]])) # 4
for _ in range(len(queue)) is the level snapshot. It processes exactly the oranges that rotted in the previous minute (the initially rotten ones in minute 1). The ones it rots join the back of the queue and wait for the next minute.
| After minute | Grid | Fresh left |
|---|---|---|
| 0 | [[2,1,1],[1,1,0],[0,1,1]] |
6 |
| 1 | [[2,2,1],[2,1,0],[0,1,1]] |
4 |
| 2 | [[2,2,2],[2,2,0],[0,1,1]] |
2 |
| 3 | [[2,2,2],[2,2,0],[0,2,1]] |
1 |
| 4 | [[2,2,2],[2,2,0],[0,2,2]] |
0 |
β οΈ The last level rots nothing. Write while queue: instead of while queue and fresh: and the loop runs once more, for the oranges that rotted in minute 4. It returns 5 here, and 1 instead of 0 for [[0, 2]], where nothing is fresh at all. Test three edge cases every time: no fresh oranges (answer 0), a fresh orange with no rotten one anywhere (β1), and a fresh orange walled off by empty cells (β1).
Cost: each cell is enqueued at most once, so the run takes O(rows Β· cols) time and O(rows Β· cols) queue space. It also mutates grid, which LeetCode allows.
Your turn: LeetCode 1091: Shortest Path in Binary Matrix. An n Γ n grid of 0 (open) and 1 (blocked). Return the number of cells on the shortest path from the top-left to the bottom-right corner, moving in 8 directions (diagonals count), or β1 if there is none. Predict the answer for [[0, 0, 0], [1, 1, 0], [1, 1, 0]] and for [[0, 1], [1, 0]]. Which two details differ from bfs_distances?
Check your answer
def shortest_path_binary_matrix(grid):
n = len(grid)
if grid[0][0] == 1 or grid[n - 1][n - 1] == 1:
return -1
dist = {(0, 0): 1} # the path length counts cells, so start at 1
queue = deque([(0, 0)])
while queue:
r, c = queue.popleft()
if (r, c) == (n - 1, n - 1):
return dist[(r, c)]
for dr in (-1, 0, 1):
for dc in (-1, 0, 1): # 8 directions; (0, 0) is caught by dist
nr, nc = r + dr, c + dc
if (0 <= nr < n and 0 <= nc < n and grid[nr][nc] == 0
and (nr, nc) not in dist):
dist[(nr, nc)] = dist[(r, c)] + 1
queue.append((nr, nc))
return -1
print(shortest_path_binary_matrix([[0, 0, 0], [1, 1, 0], [1, 1, 0]])) # 4
print(shortest_path_binary_matrix([[0, 1], [1, 0]])) # 2
The answers are 4 ((0, 0) β (0, 1), a diagonal step to (1, 2), then down to (2, 2)) and 2 (one diagonal step). The two differences: the distance counts cells, so the start is 1, not 0; and there are 8 neighbors, not 4. A blocked start or end returns β1 immediately. A 1 Γ 1 grid [[0]] returns 1.
DFS or BFS?
Both run in O(V + E) and both need a visited structure. Choose by the question, not by speed:
| The problem asks⦠| Use | Why |
|---|---|---|
| Is B reachable? How many components? | Either | Both reach exactly the reachable set |
| Fewest moves, all moves equal | BFS | First discovery is shortest; DFS depth is not a distance |
| Cycle in a directed graph, dependency order | DFS (or Kahn's, Move 5) | Needs "is this node on the current path?" |
| Deep graph in Python | BFS or an explicit stack | Recursion stops at about 1,000 frames |
The memory shapes differ too. Recursive DFS holds the current path, which is V deep on a long chain. BFS holds a frontier, which is V β 1 wide after the center of a star. Both are O(V) in the worst case.
Move 4: Cycles, and why directed graphs need a third color
Smell: "can all courses be finished?", "is there a circular dependency?", "is this graph a tree?"
LeetCode 207: Course Schedule asks whether you can take all numCourses courses, given pairs [a, b] meaning "take b before a". You can finish everything exactly when the prerequisite graph has no directed cycle.
Predict first: this detector says "I reached a node I have seen before, so I went round a loop". Does it work on the diamond 0 β 1, 0 β 2, 1 β 3, 2 β 3?
def has_cycle_wrong(graph):
visited = set()
def dfs(u):
if u in visited:
return True # "seen before, so we went round a loop"
visited.add(u)
return any(dfs(v) for v in graph[u])
return any(dfs(u) for u in range(len(graph)))
Check your answer
No. It returns True for the diamond, which has no cycle. Node 3 is reached through 1 and then again through 2, and "seen before" only means two paths meet. It even fails on a single edge 0 β 1: the outer loop explores 0 and 1, then calls dfs(1) again and gets True. A directed cycle means you reached a node that is still on the path you are walking, not merely one you met earlier.
Three colors
Give each node one of three states:
- WHITE: not entered yet.
- GRAY: entered but not finished. It is on the current recursion path.
- BLACK: finished. Everything reachable from it has been explored.
WHITE, GRAY, BLACK = 0, 1, 2
def has_cycle(graph): # directed graph as adjacency lists
color = [WHITE] * len(graph)
def dfs(u):
color[u] = GRAY # u is on the current path
for v in graph[u]:
if color[v] == GRAY:
return True # an edge back into the current path
if color[v] == WHITE and dfs(v):
return True
color[u] = BLACK # u is finished: no cycle through it
return False
return any(color[u] == WHITE and dfs(u) for u in range(len(graph)))
print(has_cycle([[1], [2], [3], [1]])) # True: 1 -> 2 -> 3 -> 1
print(has_cycle([[1, 2], [3], [3], []])) # False: the diamond
Trace of the first call, edges 0 β 1 β 2 β 3 β 1:
| Event | Path (all GRAY) | Colors of 0, 1, 2, 3 |
|---|---|---|
| enter 0 | 0 | G W W W |
| enter 1 | 0 β 1 | G G W W |
| enter 2 | 0 β 1 β 2 | G G G W |
| enter 3 | 0 β 1 β 2 β 3 | G G G G |
| 3 looks at 1: GRAY | cycle is 1 β 2 β 3 β 1 |
Node 0 is on the path but not on the cycle. The cycle is the part of the path from the GRAY node onward.
Why GRAY means cycle, and only then. The GRAY nodes are exactly the open calls, so they form a path. An edge u β v with v GRAY therefore leads back to an ancestor of u, and the path from v to u plus that edge is a cycle. Conversely, suppose a cycle exists, and let v be the first of its nodes that DFS enters. At that moment the rest of the cycle is WHITE and reachable from v, so DFS cannot finish v without walking around to the cycle node w that points back to v. When w examines w β v, v is still GRAY. A BLACK neighbor is harmless: it is already finished, either a shared descendant like node 3 in the diamond or a node finished in an earlier search.
Cost: O(V + E) time, and O(V) for the colors plus the recursion.
Undirected graphs are different
In an undirected adjacency list, every edge appears from both ends. Run has_cycle on the plain path 0 β 1 β 2, and at node 1 it sees 0, which is GRAY, and reports a cycle. It prints True for a graph that is a tree. The fix is to ignore the edge you arrived on:
def has_cycle_undirected(graph): # undirected graph as adjacency lists
seen = set()
def dfs(u, parent):
seen.add(u)
for v in graph[u]:
if v == parent:
continue # the edge we arrived on
if v in seen or dfs(v, u):
return True
return False
return any(u not in seen and dfs(u, -1) for u in range(len(graph)))
print(has_cycle_undirected(build_graph(3, [(0, 1), (1, 2)]))) # False
print(has_cycle_undirected(build_graph(3, [(0, 1), (1, 2), (2, 0)]))) # True
In undirected DFS, any already-seen neighbor other than the parent closes a loop. Two states (seen or not) are enough here because, without arrows, the diamond from the prediction above is a cycle: 0β1β3β2β0. Directed graphs need GRAY to tell "on my path" apart from "finished elsewhere". It even catches a doubled edge. The child skips both copies of the edge back to its parent, but the parent's own list holds the child twice, and the second copy finds the child already seen.
Your turn: "Given n nodes and an undirected edge list, is it a tree?" (LeetCode 261: Graph Valid Tree is premium, but the question is classic). Someone checks only len(edges) == n - 1. Give an input that fools them, and a correct test that reuses code you already have.
Check your answer
n = 4, edges = [(0, 1), (1, 2), (2, 0)] has 3 = n β 1 edges, but it is a triangle plus an isolated node 3. A tree must be connected and have exactly n β 1 edges. Either condition alone is not enough.
def valid_tree(n, edges):
return len(edges) == n - 1 and count_components(n, edges) == 1
That runs in O(n + E). With exactly n β 1 edges, "connected" and "no cycle" imply each other, so not has_cycle_undirected(build_graph(n, edges)) works in place of the component count.
Course Schedule, finished
def can_finish(num_courses, prerequisites):
graph = [[] for _ in range(num_courses)]
for course, pre in prerequisites:
graph[pre].append(course) # pre must come first: pre -> course
return not has_cycle(graph)
print(can_finish(2, [[1, 0]])) # True
print(can_finish(2, [[1, 0], [0, 1]])) # False
Two things to know before you submit:
- Direction does not change this answer. Reversing every edge turns a cycle into a reversed cycle, so
can_finishgives the same result either way (checked on 3,000 random inputs). It does change Move 5's answer: a reversed graph yields a reversed order. - Depth. LeetCode 207 allows 2,000 courses. A chain of 2,000 courses makes this recursion 2,000 calls deep, past stock Python's limit. Kahn's algorithm (next) uses no recursion at all.
Move 5: Topological order, schedule the dependencies
Smell: "return an order", "valid build/install sequence", with before/after constraints. LeetCode 210: Course Schedule II wants any order that puts every prerequisite before its course, or [] if none exists.
A topological order lists the nodes so that every edge u β v has u before v. It exists exactly when the graph has no directed cycle, and there are usually several. We will use five courses, with course 4 hanging off to the side:
βββ> 1 βββ
0 βββ€ βββ> 3 <ββ 4
βββ> 2 βββ
prerequisites = [[1, 0], [2, 0], [3, 1], [3, 2], [3, 4]]
Way 1: reverse the finish order
When a node finishes in DFS, everything it leads to has already finished. So the finish order lists every course after all the courses that depend on it. Reverse it and you have a schedule. The GRAY check from Move 4 comes along for free:
def find_order(num_courses, prerequisites):
graph = [[] for _ in range(num_courses)]
for course, pre in prerequisites:
graph[pre].append(course)
color = [WHITE] * num_courses
finished = [] # post-order
def dfs(u):
color[u] = GRAY
for v in graph[u]:
if color[v] == GRAY:
return False # cycle: no valid order
if color[v] == WHITE and not dfs(v):
return False
color[u] = BLACK
finished.append(u) # everything u leads to is already here
return True
for u in range(num_courses):
if color[u] == WHITE and not dfs(u):
return []
return finished[::-1] # reverse post-order
prereqs = [[1, 0], [2, 0], [3, 1], [3, 2], [3, 4]]
print(find_order(5, prereqs)) # [4, 0, 2, 1, 3]
| Event | finished afterwards |
|---|---|
| enter 0, enter 1, enter 3, finish 3 | [3] |
| finish 1 | [3, 1] |
| enter 2 (3 is already BLACK), finish 2 | [3, 1, 2] |
| finish 0 | [3, 1, 2, 0] |
| outer loop: enter 4 (3 is BLACK), finish 4 | [3, 1, 2, 0, 4] |
Reversed, that gives [4, 0, 2, 1, 3].
Why it works. Take any edge u β v. When u examines v, v is WHITE, BLACK, or GRAY. If WHITE, v is explored and finished inside u's call, so before u finishes. If BLACK, v finished earlier still. GRAY is impossible when there is no cycle. So v always finishes before u. In the reversed list, u comes before v.
β οΈ One start is not enough. Run DFS only from 0 and you get [0, 2, 1, 3]. It is a fine order for what 0 reaches, but course 4, which must precede 3, is missing. The outer loop over every node is what makes the order complete.
Way 2: Kahn's algorithm, peel off what is ready
Count each course's unplaced prerequisites (its in-degree). Any course at 0 is ready. Place a ready course, and each course that depended on it loses one prerequisite. Whatever drops to 0 becomes ready in turn.
def find_order_kahn(num_courses, prerequisites):
graph = [[] for _ in range(num_courses)]
indegree = [0] * num_courses # prerequisites not yet placed
for course, pre in prerequisites:
graph[pre].append(course)
indegree[course] += 1
queue = deque(u for u in range(num_courses) if indegree[u] == 0)
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in graph[u]:
indegree[v] -= 1
if indegree[v] == 0: # v's last prerequisite was just placed
queue.append(v)
return order if len(order) == num_courses else []
print(find_order_kahn(5, prereqs)) # [0, 4, 1, 2, 3]
The in-degrees start as [0, 1, 1, 3, 0], so the queue starts as [0, 4]:
| Placed | In-degree changes | Queue after | order after |
|---|---|---|---|
| 0 | 1: 1 β 0, 2: 1 β 0 | [4, 1, 2] | [0] |
| 4 | 3: 3 β 2 | [1, 2] | [0, 4] |
| 1 | 3: 2 β 1 | [2] | [0, 4, 1] |
| 2 | 3: 1 β 0 | [3] | [0, 4, 1, 2] |
| 3 | none | [] | [0, 4, 1, 2, 3] |
Why it works. Invariant: indegree[v] counts v's prerequisites not yet in order. A course enters the queue exactly when that count reaches 0, so it is placed only after all its prerequisites. If there is a cycle, no course on it can ever reach 0, because each one waits for the one before it. Those courses are never placed, and len(order) < num_courses reports the cycle. If there is no cycle, some unplaced course always has in-degree 0. (If none did, you could walk backwards from prerequisite to prerequisite forever, and in a finite graph you would repeat a course, which would be a cycle.) So every course gets placed.
Both ways cost O(V + E) time and O(V + E) extra space, most of it the adjacency list they build; beyond that list they need O(V). Kahn's has no recursion, detects the cycle with one length check, and gives the lexicographically smallest valid order if you swap the queue for a min-heap (see Heaps & Priority Queues). The DFS way reuses the three-color code and suits problems that already walk the graph depth-first.
Test it like a professional
The two methods return different valid orders ([4, 0, 2, 1, 3] and [0, 4, 1, 2, 3]), so comparing outputs with == proves nothing. Check the property instead, and compare against brute force on tiny inputs:
import itertools, random
def is_valid_order(order, num_courses, prerequisites):
position = {course: i for i, course in enumerate(order)}
return (sorted(order) == list(range(num_courses))
and all(position[pre] < position[course] for course, pre in prerequisites))
def order_exists(num_courses, prerequisites): # brute force for tiny n
return any(is_valid_order(list(p), num_courses, prerequisites)
for p in itertools.permutations(range(num_courses)))
def check(trials=500):
for _ in range(trials):
n = random.randint(1, 6)
pairs = {(random.randrange(n), random.randrange(n)) for _ in range(random.randint(0, 8))}
prereqs = [[a, b] for a, b in pairs if a != b]
for solver in (find_order, find_order_kahn):
order = solver(n, prereqs)
if order_exists(n, prereqs):
assert is_valid_order(order, n, prereqs), (n, prereqs, order)
else:
assert order == [], (n, prereqs, order)
print("all matched")
check() # all matched
Your turn: run Kahn's on num_courses = 4, prerequisites = [[1, 0], [2, 1], [1, 2], [3, 0]]. What does order hold when the queue empties, what is returned, and why is course 1 stuck?
Check your answer
The edges are 0 β 1, 1 β 2, 2 β 1 and 0 β 3, and the in-degrees start as [0, 2, 1, 1]. Only 0 is ready. Placing 0 drops course 1 to 1 and course 3 to 0. Then 3 is placed. The queue is now empty with order = [0, 3], and since 2 < 4 the function returns []. Course 1 still waits for course 2, and 2 waits for 1: that is the cycle. The leftover courses are exactly the ones on a cycle or behind one.
Move 6: Two colors, the bipartite check
Smell: "split into two groups so that every conflict (dislike, edge) goes between the groups."
LeetCode 785: Is Graph Bipartite? gives an undirected graph as adjacency lists, possibly disconnected. Can the nodes be colored with two colors so that every edge joins different colors?
def is_bipartite(graph):
color = [-1] * len(graph) # -1 = not colored yet
for s in range(len(graph)): # the graph may be disconnected
if color[s] != -1:
continue
color[s] = 0
queue = deque([s])
while queue:
u = queue.popleft()
for v in graph[u]:
if color[v] == -1:
color[v] = 1 - color[u] # forced: the opposite of u
queue.append(v)
elif color[v] == color[u]:
return False # an edge inside one group
return True
print(is_bipartite([[1, 2, 3], [0, 2], [0, 1, 3], [0, 2]])) # False
print(is_bipartite([[1, 3], [0, 2], [1, 3], [0, 2]])) # True
In the first graph, popping 0 colors 1, 2 and 3 all with color 1. Popping 1 then finds its neighbor 2 also colored 1: the edge 1β2 sits inside one group. Triangles such as 0β1β2 are to blame. The second graph is a square, and it alternates cleanly.
Why it works. Inside one connected component, the first color is a free choice, and every other color is forced: a node must get the opposite of the neighbor that discovered it. So if the forced coloring puts an edge inside one group, no two-coloring exists. If every edge has been checked and none conflicts, the coloring is a valid answer. Another way to say it: a graph is bipartite exactly when it has no odd cycle. Going round a cycle alternates colors, so an odd cycle comes back to its start with the wrong color.
β οΈ Color every component. Start only from node 0 and a triangle elsewhere goes unchecked. On build_graph(5, [(0, 1), (2, 3), (3, 4), (4, 2)]), a version without the outer loop returns True; the real answer is False. DFS colors just as well as BFS. Either way the cost is O(V + E) time and O(V) extra space.
Your turn: LeetCode 886: Possible Bipartition. People are labeled 1 to n, and dislikes[i] = [a, b] means a and b must go to different groups. Reuse is_bipartite. What do you get for n = 4, dislikes = [[1, 2], [1, 3], [2, 4]] and for n = 3, dislikes = [[1, 2], [1, 3], [2, 3]]?
Check your answer
def possible_bipartition(n, dislikes):
graph = [[] for _ in range(n + 1)] # people are 1..n; slot 0 stays empty
for a, b in dislikes:
graph[a].append(b)
graph[b].append(a)
return is_bipartite(graph)
print(possible_bipartition(4, [[1, 2], [1, 3], [2, 4]])) # True
print(possible_bipartition(3, [[1, 2], [1, 3], [2, 3]])) # False
The answers are True (groups {1, 4} and {2, 3}) and False (three people who all dislike each other form an odd cycle). The trap is the labels. With only n slots, person n indexes past the end. The unused slot 0 is harmless, because an isolated node takes any color.
Move 7: Clone Graph, traversal plus an oldβnew map
Smell: "return a deep copy" of something whose pointers can form cycles.
LeetCode 133: Clone Graph gives one node of a connected undirected graph. Each Node has a val and a list neighbors. Return a copy in which no node is shared with the original.
A copy needs one new node per original node, and every edge must point at the copies. When you meet a node for the second time, you must return the copy you already made, not a new one. So you need a map from each original node to its copy. That map is also the visited set.
class Node:
def __init__(self, val=0, neighbors=None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []
def clone_graph(node):
if node is None:
return None
copies = {} # original node -> its copy
def copy(u):
if u in copies:
return copies[u]
twin = Node(u.val)
copies[u] = twin # register BEFORE visiting neighbors
for v in u.neighbors:
twin.neighbors.append(copy(v))
return twin
return copy(node)
a, b, c, d = Node(1), Node(2), Node(3), Node(4) # the square 1-2-3-4-1
a.neighbors, b.neighbors, c.neighbors, d.neighbors = [b, d], [a, c], [b, d], [a, c]
twin = clone_graph(a)
print(twin is a, [n.val for n in twin.neighbors], twin.neighbors[0].neighbors[0] is twin) # False [2, 4] True
The last check matters. Going from the copy of 1 to the copy of 2 and back lands on the same copy of 1. The copy has the square's shape, not just its values.
Why the registration line comes first. Copying 1 leads to copying 2, which looks at 2's neighbor 1. If 1 were registered only after its loop, copy(1) would start again, then copy(2) again, and so on until RecursionError. Registering first is Move 1's rule, "mark on entry", in a new costume.
Key the map by the node object itself. Python's default __hash__ for a class like Node uses identity, so two different nodes that happen to share a value never collide. LeetCode 133 promises unique values, so keying by val passes there, but the object key is correct in general.
Cost: O(V + E) time. Extra space is O(V) for the map plus the recursion, not counting the copy itself, which is the output (V nodes and E-sized neighbor lists).
Your turn: write the BFS version. When should a copy be created, and when should an edge be added?
Check your answer
def clone_graph_bfs(node):
if node is None:
return None
copies = {node: Node(node.val)}
queue = deque([node])
while queue:
u = queue.popleft()
for v in u.neighbors:
if v not in copies:
copies[v] = Node(v.val) # create on discovery = mark on enqueue
queue.append(v)
copies[u].neighbors.append(copies[v])
return copies[node]
Create a copy when a node is first discovered; this is BFS's mark-on-enqueue. Add the edge copies[u] β copies[v] for every neighbor, including ones that already have copies. Put the append inside the if and edges back to already-copied nodes go missing. Each neighbor list is scanned once from its own end, so every edge is added once in each direction, in the original order. It uses no recursion, so large graphs are safe.
Final round: no label on the problem
Real problems do not say "use BFS". Name the nodes, the edges and the question, find the smell, and then pick the move.
Challenge 1: distance to the nearest zero
LeetCode 542: 01 Matrix. Given a grid of 0s and 1s (at least one 0, up to 10β΄ cells), return a grid where each cell holds the number of 4-directional steps to the nearest 0. For [[0, 0, 0], [0, 1, 0], [1, 1, 1]] the answer is [[0, 0, 0], [0, 1, 0], [1, 2, 1]].
- What does the slow version repeat?
- Which move applies, and where does the queue start?
Hint
The slow version runs one BFS per cell containing 1, looking for a 0. That is up to 10β΄ searches of up to 10β΄ cells each. Turn the question around: every 0 starts a ripple, and all ripples spread together.
Check your answer
This is multi-source BFS from all the zeros, the same shape as Rotting Oranges.
def update_matrix(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
queue.append((r, c)) # every 0 is a source
while queue:
r, c = queue.popleft()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and dist[nr][nc] == -1:
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
return dist
print(update_matrix([[0, 0, 0], [0, 1, 0], [1, 1, 1]])) # [[0, 0, 0], [0, 1, 0], [1, 2, 1]]
dist == -1 marks "not reached yet", so dist doubles as the visited set, and a cell is labeled when it is enqueued. The run is O(rows Β· cols) time and space, against O((rows Β· cols)Β²) for one search per cell. It matched a brute-force "minimum Manhattan distance to any zero" on 2,000 random grids.
Challenge 2: capture the surrounded regions
LeetCode 130: Surrounded Regions. A board of "X" and "O". Flip every "O" region that does not touch the border to "X", in place. ["XXXX", "XOOX", "XXOX", "XOXX"] becomes ["XXXX", "XXXX", "XXXX", "XOXX"]: the bottom O touches the border and survives.
Which cells are easy to classify, and which move finds the rest?
Check your answer
Reverse the question. Instead of asking whether each region is surrounded, find the regions that are safe: every "O" connected to a border "O". That is a flood fill seeded from all border "O"s at once. Everything not reached gets captured.
def solve_surrounded(board):
rows, cols = len(board), len(board[0])
stack = [(r, c) for r in range(rows) for c in range(cols)
if (r in (0, rows - 1) or c in (0, cols - 1)) and board[r][c] == "O"]
for r, c in stack:
board[r][c] = "S" # safe: touches the border
while stack:
r, c = stack.pop()
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == "O":
board[nr][nc] = "S"
stack.append((nr, nc))
for r in range(rows):
for c in range(cols):
board[r][c] = "O" if board[r][c] == "S" else "X"
board = [list(row) for row in ["XXXX", "XOOX", "XXOX", "XOXX"]]
solve_surrounded(board)
print(["".join(row) for row in board]) # ['XXXX', 'XXXX', 'XXXX', 'XOXX']
The temporary mark "S" is the visited marker, and the last loop turns it back into "O". The run is O(rows Β· cols) time and space, and it uses an explicit stack, so a 200 Γ 200 board cannot overflow the recursion limit. The function returns nothing, as the contract asks.
Challenge 3: many "is it a prerequisite?" questions
LeetCode 1462: Course Schedule IV. There are numCourses β€ 100 courses and an acyclic list prerequisites, where [a, b] means a must be taken before b. Then come up to 10β΄ queries [u, v]: is u a prerequisite of v, directly or indirectly? Return a list of booleans.
- Which direction do the edges point this time?
- With 100 courses and 10β΄ queries, what should you compute once?
Check your answer
The pair order is flipped compared with Course Schedule I and II: here [a, b] gives the edge a β b. "Is u a prerequisite of v?" means "is v reachable from u?". There are only 100 courses, so run one traversal from every course, store what it reaches, and answer each query with a set lookup:
def check_if_prerequisite(num_courses, prerequisites, queries):
graph = [[] for _ in range(num_courses)]
for a, b in prerequisites:
graph[a].append(b) # here [a, b] means a BEFORE b
reach = []
for s in range(num_courses): # one traversal per course
seen = set() # courses reachable from s by >= 1 edge
stack = [s]
while stack:
u = stack.pop()
for v in graph[u]:
if v not in seen:
seen.add(v)
stack.append(v)
reach.append(seen)
return [v in reach[u] for u, v in queries]
print(check_if_prerequisite(3, [[1, 2], [1, 0], [2, 0]], [[1, 0], [1, 2]])) # [True, True]
print(check_if_prerequisite(2, [[1, 0]], [[0, 1], [1, 0]])) # [False, True]
The precomputation is n traversals of O(n + E) each: at most about 100 Γ (100 + 4,950) β 505,000 steps. Each query is then O(1) expected. One traversal per query would cost up to 10β΄ Γ 5,050 β 5 Γ 10β·. It is the same trade as the prefix sums in the Foundation lesson: pay once, answer many.
Cheat sheet: smell β move
| When the problem says⦠| Reach for | Key invariant |
|---|---|---|
| "reachable", "path exists" | DFS or BFS from the start | each node admitted once |
| "how many groups / regions" | an outer loop around a flood fill | one launch = one new component |
| "fewest moves / minutes", equal costs | BFS, multi-source if many starts | the queue holds distance d, then d + 1 |
| "can everything finish?" (directed) | three-color DFS | GRAY = on the current path |
| "any cycle?" or "is it a tree?" (undirected) | DFS that skips the parent; tree = connected and n β 1 edges | a seen non-parent neighbor closes a loop |
| "give a valid order" | reverse finish order, or Kahn's | edge u β v means v finishes first; in-degree counts unplaced prerequisites |
| "two groups, conflicts cross" | two-coloring, every component | colors are forced |
| "deep copy with cycles" | an oldβnew map, registered before recursing | the map is the visited set |
Before moving on, pick one problem from this lesson and solve it aloud from a blank editor. Say what the nodes and edges are and which way the edges point. Say what the frontier holds and when a node is marked. Explain why the answer is correct: which invariant holds, and what a GRAY node or a queue level means at that moment. Give the cost, including the recursion stack. Then name the change that would break it: weighted edges, a disconnected input, a 300 Γ 300 grid under Python's recursion limit. If you can do that, the graph lessons that follow build on these seven moves.
Next: Shortest Path Algorithms for weighted edges (Dijkstra), Word Ladder and other state-space searches. Graph Algorithms & Search covers union-find, the other way to count components. Binary Tree Patterns covers level-order and depth-first traversal on trees.