Part III · Trees and Graphs Pattern 8 4 problems

Tree and Graph Depth-First Search

Follow one path to the end, then unwind and try the next. The call stack keeps the path for you, which is why DFS code is so much shorter than the problem sounds.

BFS answers “how far”. DFS answers “which paths” and “what is true of this whole subtree”. It is the workhorse of tree questions, because most tree questions are naturally recursive: the answer for a node is some combination of the answers for its children.

Contents

  1. When to use
  2. Core idea
  3. The three shapes of a DFS
  4. The templates
  5. Common mistakes
  6. Maximum Depth of Binary Tree
  7. Path Sum II
  8. Number of Islands
  9. Clone Graph
  10. Recap

When to use

The trigger. You need to explore whole paths, decide something about a whole subtree or region, or answer a question whose value at a node is built from the values at its children. Also, any time the word is “all”: all paths, all islands, all valid arrangements.
SignalWhat it looks like
Path language“all root-to-leaf paths”, “does a path exist”, “path sum”.
Subtree language“height”, “is it balanced”, “diameter”, “is it a valid BST”, “lowest common ancestor”.
Region language“connected components”, “count the islands”, “flood fill”, “surrounded regions”.
Structure copying“clone this graph”, “serialise and deserialise”, “deep copy with random pointers”.
OrderingTopological sort, cycle detection in a directed graph.
Do not use DFS for shortest paths. DFS finds a path, not the shortest one. If the question says “minimum number of steps”, switch to BFS. Reaching for DFS there is a common and costly reflex.

Core idea

Visit a node, then fully explore its first branch before looking at the second. The recursion does the remembering: the chain of active calls is the current path from the root, and returning from a call is exactly the act of backing up one step. Where BFS needs an explicit queue, DFS gets its stack for free.
A B E C D F 123 456 call stack while visiting C dfs(C) ← top dfs(B) dfs(A) ← root the stack IS the path A → B → C returning from dfs(C) backtracks one step
Figure 8.1 — Visit order 1 to 6. At any moment the active calls spell out the current root-to-node path.

The three shapes of a DFS

Almost every DFS question is one of these three. Naming the shape before you write is the fastest way to get the code right.

ShapeWhat it doesExamples
Bottom-up (return a value)Each call returns a summary of its subtree; the parent combines the children’s returns.Maximum depth, is-balanced, diameter, count nodes.
Top-down (pass state in)Each call carries context from the root down, and records a result at the leaves.Path sum, all root-to-leaf paths, valid BST with bounds.
Mark and spreadNo return value. Visit, mark, recurse into unmarked neighbours.Number of islands, flood fill, connected components.
How to choose. Ask: can a node answer the question using only what its children return? If yes, bottom-up, and the function returns something. If the node needs to know where it came from, top-down, and the function takes an extra parameter. If the question is only “how many separate regions”, mark and spread, and the function returns nothing.

The templates

Definitions used on this page
from dataclasses import dataclass, field


@dataclass
class TreeNode:
    """A binary tree node."""

    val: int = 0                          # 0: default payload when none is given
    # Quoted types: TreeNode is not fully defined yet on these lines.
    left: "TreeNode | None" = None        # None: no left child
    right: "TreeNode | None" = None       # None: no right child


class GraphNode:
    """An undirected graph node with an adjacency list."""

    # val=0: default payload. neighbors=None, not []: a [] default would be
    # one shared list across every node.
    def __init__(self, val: int = 0, neighbors: list["GraphNode"] | None = None):
        self.val = val
        # None means "no list given", so build a fresh empty one for this node.
        self.neighbors = neighbors if neighbors is not None else []
Template A — bottom-up, returns a value
def summarise(node: TreeNode | None) -> int:
    """Combine children's answers into this node's answer."""
    if node is None:
        # 0: the answer for an empty subtree. Max Depth: an empty tree has depth 0.
        return 0                       # the base case defines the whole recursion

    # Trust the calls: each returns the full answer for its subtree.
    left = summarise(node.left)
    right = summarise(node.right)

    # combine(val, left, right): build this node's answer from its children's.
    # Max Depth: 1 + max(left, right), one for this node plus the deeper side.
    return combine(node.val, left, right)
Template B — top-down with backtracking
def collect(root: TreeNode | None) -> list[list[int]]:
    """Record every root-to-leaf path that qualifies."""
    results: list[list[int]] = []
    trail: list[int] = []              # values from the root down to the current node

    # Invariant: on entry and on exit, trail is the path to node's parent.
    def walk(node: TreeNode | None) -> None:
        if node is None:
            return                     # past a leaf: nothing to record

        trail.append(node.val)         # choose

        # qualifies(trail): does this full path count?
        # Path Sum II: sum(trail) == target_sum.
        # Both children None means a leaf, so trail is a full root-to-leaf path.
        if node.left is None and node.right is None and qualifies(trail):
            results.append(list(trail))   # COPY, the trail keeps changing
        else:
            walk(node.left)
            walk(node.right)

        trail.pop()                    # un-choose: this is the backtrack

    walk(root)
    return results

Choose, recurse, un-choose. Every top-down DFS with a shared mutable trail has this shape, and it is the same shape as backtracking.

1 2 3 4 leaves: 4 and 3 trail after each step (bottom = root) 1 push 1 1 2 push 2 1 2 4 push 4 record 1 2 pop 4 1 pop 2 1 3 push 3 record 1 pop 3 [ ] pop 1
Figure 8.2 — Every push has a matching pop, so the one shared list always equals the path to the current node.

Reading the figure. Each column is the trail list after one step, with the root at the bottom. Amber is a value just pushed. Green is a push that reached a leaf, where a copy is recorded. Blue values are lower on the path. Red labels mark the un-choose step. Notice that the trail grows and shrinks like the call stack in Figure 8.1, and ends empty.

Template C — mark and spread, iterative
def flood(grid: list[list[str]], r: int, c: int) -> None:
    """Mark the whole connected region containing (r, c). No recursion depth risk."""
    rows, cols = len(grid), len(grid[0])   # grid[0]: every row has this width
    stack = [(r, c)]
    grid[r][c] = "0"                   # mark on push

    # Invariant: every cell on the stack is land already marked "0".
    while stack:
        row, col = stack.pop()         # pop from the end: LIFO, so depth-first
        # Down, up, right, left as (row step, col step). No diagonals.
        for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nr, nc = row + dr, col + dc
            # 0 <= ... < rows/cols: stay on the grid. "1": unvisited land only.
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == "1":
                grid[nr][nc] = "0"     # "0": sink it so no one pushes it again
                stack.append((nr, nc))
start 0 1 0 1 0 0 1 1 0 stack: (0,0) pop (0,0) 0 0 0 0 0 0 1 1 0 stack: (1,0) (0,1) pop (0,1) 0 0 0 0 0 0 1 1 0 stack: (1,0) pop (1,0) 0 0 0 0 0 0 0 1 0 stack: (2,0) pop (2,0) 0 0 0 0 0 0 0 0 0 stack: (2,1) one more pop of (2,1) empties the stack. All five land cells are sunk.
Figure 8.3 — Cells are marked when pushed, so each land cell enters the stack once and the region drains in linear time.

Reading the figure. Violet cells are land not reached yet. Amber cells are on the stack, and they already read "0" because marking happens on push. Red cells were popped and are done. Grey cells are water. Notice that (0,0) is never pushed again when (0,1) looks back at it.

Common mistakes

The problems

1. Maximum Depth of Binary Tree Easy

Problem

Return the number of nodes along the longest path from the root down to the furthest leaf.

Approach

Solution: recursive

def max_depth(root: TreeNode | None) -> int:
    """Number of nodes on the longest root-to-leaf path.

    Args:
        root: Root node, or None.

    Returns:
        0 for an empty tree, 1 for a single node, and so on.
    """
    if root is None:
        return 0                       # 0: an empty tree has no nodes on any path

    # 1 +: count this node, then add the deeper of the two subtrees.
    return 1 + max(max_depth(root.left), max_depth(root.right))
3 9 20 15 7 returns 1 returns 1 returns 1 returns 2 returns 3 Each call trusts its children, then adds 1. leaf 9: 1 + max(0, 0) = 1 node 20: 1 + max(1, 1) = 2 root 3: 1 + max(1, 2) = 3 An empty child returns 0, so a leaf needs no special case.
Figure 8.4 — Answers flow up. Each node returns one more than its deeper child, and the root returns 3.

Reading the figure. This uses the tree 3, 9, 20, 15, 7 from the BFS page. Blue nodes have finished and returned the purple number. Purple arrows show a child’s answer flowing up to its parent. The green root holds the final answer. The deeper side, through 20, decides the result.

Solution: iterative, if depth is a concern

def max_depth_iterative(root: TreeNode | None) -> int:
    """Same answer with an explicit stack of (node, depth) pairs."""
    if root is None:
        return 0                       # 0: an empty tree has depth 0

    best = 0                           # 0: no depth seen yet
    # 1: the root alone is a path of one node.
    stack: list[tuple[TreeNode, int]] = [(root, 1)]

    # Invariant: best is the deepest depth among nodes popped so far, and
    # every node on the stack carries its own correct depth.
    while stack:
        node, depth = stack.pop()
        best = max(best, depth)

        # depth + 1: a child sits one level below its parent.
        if node.left is not None:
            stack.append((node.left, depth + 1))
        if node.right is not None:
            stack.append((node.right, depth + 1))

    return best
TimeO(n)SpaceO(h)htree height

Space is O(log n) on a balanced tree and O(n) on a degenerate one. Saying that, rather than just “O(h)”, shows you know what h can be.

The variations built on the same three lines

QuestionChange
Minimum depthmin, but a node with one None child is not a leaf, so it must be special-cased. Classic trap.
Is the tree balancedReturn the height, and propagate a sentinel such as -1 upward the moment a subtree is unbalanced.
DiameterReturn the height, and update a nonlocal best with left + right at each node.
Count nodes1 + count(left) + count(right).

Edge cases to raise

Say this out loud: “The base case returning zero for an empty subtree is what makes a leaf come out as one, so no leaf special case is needed.”

2. Path Sum II Medium

Problem

Return every root-to-leaf path whose node values sum to target_sum. Each path is the list of values along it.

Approach

Solution

def path_sum(root: TreeNode | None, target_sum: int) -> list[list[int]]:
    """All root-to-leaf paths whose values sum to target_sum.

    Args:
        root: Root node, or None.
        target_sum: The required total. Values may be negative.

    Returns:
        A list of paths, each a list of values from root to leaf.

    Example:
        Tree 5 / (4, 8) ... with target 22 gives [[5, 4, 11, 2], [5, 8, 4, 5]]
    """
    paths: list[list[int]] = []
    trail: list[int] = []               # values from the root down to the current node

    # remaining: how much the rest of the path still has to add up to.
    # Invariant: on entry and on exit, trail is the path to node's parent.
    def walk(node: TreeNode | None, remaining: int) -> None:
        if node is None:
            return                      # past a leaf: no path here

        trail.append(node.val)          # choose
        remaining -= node.val           # this node pays part of the target

        # Both children None means a leaf. Only full paths may count.
        if node.left is None and node.right is None:
            # == 0: the path used up the target exactly.
            if remaining == 0:
                paths.append(list(trail))    # copy: trail keeps changing
        else:
            walk(node.left, remaining)
            walk(node.right, remaining)

        trail.pop()                     # un-choose, on every exit path

    walk(root, target_sum)              # the whole target is still owed at the root
    return paths

Why the leaf test is left is None and right is None

A node with one child is not a leaf. If you test only remaining == 0, you will report paths that stop halfway down, which is wrong. And if you recurse into a None child and test there, a single-child node whose partial sum matches gets reported twice, once from each None. Test for a real leaf, explicitly.

Why list(trail) and not trail

trail is one list that is mutated throughout the traversal. Appending it stores a reference, and by the time the function returns every stored reference points at the same, now-empty, list. This is the most common bug in every backtracking problem, not just this one.

Walkthrough

Tree with root 5, children 4 and 8, target 22. The recursion drives down 5 → 4 → 11 → 7, which sums to 27, no match, so it pops back to 11 and tries 2, giving 22 and a recorded path. It unwinds all the way to the root, popping each value, then explores the 8 branch.

5 4 8 11 13 4 7 2 5 1 sum 27 sum 22 sum 26 sum 22 sum 18 trail when leaf 2 is reached 5 4 11 2 remaining: 22 - 5 - 4 - 11 - 2 = 0 so record list(trail) then pop 2, pop 11, pop 4 ... results: [5, 4, 11, 2] [5, 8, 4, 5]
Figure 8.5 — Only leaves are tested. Two of the five root-to-leaf paths use up the target exactly.

Reading the figure. Each leaf shows the sum of its root-to-leaf path. Green leaves and edges sum to 22 and are recorded. Red leaves miss the target. The boxes on the right are trail at the first hit. After that leaf, the walk pops values on the way back up, then tries the 8 branch.

TimeO(n · h)SpaceO(h) working, O(n · h) output

Visiting is O(n), but copying a qualifying path costs O(h) each time, and there can be O(n) of them in a pathological tree.

Edge cases to raise

Say this out loud: “Choose, recurse, un-choose. I append a copy of the trail, because the trail itself is mutated all the way through the traversal.”

3. Number of Islands Medium

Problem

A grid of "1" for land and "0" for water. An island is a group of land cells connected horizontally or vertically. Count the islands.

Approach

Solution

# Down, up, right, left as (row step, col step). No diagonals.
DIRECTIONS: tuple[tuple[int, int], ...] = ((1, 0), (-1, 0), (0, 1), (0, -1))


def num_islands(grid: list[list[str]]) -> int:
    """Count connected regions of "1" in a grid, using 4-directional adjacency.

    Args:
        grid: Rows of "1" (land) and "0" (water). Mutated: land is sunk
              to "0" as it is visited.

    Returns:
        The number of islands.

    Example:
        >>> num_islands([["1", "1", "0"], ["0", "1", "0"], ["0", "0", "1"]])
        2
    """
    # grid[0]: the first row. If it is empty, there are no columns.
    if not grid or not grid[0]:
        return 0                     # 0: no cells, so no islands

    rows, cols = len(grid), len(grid[0])   # grid[0]: every row has this width
    islands = 0                      # 0: none found yet

    def sink(start_r: int, start_c: int) -> None:
        """Flood the region containing (start_r, start_c), iteratively.

        An explicit stack avoids Python's recursion limit, which a large
        all-land grid would otherwise blow straight through.
        """
        stack = [(start_r, start_c)]
        grid[start_r][start_c] = "0"   # "0": sink on push so it is never pushed twice

        # Invariant: every cell on the stack is land already sunk to "0".
        while stack:
            r, c = stack.pop()         # pop from the end: LIFO, so depth-first

            for dr, dc in DIRECTIONS:
                nr, nc = r + dr, c + dc
                # 0 <= ... < rows/cols: stay on the grid. "1": unsunk land only.
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == "1":
                    grid[nr][nc] = "0"       # mark on push
                    stack.append((nr, nc))

    # Scan every cell. Invariant: every island touching a scanned cell is
    # counted and fully sunk, so no "1" left behind belongs to it.
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == "1":            # "1": land no flood has reached
                islands += 1                 # a region we have not seen before
                sink(r, c)

    return islands

Walkthrough

On [["1","1","0"],["0","1","0"],["0","0","1"]]: the scan hits (0,0), counts island 1, and sinks (0,0), (0,1), (1,1). The scan continues over now-water cells until (2,2), counts island 2, and sinks it. Answer 2.

scan hits (0,0) 1 1 0 0 1 0 0 0 1 islands = 1 island 1 sunk 0 0 0 0 0 0 0 0 1 islands = 1 scan hits (2,2) 0 0 0 0 0 0 0 0 1 islands = 2 island 2 sunk 0 0 0 0 0 0 0 0 0 islands = 2 answer: 2. The counter moves once per region, not once per cell.
Figure 8.6 — Each new land cell the scan finds starts one island. Sinking it stops the scan from counting it again.

Reading the figure. Violet cells are land not visited yet. Amber is the land cell the scan just found, which adds one to the count. Red cells are sunk to "0". Grey cells are water. Between frame 2 and frame 3 the scan passes over the red cells without counting them.

TimeO(rows × cols)SpaceO(rows × cols) stack worst case

Every cell is examined by the outer scan once and pushed at most once, so the total is linear in the number of cells despite the nested loops.

Recursive version, and why it is risky

def sink_recursive(grid, r, c, rows, cols) -> None:
    """Elegant, but the depth equals the region size."""
    # Off the grid, or not unvisited land ("1"): stop here.
    if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != "1":
        return

    grid[r][c] = "0"                   # "0": sink before recursing, or we loop forever
    for dr, dc in DIRECTIONS:          # spread to all 4 neighbours
        sink_recursive(grid, r + dr, c + dc, rows, cols)
A 300×300 grid of solid land gives a recursion 90,000 frames deep. Python raises RecursionError long before that. Write the recursive version if it is clearer, then say “in production I would make this iterative because the depth is unbounded”. That sentence is worth real points.

Edge cases to raise

Say this out loud: “The counter goes up once per region, not once per cell, because the sink call removes the entire region before the scan continues.”

4. Clone Graph Medium

Problem

Given a reference to a node in a connected, undirected graph, return a deep copy of the whole graph. Each node holds a value and a list of neighbours.

The one hard part

A graph has cycles, so a naive recursive copy loops forever. The fix is a dictionary from original node to its clone, and the critical detail is when you write into it: register the clone before recursing into the neighbours. Then when the recursion comes back around a cycle to a node already in progress, it finds the half-built clone and returns it instead of starting again.

This map does three jobs at once: it is the visited set, it is the place partly-built clones live, and it is how a second edge into the same node finds the same clone rather than making a duplicate.

Solution

def clone_graph(node: GraphNode | None) -> GraphNode | None:
    """Deep-copy a connected undirected graph reachable from node.

    Args:
        node: Any node of the graph, or None for an empty graph.

    Returns:
        The clone corresponding to node, with the whole graph copied.
    """
    if node is None:
        return None                    # empty graph: nothing to copy

    # original -> its clone. Doubles as the visited set, so cycles stop.
    clones: dict[GraphNode, GraphNode] = {}

    def copy(original: GraphNode) -> GraphNode:
        existing = clones.get(original)   # .get returns None if not built yet
        if existing is not None:
            return existing            # already built, or being built right now

        duplicate = GraphNode(original.val)
        clones[original] = duplicate   # REGISTER FIRST, then recurse

        # Each neighbour is cloned (or found in clones) and linked in order.
        duplicate.neighbors = [copy(neighbour) for neighbour in original.neighbors]
        return duplicate

    return copy(node)

Iterative version, BFS flavoured

from collections import deque


def clone_graph_iterative(node: GraphNode | None) -> GraphNode | None:
    """Same result with an explicit queue, so there is no recursion depth limit."""
    if node is None:
        return None                    # empty graph: nothing to copy

    # original -> its clone. Build the start node's clone up front.
    clones = {node: GraphNode(node.val)}
    queue = deque([node])

    # Invariant: every node in the queue already has a clone in `clones`.
    # Its clone's neighbor list is filled in when the node is popped.
    while queue:
        original = queue.popleft()

        for neighbour in original.neighbors:
            # First sighting: make its clone now and queue it once.
            if neighbour not in clones:
                clones[neighbour] = GraphNode(neighbour.val)
                queue.append(neighbour)

            # Link the clones in the same order as the originals.
            clones[original].neighbors.append(clones[neighbour])

    return clones[node]                # the clone of the start node

Walkthrough

A four-node cycle 1 – 2 – 3 – 4 – 1. copy(1) registers clone 1, then recurses to 2, which registers clone 2, then to 3, then to 4. Node 4’s neighbours are 3 and 1; both are already in the map, so both return immediately and the cycle closes without infinite recursion. The recursion then unwinds, filling in each neighbour list.

1 2 3 4 originals call stack: copy(1) → 2 → 3 → 4 clones map, in insert order 1. node 1 → clone 1 2. node 2 → clone 2 3. node 3 → clone 3 4. node 4 → clone 4 register first, then recurse clones 1′ 2′ 3′ 4′ 4′ finds 3′ and 1′ in the map dashed links fill in as calls return
Figure 8.7 — Registering each clone before recursing lets the cycle close through the map instead of looping forever.

Reading the figure. Blue nodes are on the call stack, still waiting for their neighbour lists. Amber is the current call, copy(4). The middle column is clones, filled in the order shown. On the right, 4′ is the clone of node 4. Its green links are done. The dashed links are made as the other calls return.

TimeO(V + E)SpaceO(V)

Each node is created once and each edge is traversed once from each side.

Edge cases to raise

Say this out loud: “I put the clone in the map before I recurse. That is what stops the cycle, because a neighbour that loops back finds the partly-built clone instead of starting a new one.”

Recap

The six things to carry forward

Where this goes next

Pattern 9, Top K Elements, changes the tool. Instead of a traversal it uses a heap, and the trick is realising you almost never need the data sorted, only its extremes.


← 7 — Breadth-First Search 9 — Top K Elements →