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.
while queue:
    size = len(queue)              # snapshot BEFORE the inner loop
    for _ in range(size):
        node = queue.popleft()
        # ... 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
    left: "TreeNode | None" = None
    right: "TreeNode | None" = None
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 []

    levels: list[list[int]] = []
    queue = deque([root])

    while queue:
        level: list[int] = []

        for _ in range(len(queue)):        # exactly this level's nodes
            node = queue.popleft()
            level.append(node.val)
            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}
    steps = 0

    while queue:
        for _ in range(len(queue)):
            node = queue.popleft()
            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

    return -1
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.

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 []

    levels: list[list[int]] = []
    queue = deque([root])

    while queue:
        level: list[int] = []

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

            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
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 []

    levels: list[list[int]] = []
    queue = deque([root])
    left_to_right = True

    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)):
            node = queue.popleft()

            if left_to_right:
                level.append(node.val)
            else:
                level.appendleft(node.val)

            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

    return levels

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

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
    """
    if not grid or not grid[0]:
        return 0

    rows, cols = len(grid), len(grid[0])
    queue: deque[tuple[int, int]] = deque()
    fresh = 0

    # 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:
                queue.append((r, c))
            elif grid[r][c] == 1:
                fresh += 1

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

    minutes = 0

    # Stop as soon as nothing fresh is left, so we do not count a final
    # empty round.
    while queue and fresh > 0:
        for _ in range(len(queue)):
            r, c = queue.popleft()

            for dr, dc in DIRECTIONS:
                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 push: the grid is the visited set
                    fresh -= 1
                    queue.append((nr, nc))

        minutes += 1

    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.

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

    queue: deque[str] = deque([begin_word])
    words.discard(begin_word)
    steps = 1                          # the ladder includes begin_word itself

    while queue:
        for _ in range(len(queue)):
            word = queue.popleft()

            if word == end_word:
                return steps

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

                for letter in string.ascii_lowercase:
                    candidate = prefix + letter + suffix

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

        steps += 1

    return 0
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
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.
front, back = {begin_word}, {end_word}
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