Part III · Trees and Graphs Pattern 7 4 problems

Tree and Graph Breadth-First Search

A queue, a visited set, and one loop that processes a whole level at a time. The first time BFS reaches a node, it has reached it by the shortest route.

BFS is the answer to two very different questions that turn out to be the same question: process this tree level by level, and find the fewest steps from A to B. Both fall out of the same property, that a queue explores in order of distance from the start.

Contents

  1. When to use
  2. Core idea
  3. The level-size trick
  4. The templates
  5. Common mistakes
  6. Binary Tree Level Order Traversal
  7. Binary Tree Zigzag Level Order Traversal
  8. Rotting Oranges
  9. Word Ladder
  10. Recap

When to use

The trigger. Either the answer is organised by level, or the answer is a minimum number of steps in a graph where every step costs the same. If the steps have different costs, this is not BFS, it is Dijkstra.
SignalWhat it looks like
Level language“level order”, “by depth”, “the rightmost node of each row”, “average per level”.
Shortest-path language“minimum number of moves”, “fewest transformations”, “how many minutes until…”.
SpreadingInfection, fire, water, rot. Anything expanding outward one unit of time at a time.
Nearest something“distance to the closest zero”, “nearest exit”. Often a multi-source BFS.

BFS or DFS?

BFSDFS
Best atShortest path, level structureAll paths, connectivity, bottom-up values
Data structureQueue, explicitStack, usually the call stack
MemoryO(width) — can be huge on a wide treeO(depth) — can be huge on a deep one
Finds the shortest path?Yes, on unweighted graphsNo, not without extra work
On a balanced binary tree the last level holds about half the nodes, so BFS uses O(n/2) memory while DFS uses O(log n). On a path-shaped tree it is the reverse. If the interviewer asks about memory, this is the trade-off to state.

Core idea

Keep a queue of nodes to visit and a visited marker so no node enters twice. Pop from the front, record it, push its unvisited neighbours to the back. Because the queue is first in, first out, every node at distance d is dequeued before any node at distance d + 1. So the first time a node is reached, it is reached along a shortest path, and it never needs revisiting.
3 9 20 4 15 7 queue at the top of each round [3] → emit level [3] [9, 20] → emit level [9, 20] [4, 15, 7] → emit level [4, 15, 7] len(queue) at the top of the round is exactly the width of that level
Figure 7.1 — The queue holds exactly one level at the start of each round, which is what makes level grouping free.

The level-size trick

At the top of each outer iteration the queue holds exactly the nodes of one level. So capture size = len(queue) before the inner loop and pop exactly that many. Everything pushed during the inner loop belongs to the next level and is left for the next round. This one line is the difference between a flat traversal and a level-grouped one.
# One outer pass per level. Invariant: at the top, the queue holds exactly
# one whole level and nothing else.
while queue:
    size = len(queue)              # snapshot BEFORE the inner loop
    for _ in range(size):          # pop exactly `size` nodes: this level only
        node = queue.popleft()     # popleft: oldest first, so FIFO order
        # ... push children. They land after the snapshot boundary.
Do not write for _ in range(len(queue)) if the queue is mutated and you re-read it. Python evaluates range(len(queue)) once, so that form is actually safe and is the idiomatic one-liner. The bug is writing while len(queue) > 0 as the inner loop, which never terminates because children keep arriving.

The templates

Node definition used on this page
from dataclasses import dataclass


@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
Template A — level-by-level tree BFS
from collections import deque


def by_level(root: TreeNode | None) -> list[list[int]]:
    """Group a tree's values by depth."""
    if root is None:
        return []                          # empty tree: no levels at all

    levels: list[list[int]] = []
    queue = deque([root])                  # level 0 is just the root

    # Invariant: at the top of each pass, the queue holds one whole level.
    while queue:
        level: list[int] = []

        for _ in range(len(queue)):        # exactly this level's nodes
            node = queue.popleft()         # popleft: oldest first (FIFO)
            level.append(node.val)
            # Children go to the back. They form the next level.
            if node.left is not None:
                queue.append(node.left)
            if node.right is not None:
                queue.append(node.right)

        levels.append(level)

    return levels

A tree needs no visited set, because there is exactly one path to every node. A graph always does.

Template B — shortest path on a grid or graph
def shortest_steps(start, goal, neighbours) -> int:
    """Fewest edges from start to goal, or -1 if unreachable."""
    queue = deque([start])
    seen = {start}                         # start is reached before any step
    steps = 0                              # 0: start is 0 edges from itself

    # Invariant: the queue holds every node exactly `steps` edges from start.
    while queue:
        for _ in range(len(queue)):        # len(queue): just this distance ring
            node = queue.popleft()
            # BFS reaches nodes in distance order, so the first hit is shortest.
            if node == goal:
                return steps

            for nxt in neighbours(node):
                if nxt not in seen:
                    seen.add(nxt)          # mark on PUSH, not on pop
                    queue.append(nxt)

        steps += 1                         # + 1: the next ring is one edge farther

    return -1                              # -1: queue ran dry, goal not reachable
Mark visited when you push, not when you pop. If you mark on pop, a node with several predecessors can be pushed many times before it is ever popped, and the queue blows up. On a dense graph this is the difference between O(V + E) and something exponential. It is the single most common BFS bug.
snapshot when steps = 3 S 1 2 3 1 2 3 G queue now: the two amber cells, both 3 steps away seen: every blue and amber cell grey cells are walls, white cells not reached yet Mark on push: an amber cell went into seen the moment it joined the queue, so a second neighbour can never push it again. BFS reaches G at steps = 6, the shortest path.
Figure 7.2 — The queue always holds one distance ring, so the first time BFS touches the goal is along a shortest path.

Reading the figure. Numbers are the step count from S. Blue cells were popped in earlier rounds. Amber cells are in the queue now. Green is the goal. Notice that the queue holds only one ring. Every cell at distance 3 leaves the queue before any cell at distance 4 is seen.

Common mistakes

The problems

1. Binary Tree Level Order Traversal Medium

Problem

Return the values of a binary tree grouped by level, from left to right, top to bottom.

Approach

Solution

from collections import deque


def level_order(root: TreeNode | None) -> list[list[int]]:
    """Values of a binary tree, grouped by depth.

    Args:
        root: Root node, or None.

    Returns:
        One list per level, top to bottom, each left to right.

    Example:
        Tree 3 / (9, 20) / (None, None, 15, 7) gives [[3], [9, 20], [15, 7]]
    """
    if root is None:
        return []                          # empty tree: no levels at all

    levels: list[list[int]] = []
    queue = deque([root])                  # level 0 is just the root

    # Invariant: at the top of each pass, the queue holds one whole level.
    while queue:
        level: list[int] = []

        # len(queue) here is exactly the width of the current level.
        for _ in range(len(queue)):
            node = queue.popleft()         # popleft: oldest first (FIFO)
            level.append(node.val)

            # Left before right keeps each level in left-to-right order.
            if node.left is not None:
                queue.append(node.left)
            if node.right is not None:
                queue.append(node.right)

        levels.append(level)

    return levels
3 9 20 15 7 tree top of round end of round size = 1 round 1 3 → 9 20 emit [3] size = 2 round 2 9 20 → 15 7 emit [9, 20] size = 2 round 3 15 7 → empty emit [15, 7]
Figure 7.3 — Snapshot the size, pop exactly that many, and the queue then holds the whole next level.

Reading the figure. Amber boxes are the queue when a round starts. That count is size. Blue boxes are the children pushed during the round, which are the next level. Green text is the list the round adds to levels. Each blue row becomes the next amber row.

TimeO(n)SpaceO(w)wmaximum level width

Each node is enqueued and dequeued exactly once. The queue holds at most one level plus part of the next, so its peak is the maximum width, which is O(n) in the worst case and roughly n/2 for a full tree.

The family of problems this unlocks

QuestionChange to the template
Bottom-up level orderBuild normally, then levels.reverse(), or appendleft into a deque.
Right side viewAppend only the last value of each level.
Average of each levelsum(level) / len(level) instead of the list.
Maximum depthCount the rounds of the outer loop.
Minimum depthReturn the round number at the first leaf you dequeue.

Five interview questions, one loop. This is why the template is worth memorising exactly.

Edge cases to raise

Say this out loud: “I snapshot the queue length before the inner loop, so the nodes I push during the loop are cleanly separated into the next level.”

2. Binary Tree Zigzag Level Order Traversal Medium

Problem

Same as above, but alternate direction: the first level left to right, the second right to left, and so on.

Approach

Solution

from collections import deque


def zigzag_level_order(root: TreeNode | None) -> list[list[int]]:
    """Level order, alternating left-to-right and right-to-left.

    Args:
        root: Root node, or None.

    Returns:
        One list per level, with odd-numbered levels reversed.

    Example:
        [[3], [20, 9], [15, 7]]
    """
    if root is None:
        return []                          # empty tree: no levels at all

    levels: list[list[int]] = []
    queue = deque([root])                  # level 0 is just the root
    left_to_right = True                   # level 0 reads left to right

    # Invariant: at the top of each pass, the queue holds one whole level,
    # and left_to_right says which way to write it.
    while queue:
        # A deque lets us write to either end in O(1), so no reversal pass.
        level: deque[int] = deque()

        for _ in range(len(queue)):        # len(queue): this level's nodes only
            node = queue.popleft()

            if left_to_right:
                level.append(node.val)     # back: keeps the visit order
            else:
                level.appendleft(node.val)  # front: reverses the visit order

            # The queue itself always goes left then right. Only `level` flips.
            if node.left is not None:
                queue.append(node.left)
            if node.right is not None:
                queue.append(node.right)

        levels.append(list(level))
        left_to_right = not left_to_right  # flip direction for the next level

    return levels
3 9 20 15 7 level 0: left to right level 1: right to left level 2: left to right level 1, popped as 9 then 20 appendleft(9) 9 appendleft(20) 20 9 the queue order never changes result: [[3], [20, 9], [15, 7]]
Figure 7.4 — The traversal is plain level order. Only the way each level is written out flips, using appendleft.

Reading the figure. Purple arrows show the direction each level is written. The amber level is the one that reads right to left. The boxes on the right are its level deque. BFS still pops 9 before 20. appendleft puts 20 in front, so the level reads [20, 9] with no extra pass.

Why not enqueue the children in reverse order

It is tempting to push right before left on alternating levels. That produces the right answer on a perfect tree and the wrong one as soon as a node has only one child, because the mirrored ordering no longer lines up level to level. Keep the traversal canonical and change only the output order. Interviewers ask this exact follow-up.
TimeO(n)SpaceO(w)

list(level) copies, but each node is copied once overall, so it does not change the bound. Using level.reverse() on a plain list would also be O(n) total; the deque version just avoids the second touch.

Edge cases to raise

Say this out loud: “I keep the traversal identical and only flip how I write each level out, because changing the push order breaks on incomplete trees.”

3. Rotting Oranges Medium

Problem

A grid holds 0 for empty, 1 for a fresh orange, 2 for a rotten one. Every minute, a rotten orange rots each fresh orange directly adjacent to it, in the four cardinal directions. Return the number of minutes until no fresh orange remains, or -1 if that never happens.

Approach

Solution

from collections import deque

# 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 oranges_rotting(grid: list[list[int]]) -> int:
    """Minutes until every fresh orange rots, or -1 if some never do.

    Args:
        grid: 0 empty, 1 fresh, 2 rotten. Mutated in place as rot spreads.

    Returns:
        Elapsed minutes, or -1 if at least one fresh orange is unreachable.

    Example:
        >>> oranges_rotting([[2, 1, 1], [1, 1, 0], [0, 1, 1]])
        4
    """
    # grid[0]: the first row. If it is empty, there are no columns.
    if not grid or not grid[0]:
        return 0                           # 0: no oranges, so no time passes

    rows, cols = len(grid), len(grid[0])   # grid[0]: every row has this width
    queue: deque[tuple[int, int]] = deque()
    fresh = 0                              # 0: count starts empty

    # Seed every rotten cell at once: this is a multi-source BFS.
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 2:            # 2: rotten, a starting source
                queue.append((r, c))
            elif grid[r][c] == 1:          # 1: fresh, must rot before we finish
                fresh += 1                 # + 1: one more fresh orange to rot

    if fresh == 0:
        return 0                       # nothing to rot, even if the grid is empty

    minutes = 0                        # 0: time starts before any spread

    # Stop as soon as nothing fresh is left, so we do not count a final
    # empty round.
    # Invariant: the queue holds exactly the cells that rotted at `minutes`.
    while queue and fresh > 0:
        for _ in range(len(queue)):    # len(queue): one minute's wave only
            r, c = queue.popleft()

            for dr, dc in DIRECTIONS:
                nr, nc = r + dr, c + dc
                # 0 <= ... < rows/cols: stay on the grid. == 1: fresh only.
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                    grid[nr][nc] = 2   # mark on push: the grid is the visited set
                    fresh -= 1         # - 1: one fewer fresh orange left
                    queue.append((nr, nc))

        minutes += 1                   # + 1: one wave of spread is one minute

    # fresh == 0: all rotted. Otherwise -1: some orange was walled off.
    return minutes if fresh == 0 else -1

Walkthrough

[[2,1,1],[1,1,0],[0,1,1]], one rotten orange at the top left and six fresh:

minutenewly rottenfresh left
1(0,1) and (1,0)4
2(0,2) and (1,1)2
3(2,1)1
4(2,2)0

Answer 4.

minute 0 2 1 1 1 1 1 1 fresh = 6 minute 1 2 2 1 2 1 1 1 fresh = 4 minute 2 2 2 2 2 2 1 1 fresh = 2 minute 3 2 2 2 2 2 2 1 fresh = 1 minute 4 2 2 2 2 2 2 2 fresh = 0 answer: 4 minutes. The loop stops once fresh hits 0.
Figure 7.5 — Each round of the outer loop is one minute. The frontier moves one cell outward per round.

Reading the figure. Amber cells are the queue for that minute, the oranges that just turned rotten. Blue cells rotted in an earlier minute. White cells are still fresh. Grey cells are empty. Read left to right. Each amber set touches the one before it, and the count under each grid drops to 0 at minute 4.

TimeO(rows × cols)SpaceO(rows × cols)

Every cell is enqueued at most once. The queue peaks at the size of the rot frontier, which is O(rows × cols) in the worst case.

Why the loop guard is while queue and fresh > 0

With just while queue, the last round dequeues the final frontier, finds no fresh neighbours, and still increments minutes. That over-counts by one. Adding fresh > 0 stops as soon as the job is finished. The alternative fix is to increment only when something actually rotted; both are fine, but pick one and explain it.

Edge cases to raise

Say this out loud: “All the rotten cells start in the queue together, so this is a multi-source BFS and each round of the outer loop is one minute. The grid doubles as the visited set.”

4. Word Ladder Hard

Problem

Given begin_word, end_word, and a dictionary word_list, find the length of the shortest transformation sequence from begin_word to end_word, where each step changes exactly one letter and every intermediate word must be in the dictionary. The length counts words, not steps. Return 0 if there is no such sequence.

The reframing

This is a shortest-path problem on a graph that is never built. Each word is a node, and two words are adjacent when they differ in exactly one letter. Every edge costs the same, so BFS is exactly right. The only real design decision is how to generate neighbours cheaply.

Generating neighbours: the cost decision

MethodCost per wordWhen it wins
Compare against every other wordO(N · L)Never, in an interview. Mention and discard.
Try all 26 letters at each of L positionsO(26 · L²) with slicingThe dictionary is large relative to the word length. This is the standard answer.
Pre-build wildcard buckets like h*tO(L) lookups after O(N · L²) setupMany queries against one dictionary.

With N words of length L: the second option gives O(N · 26 · L²) overall, since building each candidate string by slicing is itself O(L).

Solution

import string
from collections import deque


def ladder_length(begin_word: str, end_word: str, word_list: list[str]) -> int:
    """Length of the shortest one-letter-at-a-time word ladder.

    Args:
        begin_word: Starting word. Need not be in word_list.
        end_word: Target word. Must be in word_list or the answer is 0.
        word_list: The permitted intermediate words.

    Returns:
        Number of words in the shortest ladder, counting both ends, or 0.

    Example:
        >>> ladder_length("hit", "cog", ["hot", "dot", "dog", "lot", "log", "cog"])
        5
    """
    words = set(word_list)             # O(1) membership, and it is our visited set
    if end_word not in words:
        return 0                       # 0: no ladder can end on a banned word

    queue: deque[str] = deque([begin_word])
    words.discard(begin_word)          # discard, not remove: it may not be listed
    steps = 1                          # the ladder includes begin_word itself

    # Invariant: the queue holds every word that is `steps` words into a ladder.
    while queue:
        for _ in range(len(queue)):    # len(queue): this ladder length only
            word = queue.popleft()

            # BFS finds words in ladder-length order, so the first hit is shortest.
            if word == end_word:
                return steps

            # Neighbours: change one letter at a time, in every position.
            for i in range(len(word)):     # i: the position being changed
                # i + 1: the suffix skips the letter at i, which we replace.
                prefix, suffix = word[:i], word[i + 1 :]

                for letter in string.ascii_lowercase:   # try all 26 letters
                    candidate = prefix + letter + suffix

                    if candidate in words:
                        words.remove(candidate)   # mark on push
                        queue.append(candidate)

        steps += 1                     # + 1: the next ring adds one word

    return 0                           # 0: queue ran dry, no ladder exists
Removing from words is the visited set. A word that has been reached at distance d can never be usefully reached again, because any later route to it is at least as long. Deleting it does double duty: it prevents revisits and it shrinks the candidate set as the search proceeds. This is why no separate seen structure appears.

Walkthrough

begin = "hit", end = "cog", dictionary ["hot","dot","dog","lot","log","cog"]:

stepsfrontier
1hit
2hot
3dot, lot
4dog, log
5cog → match, return 5
hit hot dot lot dog log cog steps = 1 steps = 2 steps = 3 steps = 4 steps = 5 cog is first dequeued in round 5, so the answer is 5.
Figure 7.6 — BFS explores the word graph one round at a time, so cog is found at the fewest steps.

Reading the figure. Each column is one round of BFS, and the label on top is the value of steps. Lines join words that differ by one letter. Green boxes and lines trace one shortest ladder. Blue boxes are reached but not on it. Dashed lines join two words in the same round. BFS never uses them, because the far word is already removed from words.

TimeO(N · 26 · L²)SpaceO(N · L)

The follow-up: bidirectional BFS

# Sketch, not full code. Search from both ends and always expand the
# smaller frontier. If the branching factor is b and the answer is at
# depth d, this visits about 2 * b**(d/2) nodes instead of b**d.
# 2 *: two searches. d/2: each only goes half the depth.
front, back = {begin_word}, {end_word}
# Invariant: front and back are the current frontiers, and they do not touch yet.
while front and back:
    if len(front) > len(back):
        front, back = back, front      # always expand the cheaper side
    # ... expand `front` one level. If it touches `back`, the two halves meet.

On a real dictionary this is often several times faster. Mentioning it, with the b**(d/2) argument, is a strong finish to this question.

Edge cases to raise

Say this out loud: “The graph is implicit, so I never build it. I generate neighbours by substituting each letter, and I delete words from the dictionary as I reach them, which is both my visited set and a shrinking search space.”

Recap

The six things to carry forward

Where this goes next

Pattern 8, Depth-First Search, is the other traversal. It cannot find shortest paths, but it can do what BFS finds awkward: enumerate every path, compute a value bottom-up from the leaves, and flood-fill a connected region.


← 6 — In-Place Reversal of a Linked List 8 — Depth-First Search →