Algorithms

Depth-First Search — exercises

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 on the directed graph below. Record the discover and finish time of every vertex.

A → B, C
B → D, E
C → F
D →
E → F
F →

Diagnosis. DFS visits a vertex, recursively descends into each unvisited neighbor in order, then returns. Each visit "discovers" the vertex (timestamp ) and each return "finishes" it (timestamp ). The interval is the period during which 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 . At each event (discover or finish), increment and stamp.

eventtvertexstate of stack
discover1A[A]
discover2B[A, B]
discover3D[A, B, D]
finish4D[A, B]
discover5E[A, B, E]
discover6F[A, B, E, F]
finish7F[A, B, E]
finish8E[A, B]
finish9B[A]
discover10C[A, C]
finish11C[A]
finish12A[]

Final timestamps.

The parenthesis property. Compare the intervals: , . The first contains the second — was discovered and finished while was on the stack. is an ancestor of in the DFS tree. Contrast with and : disjoint intervals, no ancestor relationship — they're separate subtrees of .

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 = 0
discover, 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] = time

dfs('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 .)


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.1 cycle detection in directed graph

Use DFS to detect whether a directed graph contains a cycle. Apply your algorithm to:

(a) Graph 1:

(b) Graph 2: (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, 2

def 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']}))     # True
print(has_cycle({'A': ['B', 'C'], 'B': ['C'], 'C': []}))   # False

(a) Has cycle. Starting DFS at : color gray, visit (gray), visit (gray), look at is gray, cycle detected.

(b) No cycle. Starting at : visit , visit (no neighbors), finish (black), finish (black), explore already black, not a back edge. Finish .

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.2 topological sort

Compute a topological ordering of the directed acyclic graph:

A → B, C
B → D
C → D
D → 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 implies . 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., (one valid trace). Sorting by decreasing finish: . ✓ 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 order

print(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 , is either visited within 's DFS subtree (so ) or visited entirely before in some earlier outer-loop call (so ). Either way . 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.3 connected components (undirected)

Count the connected components of this undirected graph:

Vertices: {1, 2, 3, 4, 5, 6}
Edges: (1, 2), (1, 3), (4, 5)
(no edges touching vertex 6)

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: .

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 count

g = {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.4 tree 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.5 reachability and ancestry

Given a directed graph and two vertices and , write a DFS-based function that returns True if is reachable from .

Then use the discover/finish timestamps from the worked example to answer: without running another DFS, is reachable from ? Is reachable from ?

Find the analogue: reachability is the simplest DFS application — just check if the target gets visited. Ancestry uses the parenthesis property: is an ancestor of in the DFS tree iff (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: . . , so is in 's DFS subtree. Yes, is reachable from .

. is disjoint from — they don't nest. So is not a descendant of in the DFS tree. No, is not reachable from (you can verify by looking at the edge list — there's no path from back to ).

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.6 maze / 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 0
1 0 1 0
0 0 0 0
0 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 path

maze = [[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.7 iterative 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 order

print(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 .

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 1 derivation

Prove that DFS on a graph with vertices and edges runs in 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 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: .

Edge work. Inside the per-vertex body, the inner loop iterates over the adjacency list of , which has size . The body of the inner loop does constant work (check visited, maybe recurse).

Summing over all vertices: in an undirected graph (each edge contributes to two adjacency lists) or in a directed graph (each directed edge contributes to one). In both cases, total edge-side work is .

Each edge examined at most twice (undirected) or once (directed). The edge appears in , so it's examined when DFS visits . In undirected graphs it also appears in and is examined when DFS visits — but since each vertex is visited at most once, each edge is examined at most twice. In directed graphs, it appears only once.

Total. . ✓

This is the famous " 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 work in the worst case.

Check 2 diagnosis

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).

(b) Explain the bug precisely.

(c) Propose two valid fixes.

show solution sketch

(a) Counterexample. Graph: {'A': ['B', 'C'], 'B': ['C'], 'C': []}, starting at 'A'.

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 is never added to . Any edge from another vertex back to causes 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:

  1. Mark source on initialization: change visited = set() to visited = {src}.
  2. 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.