Part III · Trees and Graphs Pattern 7 4 problems

Tree and Graph Breadth-First Search

A Queue<T>, a visited set, and one loop that handles a whole level at a time. The first time BFS reaches a node, it got there by the shortest route.

BFS answers two questions that look different. One is process this tree level by level. The other is find the fewest steps from A to B. Both come from one fact. A queue explores nodes in order of their distance from the start.

C# has no built-in deque. You do not need one here. BFS only adds at the back and removes at the front, and Queue<T> does both in O(1). It is a ring buffer over an array, so it is also cache friendly. The Python version of this page is here.

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 steps have different costs, this is not BFS. It is Dijkstra.

BFS or DFS?

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, state this trade-off. BFS also never touches the call stack, so it cannot throw a StackOverflowException.

Core idea

Keep a queue of nodes to visit and a visited marker so no node enters twice. Dequeue from the front, record it, and enqueue its unvisited neighbours at the back. The queue is first in, first out. So every node at distance d leaves before any node at distance d + 1. The first time a node is reached, it is reached along a shortest path. It never needs a second visit.
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] queue.Count 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.

Reading the figure. Each colour is one level of the tree. The boxes on the right show the queue when each round starts. Blue is round 1, amber is round 2, and green is round 3. Notice that each box holds exactly one level. So queue.Count at that moment is the width of the level.

The level-size trick

At the top of each outer pass the queue holds exactly the nodes of one level. So capture int size = queue.Count before the inner loop, and dequeue exactly that many. Everything enqueued during the inner loop belongs to the next level. It waits for the next round. This one line turns a flat traversal into 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.Count > 0)                   // > 0: stop when nothing is left to visit
{
    int size = queue.Count;               // snapshot BEFORE the inner loop
    for (int i = 0; i < size; i++)        // pop exactly size nodes: this level only
    {
        var node = queue.Dequeue();       // Dequeue: oldest first, so FIFO order
        // ... enqueue children. They land after the snapshot boundary.
    }
}
Never write for (int i = 0; i < queue.Count; i++) as the inner loop. This is the C# trap. A for condition is checked again on every pass. So queue.Count is read again after each child is enqueued, and the loop runs into the next level. Python's range(len(queue)) is safe because it reads the length once. In C# you must copy Count into a local first.

The templates

Node definition used on this page
/// <summary>A binary tree node. One shared type for every block on this page.</summary>
/// <param name="val">The value stored in the node.</param>
/// <param name="left">Left child, or null.</param>
/// <param name="right">Right child, or null.</param>
public sealed class TreeNode(int val = 0, TreeNode? left = null, TreeNode? right = null)
{
    public int Val = val;                 // 0 by default: a value when none is given
    public TreeNode? Left = left;         // null by default: no left child
    public TreeNode? Right = right;       // null by default: no right child
}

A primary constructor keeps the node to three lines. The fields stay mutable, so tests and tree builders can wire children later.

Template A — level-by-level tree BFS
public static class LevelTemplate
{
    /// <summary>Group a tree's values by depth.</summary>
    /// <param name="root">Root node, or null for an empty tree.</param>
    /// <returns>One list per level, top to bottom, each left to right.</returns>
    /// <example><c>ByLevel(new TreeNode(1, new TreeNode(2)))</c>
    /// returns <c>[[1], [2]]</c>.</example>
    public static List<List<int>> ByLevel(TreeNode? root)
    {
        var levels = new List<List<int>>();
        if (root is null)
            return levels;                    // empty tree: no levels at all

        // [root]: a collection expression seeds the queue with level 0.
        var queue = new Queue<TreeNode>([root]);

        // Invariant: at the top of each pass, the queue holds one whole level.
        while (queue.Count > 0)               // > 0: stop when nothing is left
        {
            int size = queue.Count;           // snapshot: this level's width
            var level = new List<int>(size);  // size: exact capacity, no regrowth

            // i counts nodes taken from this level. Invariant: i < size.
            for (int i = 0; i < size; i++)    // 0 to size - 1: this level only
            {
                TreeNode node = queue.Dequeue();   // Dequeue: oldest first (FIFO)
                level.Add(node.Val);
                // Children go to the back. They form the next level.
                if (node.Left is not null) queue.Enqueue(node.Left);
                if (node.Right is not null) queue.Enqueue(node.Right);
            }

            levels.Add(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
public static class StepsTemplate
{
    /// <summary>Fewest edges from start to goal, or -1 if goal is unreachable.</summary>
    /// <typeparam name="TNode">Node type. It needs value equality, such as int,
    /// string, or a (row, col) tuple.</typeparam>
    /// <param name="start">Where the search begins.</param>
    /// <param name="goal">The node to reach.</param>
    /// <param name="neighbours">Returns the nodes one edge away from a node.</param>
    /// <returns>The edge count of a shortest path, or -1.</returns>
    /// <example><c>ShortestSteps(1, 10, n => [n + 1, n * 2])</c> returns <c>4</c>.</example>
    public static int ShortestSteps<TNode>(
        TNode start, TNode goal, Func<TNode, IEnumerable<TNode>> neighbours)
        where TNode : notnull
    {
        var queue = new Queue<TNode>([start]);
        var seen = new HashSet<TNode> { start };  // start is reached before any step
        int steps = 0;                            // 0: start is 0 edges from itself

        // Invariant: the queue holds every node exactly `steps` edges from start.
        while (queue.Count > 0)                   // > 0: nodes still to explore
        {
            int size = queue.Count;               // size: just this distance ring
            for (int i = 0; i < size; i++)        // i: nodes taken from this ring
            {
                TNode node = queue.Dequeue();
                // BFS reaches nodes in distance order, so the first hit is shortest.
                if (EqualityComparer<TNode>.Default.Equals(node, goal))
                    return steps;

                foreach (TNode next in neighbours(node))
                {
                    // Add returns false if already seen. Mark on PUSH, not on pop.
                    if (seen.Add(next))
                        queue.Enqueue(next);
                }
            }

            steps++;                              // + 1: the next ring is one edge farther
        }

        return -1;                                // -1: queue ran dry, goal not reachable
    }
}

HashSet<T>.Add returns bool. One call both tests and marks, so the seen check costs a single hash.

Mark visited when you enqueue, not when you dequeue. If you mark on dequeue, a node with several predecessors can be enqueued many times before it ever leaves. The queue then blows up. On a dense graph this is the gap between O(V + E) and something far worse. It is the 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 dequeued 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

public static class LevelOrderTraversal
{
    /// <summary>Values of a binary tree, grouped by depth.</summary>
    /// <param name="root">Root node, or null.</param>
    /// <returns>One list per level, top to bottom, each left to right.</returns>
    /// <example>Tree 3 / (9, 20) / (15, 7 under 20) gives <c>[[3], [9, 20], [15, 7]]</c>.</example>
    public static List<List<int>> LevelOrder(TreeNode? root)
    {
        var levels = new List<List<int>>();
        if (root is null)
            return levels;                    // empty tree: [] and not [[]]

        var queue = new Queue<TreeNode>([root]);  // level 0 is just the root

        // Invariant: at the top of each pass, the queue holds one whole level.
        while (queue.Count > 0)               // > 0: some level is still waiting
        {
            // Count here is exactly the width of the current level.
            int size = queue.Count;
            var level = new List<int>(size);  // size: exact capacity for this level

            // i counts nodes taken so far. Children pushed now wait for next round.
            for (int i = 0; i < size; i++)
            {
                TreeNode node = queue.Dequeue();   // Dequeue: oldest first (FIFO)
                level.Add(node.Val);

                // Left before right keeps each level in left-to-right order.
                if (node.Left is not null) queue.Enqueue(node.Left);
                if (node.Right is not null) queue.Enqueue(node.Right);
            }

            levels.Add(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 enqueued 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. That is O(n) in the worst case and about n/2 for a full tree.

The family of problems this unlocks

Five interview questions, one loop. That is why the template is worth learning exactly.

Edge cases to raise

Say this out loud: “I copy the queue count into a local before the inner loop. So the nodes I enqueue during the loop are cleanly split off into the next level.”

2. Binary Tree Zigzag Level Order Traversal Medium

Problem

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

Approach

Solution

public static class ZigzagTraversal
{
    /// <summary>Level order, alternating left-to-right and right-to-left.</summary>
    /// <param name="root">Root node, or null.</param>
    /// <returns>One array per level, with odd-numbered levels reversed.</returns>
    /// <example>Tree 3 / (9, 20) / (15, 7 under 20) gives <c>[[3], [20, 9], [15, 7]]</c>.</example>
    public static List<int[]> ZigzagLevelOrder(TreeNode? root)
    {
        var levels = new List<int[]>();
        if (root is null)
            return levels;                    // empty tree: no levels at all

        var queue = new Queue<TreeNode>([root]);  // level 0 is just the root
        bool leftToRight = true;              // level 0 reads left to right

        // Invariant: at the top of each pass, the queue holds one whole level,
        // and leftToRight says which way to write it.
        while (queue.Count > 0)               // > 0: some level is still waiting
        {
            int size = queue.Count;           // snapshot: this level's width
            var level = new int[size];        // size: one slot per node, filled by index

            // i is the visit order inside this level, 0 to size - 1.
            for (int i = 0; i < size; i++)
            {
                TreeNode node = queue.Dequeue();

                // size - 1 - i: the mirror slot, so the visit order reverses.
                int slot = leftToRight ? i : size - 1 - i;
                level[slot] = node.Val;

                // The queue itself always goes left then right. Only `level` flips.
                if (node.Left is not null) queue.Enqueue(node.Left);
                if (node.Right is not null) queue.Enqueue(node.Right);
            }

            levels.Add(level);
            leftToRight = !leftToRight;       // 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 level[1] = 9 9 level[0] = 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 slot each value is written to flips, using a mirrored index.

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 array. BFS still dequeues 9 before 20. Node 9 has i = 0, so it goes to the mirror slot size - 1 - 0 = 1. Node 20 goes to slot 0. 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 gives the right answer on a perfect tree. It gives the wrong one as soon as a node has only one child, because the mirrored order no longer lines up level to level. Keep the traversal standard and change only the output order. Interviewers ask this exact follow-up.
TimeO(n)SpaceO(w)

Each node is written once, straight into its final slot. A List<int> plus level.Reverse() also works in O(n) total. The array version just skips the second touch. A LinkedList<int> with AddFirst mirrors the Python deque. It allocates a node per value, so the array is the better C# choice.

Edge cases to raise

Say this out loud: “I keep the traversal the same and only flip where I write each value. I know the level size up front, so I fill an array from the back on odd levels.”

3. Rotting Oranges Medium

Problem

A grid holds 0 for empty, 1 for a fresh orange, and 2 for a rotten one. Every minute, a rotten orange rots each fresh orange next to it, in the four main directions. Return the minutes until no fresh orange is left, or -1 if that never happens.

Approach

Solution

public static class RottingOranges
{
    // Down, up, right, left as (row step, col step). No diagonals.
    private static readonly (int Dr, int Dc)[] Directions = [(1, 0), (-1, 0), (0, 1), (0, -1)];

    /// <summary>Minutes until every fresh orange rots, or -1 if some never do.</summary>
    /// <param name="grid">0 empty, 1 fresh, 2 rotten. Changed in place as rot spreads.</param>
    /// <returns>Elapsed minutes, or -1 if a fresh orange is unreachable.</returns>
    /// <example><c>OrangesRotting([[2, 1, 1], [1, 1, 0], [0, 1, 1]])</c>
    /// returns <c>4</c>.</example>
    public static int OrangesRotting(int[][] grid)
    {
        // grid[0]: the first row. If it is empty, there are no columns.
        if (grid.Length == 0 || grid[0].Length == 0)
            return 0;                         // 0: no oranges, so no time passes

        int rows = grid.Length, cols = grid[0].Length;  // grid[0]: all rows share this width
        var queue = new Queue<(int R, int C)>();
        int fresh = 0;                        // 0: count starts empty

        // Seed every rotten cell at once: this is a multi-source BFS.
        // r, c walk every cell once, row by row.
        for (int r = 0; r < rows; r++)
        {
            for (int c = 0; c < cols; c++)
            {
                if (grid[r][c] == 2)          // 2: rotten, a starting source
                    queue.Enqueue((r, c));
                else if (grid[r][c] == 1)     // 1: fresh, must rot before we finish
                    fresh++;                  // + 1: one more fresh orange to rot
            }
        }

        if (fresh == 0)                       // 0 fresh: nothing to rot
            return 0;                         // 0 minutes, even for an all-empty grid

        int minutes = 0;                      // 0: time starts before any spread

        // Stop as soon as nothing fresh is left, so we do not count an empty round.
        // Invariant: the queue holds exactly the cells that rotted at `minutes`.
        while (queue.Count > 0 && fresh > 0)  // > 0 twice: a wave exists and work remains
        {
            int size = queue.Count;           // size: one minute's wave only
            for (int i = 0; i < size; i++)    // i: cells taken from this wave
            {
                var (r, c) = queue.Dequeue();

                foreach (var (dr, dc) in Directions)
                {
                    int nr = r + dr, nc = c + dc;
                    // 0 <= ... < rows/cols: stay on the grid. == 1: fresh only.
                    if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] == 1)
                    {
                        grid[nr][nc] = 2;     // 2: rotten. Mark on push: grid is the visited set
                        fresh--;              // - 1: one fewer fresh orange left
                        queue.Enqueue((nr, nc));
                    }
                }
            }

            minutes++;                        // + 1: one wave of spread is one minute
        }

        // fresh == 0: all rotted. Otherwise -1: some orange was walled off.
        return fresh == 0 ? minutes : -1;
    }
}

Walkthrough

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

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. Queue<(int R, int C)> stores value tuples inline in its array, so there is no allocation per cell.

Why the loop guard is queue.Count > 0 && fresh > 0

With only queue.Count > 0, the last round dequeues the final frontier and finds no fresh neighbours. It still adds one to minutes. That over-counts by one. Adding fresh > 0 stops as soon as the job is done. The other fix is to add a minute only when something rotted. Both are fine. Pick one and explain it.
The grid is changed in place. Arrays are reference types in C#, so the caller sees every 2 we write. Say so, and offer to copy first if the caller needs the grid back.

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

You get beginWord, endWord, and a dictionary wordList. Find the length of the shortest chain from beginWord to endWord. Each step changes exactly one letter, and every word after the first must be in the dictionary. The length counts words, not steps. Return 0 if no chain exists.

The reframing

This is a shortest-path problem on a graph that is never built. Each word is a node. 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 choice is how to generate neighbours cheaply.

Generating neighbours: the cost decision

With N words of length L, the second option is O(N · 26 · L²) overall.

Solution

public static class WordLadder
{
    /// <summary>Length of the shortest one-letter-at-a-time word ladder.</summary>
    /// <param name="beginWord">Starting word. It need not be in wordList.</param>
    /// <param name="endWord">Target word. It must be in wordList or the answer is 0.</param>
    /// <param name="wordList">The allowed words after the first.</param>
    /// <returns>Words in the shortest ladder, counting both ends, or 0.</returns>
    /// <example><c>LadderLength("hit", "cog", ["hot", "dot", "dog", "lot", "log", "cog"])</c>
    /// returns <c>5</c>.</example>
    public static int LadderLength(string beginWord, string endWord, IEnumerable<string> wordList)
    {
        var words = new HashSet<string>(wordList);  // O(1) lookup, and our visited set
        if (!words.Contains(endWord))
            return 0;                         // 0: no ladder can end on a missing word

        var queue = new Queue<string>([beginWord]);
        words.Remove(beginWord);              // Remove is safe if it is absent: returns false
        int steps = 1;                        // 1: the ladder includes beginWord itself

        // Invariant: the queue holds every word that is `steps` words into a ladder.
        while (queue.Count > 0)               // > 0: words still to expand
        {
            int size = queue.Count;           // size: this ladder length only
            for (int k = 0; k < size; k++)    // k: words taken from this ring
            {
                string word = queue.Dequeue();

                // BFS finds words in ladder-length order, so the first hit is shortest.
                if (word == endWord)
                    return steps;

                // Strings are immutable, so edit a char[] copy and restore it after.
                char[] chars = word.ToCharArray();
                for (int i = 0; i < chars.Length; i++)   // i: the position being changed
                {
                    char original = chars[i];
                    // 'a' to 'z': try all 26 lowercase letters in this position.
                    for (char letter = 'a'; letter <= 'z'; letter++)
                    {
                        if (letter == original)
                            continue;         // same letter: same word, not a neighbour
                        chars[i] = letter;
                        var candidate = new string(chars);   // O(L) copy

                        // Remove returns true only if it was there. Mark on push.
                        if (words.Remove(candidate))
                            queue.Enqueue(candidate);
                    }
                    chars[i] = original;      // restore before moving to the next position
                }
            }

            steps++;                          // + 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 reached at distance d is never worth reaching again, because any later route is at least as long. Deleting it does two jobs. It stops revisits, and it shrinks the candidate set as the search goes on. That is why no separate seen set appears. HashSet<T>.Remove returns bool, so one call both tests and marks.

Walkthrough

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

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)

Advanced C#: no string per candidate

Most of the 26 · L candidates are not in the dictionary. The version above still builds a string for each one. Since .NET 9, a HashSet<string> can be searched with a ReadOnlySpan<char> through GetAlternateLookup. A miss then allocates nothing. Only a hit hands back the stored string. Mention this if the interviewer asks about allocations or GC pressure.

public static class WordLadderSpan
{
    /// <summary>Same answer as WordLadder.LadderLength, without a string per candidate.</summary>
    /// <param name="beginWord">Starting word. It need not be in wordList.</param>
    /// <param name="endWord">Target word. It must be in wordList or the answer is 0.</param>
    /// <param name="wordList">The allowed words after the first.</param>
    /// <returns>Words in the shortest ladder, counting both ends, or 0.</returns>
    /// <example><c>LadderLength("hit", "cog", ["hot", "dot", "dog", "lot", "log", "cog"])</c>
    /// returns <c>5</c>.</example>
    public static int LadderLength(string beginWord, string endWord, IEnumerable<string> wordList)
    {
        var words = new HashSet<string>(wordList);
        if (!words.Contains(endWord))
            return 0;                         // 0: no ladder can end on a missing word

        // Lets us query the set with a span of chars instead of a string.
        var lookup = words.GetAlternateLookup<ReadOnlySpan<char>>();
        var queue = new Queue<string>([beginWord]);
        words.Remove(beginWord);
        int steps = 1;                        // 1: the ladder includes beginWord itself

        // Invariant: the queue holds every word that is `steps` words into a ladder.
        while (queue.Count > 0)               // > 0: words still to expand
        {
            int size = queue.Count;           // size: this ladder length only
            for (int k = 0; k < size; k++)    // k: words taken from this ring
            {
                string word = queue.Dequeue();
                if (word == endWord)
                    return steps;

                char[] chars = word.ToCharArray();   // one buffer per word, not per candidate
                for (int i = 0; i < chars.Length; i++)   // i: the position being changed
                {
                    char original = chars[i];
                    // 'a' to 'z': try all 26 lowercase letters in this position.
                    for (char letter = 'a'; letter <= 'z'; letter++)
                    {
                        chars[i] = letter;
                        // A hit gives back the stored string, so nothing new is built.
                        if (lookup.TryGetValue(chars, out string? found))
                        {
                            words.Remove(found);   // mark on push
                            queue.Enqueue(found);
                        }
                    }
                    chars[i] = original;      // restore before moving to the next position
                }
            }

            steps++;                          // + 1: the next ring adds one word
        }

        return 0;                             // 0: queue ran dry, no ladder exists
    }
}

This version does not skip letter == original. The current word was already removed from words, so it can never match itself.

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.
var front = new HashSet<string> { beginWord };
var back = new HashSet<string> { endWord };
// Invariant: front and back are the current frontiers, and they do not touch yet.
while (front.Count > 0 && back.Count > 0)  // > 0: both sides can still grow
{
    if (front.Count > back.Count)
        (front, back) = (back, front);     // tuple swap: always expand the cheaper side
    // ... expand front one level. If a neighbour is in back, the halves meet.
}

On a real dictionary this is often several times faster. Mention it with the b^(d/2) argument. It 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 make neighbours by swapping each letter. I delete words from the set 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 does what BFS finds awkward. It lists every path, computes a value bottom-up from the leaves, and flood-fills a connected region.


← 06 — In-Place Reversal of a Linked List 08 — Tree and Graph Depth-First Search →