Traverse a graph or tree by following each branch to its end before backtracking. Use the discover/finish timestamps (or coloring scheme: white/gray/black) to derive cycle detection, topological sort, connected components, and reachability — DFS is the substrate for an entire family of graph algorithms.
1 worked example · 7 practice problems · 2 check problems
Worked example: DFS with discover/finish timestamps
Problem. Run DFS from A on the directed graph below. Record the discover and finish time of every vertex.
A → B, CB → D, EC → FD →E → FF →
Diagnosis. DFS visits a vertex, recursively descends into each unvisited neighbor in order, then returns. Each visit "discovers" the vertex (timestamp d[u]) and each return "finishes" it (timestamp f[u]). The interval [d[u],f[u]] is the period during which u is "on the stack" — and the discover/finish intervals of any two vertices either nest or are disjoint (never interleave), which is what makes DFS such a clean substrate for graph algorithms.
Predict before reading on: predict which vertex finishes first. The first finish is the deepest vertex on the first DFS path — the one with no unvisited outgoing neighbors at the moment it's reached.
Trace. Start with global clock t=0. At each event (discover or finish), increment t and stamp.
event
t
vertex
state of stack
discover
1
A
[A]
discover
2
B
[A, B]
discover
3
D
[A, B, D]
finish
4
D
[A, B]
discover
5
E
[A, B, E]
discover
6
F
[A, B, E, F]
finish
7
F
[A, B, E]
finish
8
E
[A, B]
finish
9
B
[A]
discover
10
C
[A, C]
finish
11
C
[A]
finish
12
A
[]
Final timestamps.
d[A]=1,f[A]=12
d[B]=2,f[B]=9
d[D]=3,f[D]=4
d[E]=5,f[E]=8
d[F]=6,f[F]=7
d[C]=10,f[C]=11
The parenthesis property. Compare the intervals: [B]=[2,9], [E]=[5,8]. The first contains the second — E was discovered and finished while B was on the stack. B is an ancestor of E in the DFS tree. Contrast with [B]=[2,9] and [C]=[10,11]: disjoint intervals, no ancestor relationship — they're separate subtrees of A.
Predict before reading on: why does the parenthesis property hold? What about the stack-based execution makes intervals always nest or be disjoint, never interleave?
Verification.
graph = { 'A': ['B', 'C'], 'B': ['D', 'E'], 'C': ['F'], 'D': [], 'E': ['F'], 'F': [],}time = 0discover, finish, visited = {}, {}, set()def dfs(u): global time visited.add(u) time += 1 discover[u] = time for v in graph[u]: if v not in visited: dfs(v) time += 1 finish[u] = timedfs('A')print(discover) # {'A': 1, 'B': 2, 'D': 3, 'E': 5, 'F': 6, 'C': 10}print(finish) # {'D': 4, 'F': 7, 'E': 8, 'B': 9, 'C': 11, 'A': 12}
Articulate: state in one sentence what DFS "knows" about a vertex when it finishes it. (Hint: think about which other vertices' states are determined by the time we finish u.)
Practice problems
Seven problems. Each uses DFS as a substrate — the traversal is the same; what changes is what we record during it and what we read off at the end.
P.1cycle detection in directed graph
Use DFS to detect whether a directed graph contains a cycle. Apply your algorithm to:
(a) Graph 1: A→B→C→A
(b) Graph 2: A→B→C,A→C (no back edge)
Find the analogue:
use three colors during DFS: white (unvisited), gray (on the current DFS stack), black (finished). A back edge from a vertex to a gray ancestor is exactly a cycle. The discover/finish timestamps make this rigorous — back edges are edges into vertices whose finish time is later than the current vertex's.
show answer
WHITE, GRAY, BLACK = 0, 1, 2def has_cycle(graph): color = {v: WHITE for v in graph} def visit(u): color[u] = GRAY for v in graph.get(u, []): if color.get(v, WHITE) == GRAY: return True if color.get(v, WHITE) == WHITE and visit(v): return True color[u] = BLACK return False return any(color[u] == WHITE and visit(u) for u in graph)print(has_cycle({'A': ['B'], 'B': ['C'], 'C': ['A']})) # Trueprint(has_cycle({'A': ['B', 'C'], 'B': ['C'], 'C': []})) # False
(a) Has cycle. Starting DFS at A: color A gray, visit B (gray), visit C (gray), look at C→A — A is gray, cycle detected.
(b) No cycle. Starting at A: visit B, visit C (no neighbors), finish C (black), finish B (black), explore A→C — C already black, not a back edge. Finish A.
The three-color scheme is the substrate for cycle detection. In undirected graphs it's simpler: any edge to a non-parent visited vertex is a back edge.
P.2topological sort
Compute a topological ordering of the directed acyclic graph:
A → B, CB → DC → DD → E
Use the DFS approach. Explain why reverse order of finish times gives a valid topological sort.
Find the analogue:
DFS finishes a vertex only after finishing all its descendants. So an edge u→v implies f[u]>f[v]. Sorting in decreasing order of finish time satisfies "u before v whenever u → v exists" — the topological property.
show answer
Run DFS, record finish times: e.g., d[A]=1,f[E]=4,f[D]=5,f[B]=6,f[C]=8,f[A]=9 (one valid trace). Sorting by decreasing finish: A,C,B,D,E. ✓ Valid topo order.
def topo_sort(graph): visited = set() order = [] def visit(u): visited.add(u) for v in graph[u]: if v not in visited: visit(v) order.append(u) # post-order push for u in graph: if u not in visited: visit(u) return list(reversed(order)) # reverse for topo orderprint(topo_sort({'A': ['B', 'C'], 'B': ['D'], 'C': ['D'], 'D': ['E'], 'E': []}))# ['A', 'C', 'B', 'D', 'E'] (one of several valid orders)
Why this works: for any edge u→v, v is either visited within u's DFS subtree (so f[v]<f[u]) or visited entirely before u in some earlier outer-loop call (so f[v]<d[u]<f[u]). Either way f[u]>f[v]. Decreasing finish-time order respects all such edges.
If the graph has a cycle, no topological order exists — and indeed the three-color cycle detection from P.1 catches this case during DFS.
P.3connected components (undirected)
Count the connected components of this undirected graph:
Find the analogue:
run DFS starting from each unvisited vertex. Each top-level DFS call discovers an entire component. Count the number of such top-level calls.
show answer
3 components:{1,2,3},{4,5},{6}.
def count_components(graph): visited = set() count = 0 def visit(u): visited.add(u) for v in graph[u]: if v not in visited: visit(v) for u in graph: if u not in visited: visit(u) count += 1 return countg = {1: [2, 3], 2: [1], 3: [1], 4: [5], 5: [4], 6: []}print(count_components(g)) # 3
This is the pattern for any "do DFS, count something per starting vertex" problem. Same loop structure also handles: counting weakly connected components in directed graphs (after symmetrizing), counting connected groups of like-colored cells in an image (treating each 1-cell as a vertex), counting "islands" in a grid.
P.4tree traversal orders (pre, in, post)
Given the binary tree:
1 / \ 2 3 / \ \ 4 5 6
Write out the preorder, inorder, and postorder traversals. Briefly state what each traversal is commonly used for.
Find the analogue:
pre/in/post-order are three points to "visit" the current node during DFS: before recursing into children (pre), between left and right children (in), or after all children (post). Same DFS — different placement of the visit.
show answer
Preorder (root, left, right): 1, 2, 4, 5, 3, 6. Used to copy a tree (visit root first so you can build the new tree's root before recursing).
Inorder (left, root, right): 4, 2, 5, 1, 6, 3. On a binary search tree, gives sorted order. Used for tree-to-sorted-array conversion.
Postorder (left, right, root): 4, 5, 2, 6, 3, 1. Used to delete a tree (free children before parent) or compute aggregate values (height, size — anything that needs children's values to compute the parent's).
All three are the same DFS skeleton:
def traverse(node, out, order): if not node: return if order == 'pre': out.append(node.val) traverse(node.left, out, order) if order == 'in': out.append(node.val) traverse(node.right, out, order) if order == 'post': out.append(node.val)
Choice of order depends on what the algorithm needs to know about its children before processing itself.
P.5reachability and ancestry
Given a directed graph and two vertices s and t, write a DFS-based function that returns True if t is reachable from s.
Then use the discover/finish timestamps from the worked example to answer: without running another DFS, is F reachable from B? Is C reachable from B?
Find the analogue:
reachability is the simplest DFS application — just check if the target gets visited. Ancestry uses the parenthesis property: u is an ancestor of v in the DFS tree iff d[u]<d[v]≤f[v]<f[u] (intervals nest).
show answer
Simple DFS reachability:
def reachable(graph, src, target): visited = set() def visit(u): if u == target: return True visited.add(u) return any(v not in visited and visit(v) for v in graph[u]) return visit(src)
Using the timestamps from the worked example: d[B]=2,f[B]=9. d[F]=6,f[F]=7. [6,7]⊂[2,9], so F is in B's DFS subtree. Yes, F is reachable from B.
d[C]=10,f[C]=11. [10,11] is disjoint from [2,9] — they don't nest. So C is not a descendant of B in the DFS tree. No, C is not reachable from B (you can verify by looking at the edge list — there's no path from B back to C).
Caveat: nesting timestamps give reachability within the DFS tree. For non-tree edges (cross edges, forward edges), you need to check the edge directly. In directed graphs, the timestamp test is necessary but not sufficient for general reachability — it captures DFS-tree ancestry, which is a subset.
P.6maze / grid path with backtracking
Find any path from the top-left to the bottom-right of a 4×4 maze. 0 is passable, 1 is wall:
0 0 1 01 0 1 00 0 0 00 1 1 0
Report one valid sequence of moves (e.g., D for down, R for right).
Find the analogue:
grid cells are vertices, 4-adjacent passable cells are edges. DFS from the start, marking cells visited. Record the path as you go; if you hit a dead end, backtrack (DFS does this automatically as the call stack unwinds).
show answer
One valid path: D, R, R, D, D, R from (0,0) to (3,3). Trace: (0,0) → (1,1)? No, (0,1)... actually we have to navigate around walls.
Path: (0,0) → (0,1) → (1,1) → (2,1) → (2,0) — wait this is going backward. Let me trace more carefully.
Walls at (0,2), (1,0), (1,2), (3,1), (3,2). Open cells form a connected region. One path: (0,0) → (0,1) → (1,1) → (2,1) → (2,2) → (2,3) → (3,3). Moves: R, D, D, R, R, D.
def maze_path(maze): m, n = len(maze), len(maze[0]) visited = [[False]*n for _ in range(m)] path = [] def visit(i, j): if i == m-1 and j == n-1: path.append((i, j)) return True visited[i][j] = True path.append((i, j)) for di, dj in [(1,0),(0,1),(-1,0),(0,-1)]: ni, nj = i+di, j+dj if 0 <= ni < m and 0 <= nj < n and maze[ni][nj] == 0 and not visited[ni][nj]: if visit(ni, nj): return True path.pop() # ← backtrack return False visit(0, 0) return pathmaze = [[0,0,1,0],[1,0,1,0],[0,0,0,0],[0,1,1,0]]print(maze_path(maze)) # Some valid path
Backtracking is the canonical DFS application: when a path doesn't pan out, undo the most recent choice and try the next one. The same pattern is the engine behind N-queens, Sudoku solvers, constraint satisfaction, and combinatorial enumeration.
P.7iterative vs recursive implementation
The standard recursive DFS uses the call stack. Convert it to an iterative version using an explicit stack. Compare the two implementations: in what ways is iterative DFS "the same" as recursive, and in what ways does it differ in observable behavior?
Find the analogue:
replace the call stack with an explicit stack. The visit order can be the same — but the choice of when to mark a vertex visited (on push vs on pop) determines this.
show answer
def dfs_iter(graph, src): stack = [src] visited = set([src]) order = [] while stack: u = stack.pop() order.append(u) # Push neighbors in reverse so the popped order matches recursive order for v in reversed(graph[u]): if v not in visited: visited.add(v) stack.append(v) return orderprint(dfs_iter({'A': ['B', 'C'], 'B': ['D', 'E'], 'C': ['F'], 'D': [], 'E': ['F'], 'F': []}, 'A'))# ['A', 'B', 'D', 'E', 'F', 'C']
Sameness: both produce the same set of visited vertices in the same connected component. Both run in O(V+E).
Differences:
Neighbor order matters. Recursive DFS visits children in the order they appear in the adjacency list. Iterative DFS with naive push order visits them in reverse. Push in reverse to match.
"Visited" timing. Mark visited on push (above) vs on pop. The pop variant lets the same vertex appear in the stack multiple times — fine for correctness but wastes memory and breaks the discover-time tracking. Mark on push for cleaner semantics.
Stack overflow. Recursive DFS hits Python's default 1000-frame recursion limit on long paths. Iterative DFS uses heap memory and scales to the graph size. For graph-trace problems with chain-like structure (e.g., a million-vertex linked list as a graph), iterative is the only option.
Finish timestamps. The recursive version naturally has a "return" moment to record finish time. The iterative version needs an explicit "finish" event on the stack (e.g., push a sentinel like ("FINISH", u)) to recover this — at which point the iterative code is no simpler than the recursive.
Practical recommendation: write DFS recursively unless you specifically need iterative for stack-overflow reasons. The recursive form is shorter, mirrors the math more directly, and integrates with the call stack's natural finish-event timing.
Check problems
Check 1derivation
Prove that DFS on a graph with V vertices and E edges runs in O(V+E) total time, regardless of the graph's structure (sparse vs dense, tree vs cyclic).
Your proof should account for:
The work done at each vertex (the outer "for vertex in graph" pattern).
The work done at each edge (the inner "for neighbor in graph[u]" loop).
Why each edge is examined a bounded number of times.
show solution sketch
Vertex work. The outer loop "for u in graph" runs at most V iterations. The "if u not in visited" guard ensures each vertex's body runs at most once. Each body does constant work plus the inner edge loop.
Total vertex-side work: O(V).
Edge work. Inside the per-vertex body, the inner loop iterates over the adjacency list of u, which has size deg(u). The body of the inner loop does constant work (check visited, maybe recurse).
Summing over all vertices: ∑udeg(u)=2E in an undirected graph (each edge contributes to two adjacency lists) or E in a directed graph (each directed edge contributes to one). In both cases, total edge-side work is O(E).
Each edge examined at most twice (undirected) or once (directed). The edge (u,v) appears in graph[u], so it's examined when DFS visits u. In undirected graphs it also appears in graph[v] and is examined when DFS visits v — but since each vertex is visited at most once, each edge is examined at most twice. In directed graphs, it appears only once.
Total.O(V)+O(E)=O(V+E). ✓
This is the famous "O(V+E) for graph traversal" bound. It holds for both DFS and BFS, and it's tight — every algorithm that visits all reachable vertices must do at least Ω(V+E) work in the worst case.
Check 2diagnosis
The following iterative DFS implementation looks reasonable but produces an incorrect order on some inputs. Find the bug.
def dfs_iter_buggy(graph, src): stack = [src] visited = set() order = [] while stack: u = stack.pop() order.append(u) for v in graph[u]: if v not in visited: visited.add(v) stack.append(v) return order
(a) Construct a small graph and starting vertex where this code visits a vertex more than once (incorrectly).
Trace: stack = ['A']. Pop 'A', append to order. Neighbors: B (not in visited), add to visited, push. C (not in visited), add to visited, push. Stack = ['B', 'C']. Pop 'C', append. Neighbors of 'C': none. Stack = ['B']. Pop 'B', append. Neighbors of 'B': 'C' is already in visited, skip.
Hmm — this trace gives order = ['A', 'C', 'B'], no duplicate. Let me try a different graph: {'A': ['B'], 'B': ['A', 'C'], 'C': []}, starting at 'A'.
Stack = ['A']. Pop 'A' → order=['A']. Neighbors: 'B' not visited, push. visited=B. Stack=['B']. Pop 'B' → order=['A', 'B']. Neighbors: 'A' is NOT in visited (the bug: 'A' was never added to visited!), so push 'A'. Then 'C' not visited, push. Stack=['A', 'C']. Pop 'C' → order=['A', 'B', 'C']. Pop 'A' → order=['A', 'B', 'C', 'A']. Duplicate.
(b) The bug: the source src is never added to visited. Any edge from another vertex back to src causes src to be re-pushed and re-processed.
Less obvious: only newly-pushed neighbors are added to visited. The just-popped vertex isn't, so any back-edge to it from a descendant would push it again. The "marking on push" pattern works only if you also mark the source.
(c) Two valid fixes:
Mark source on initialization: change visited = set() to visited = {src}.
Mark on pop instead of push: replace the per-neighbor marking with
while stack: u = stack.pop() if u in visited: continue # ← skip duplicates visited.add(u) # ← mark on pop order.append(u) for v in graph[u]: if v not in visited: stack.append(v)
The "mark on pop" version trades a bit of duplicate pushing (a vertex can be on the stack multiple times before it's popped) for simpler correctness. The "mark on push" version is more memory-efficient but you have to remember to mark the source. Both produce correct results.
This bug is a real one — I've seen it in production code. It tends to evade simple test cases (forward-only graphs work fine) and only manifests when there's a back edge to the source. The "all vertices on stack exactly once" invariant has to be maintained explicitly.