Skip to content

Volume ITechnology

4.20.13 — Word Ladder ​

LeetCode 127 · Hard · ★ Blind 75

The problem ​

Transform beginWord into endWord, changing one letter at a time, where every intermediate word must be in the given word list. Return the number of words in the shortest such sequence, or 0 if there is none.

beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log","cog"]

hit → hot → dot → dog → cog       →  5   (the count includes both ends)

In plain English, we are given a starting word, a target word, and a dictionary of allowed words. We want to find the shortest possible transformation sequence from the start word to the target word. Each step in the transformation must change exactly one character, and every intermediate word must exist in the dictionary. If no such sequence exists, return 0.

Words are up to 10 letters, and the list holds up to 5,000 words.

The pattern ​

This is a graph, even though nobody gave you one. Each word is a node, and two words are joined by an edge when they differ in exactly one letter.

The ELI5 Analogy ​

Imagine a massive puzzle book where every page has a word. You want to get from the page "HIT" to the page "COG". The rule is you can only turn to a page if its word differs by exactly one letter from your current page. If you try to flip through the entire book to see which pages are 1 letter away from "HIT", it takes forever (comparing words). Instead, you take a piece of paper, write down "HIT", and systematically erase one letter at a time to guess new words: "_IT" (AIT, BIT, CIT...), "H_T" (HAT, HBT, HCT...), "HI_" (HIA, HIB, HIC...). For each guess, you check the index of the book to see if that page exists. Because the book is huge but words are short, guessing and checking the index is vastly faster than reading every single page.

The question is "the fewest steps", every step costs the same, so it is BFS — and only BFS. This is the clearest example in the book of a problem where the graph is implicit and the whole skill is recognising it.

Two things then have to be decided: how to find a word's neighbours, and how to avoid revisiting.

Finding neighbours cheaply ​

The obvious way is to compare the current word against every word in the list and keep those differing by one letter. That is O(N \times L) per word and O(N^2 \times L) overall — 25 million character comparisons here, and it is the version that times out.

The better way: generate the neighbours instead of searching for them.

For a word of length L, try replacing each position with each of the 26 letters. That gives 26L candidates, and a set lookup says which of them are real words. For L = 10 that is 260 lookups per word — independent of how many words exist.

O(N \times L \times 26) overall, and with N = 5000 and L = 10 that is about 1.3 million operations instead of 25 million. Generating candidates beats searching for matches whenever the alphabet is small and the dictionary is large, and that trade is the real lesson of this problem.

The solution ​

python
from collections import deque

class Solution:
    def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
        words = set(wordList)
        if endWord not in words:
            return 0                                  # unreachable by definition

        queue = deque([beginWord])
        words.discard(beginWord)
        steps = 1                                     # the count includes beginWord

        while queue:
            for _ in range(len(queue)):               # one level = one step
                word = queue.popleft()
                if word == endWord:
                    return steps

                for i in range(len(word)):
                    for ch in 'abcdefghijklmnopqrstuvwxyz':
                        candidate = word[:i] + ch + word[i+1:]
                        if candidate in words:
                            words.discard(candidate)  # mark visited ON ENQUEUE
                            queue.append(candidate)
            steps += 1

        return 0
ts
function ladderLength(beginWord: string, endWord: string, wordList: string[]): number {
  const words = new Set(wordList);
  if (!words.has(endWord)) return 0;

  let queue: string[] = [beginWord];
  words.delete(beginWord);
  let steps = 1;

  while (queue.length) {
    const next: string[] = [];
    for (const word of queue) {
      if (word === endWord) return steps;

      for (let i = 0; i < word.length; i++) {
        for (let c = 97; c < 123; c++) {
          const candidate = word.slice(0, i) + String.fromCharCode(c) + word.slice(i + 1);
          if (words.has(candidate)) {
            words.delete(candidate);
            next.push(candidate);
          }
        }
      }
    }
    queue = next;
    steps++;
  }

  return 0;
}

Four details.

Removing a word from the set is the visited mark. No separate structure — a word that has been queued is deleted, so it can never be queued again. This is the same "the data structure is the visited set" idea as marking a grid in 4.20.1.

Delete on enqueue, not on dequeue. Several words in the same level may generate the same neighbour; deleting immediately stops it being queued several times.

steps starts at 1, because the problem counts both endpoints. hit → hot → dot → dog → cog is four transformations and five words.

The early check that endWord is in the list saves a full search of a graph that cannot contain the target.

Code Walkthrough & Trace ​

begin="hit", end="cog", words=["hot","dot","dog","cog"]

StepActionQueuewords setsteps
1Init. end is in words.["hit"]{"hot","dot","dog","cog"}1
2Loop len(queue)=1. Pop "hit".[]1
3Generate mutations of "hit". Finds "hot".["hot"]{"dot","dog","cog"}1
4End inner loop. steps++.["hot"]{"dot","dog","cog"}2
5Loop len(queue)=1. Pop "hot".[]2
6Generate mutations of "hot". Finds "dot".["dot"]{"dog","cog"}2
7End inner loop. steps++.["dot"]{"dog","cog"}3
8Loop len(queue)=1. Pop "dot".[]3
9Generate mutations of "dot". Finds "dog".["dog"]{"cog"}3
10End inner loop. steps++.["dog"]{"cog"}4
11Loop len(queue)=1. Pop "dog".[]4
12Generate mutations of "dog". Finds "cog".["cog"]{}4
13End inner loop. steps++.["cog"]{}5
14Loop len=1. Pop "cog". Equals endWord!5 (Return)

Complexity ​

O(N \times L^2) — for each of the N words, you build 26L candidate strings, and each string costs O(L) to construct and hash. The 26 is a constant. To break this down like you're talking to a friend: "We might visit up to all N words. For each word, we try replacing each of its L characters with 26 letters. That's 26 \times L strings we create. But creating a string of length L in most languages takes O(L) time because it has to copy the characters. So generating the candidates for one word takes 26 \times L \times L = O(L^2) time. Across N words, that's O(N \times L^2)."

Space is O(N \times L) for the set and the queue. Storing N words of length L in a hash set takes O(N \times L) memory. The queue can also hold up to N words at worst.

Bidirectional BFS ​

The genuine optimisation, and worth naming even if you do not write it.

Search from both ends at once, alternating, and stop when the two frontiers meet.

A BFS frontier grows roughly like b^d for branching factor b and depth d. Two searches each of depth d/2 cost about 2 \times b^{d/2}, which is dramatically less than b^d. On a ladder of length 10 with a branching factor of 10, that is 200 nodes instead of 10 billion.

The implementation trick: keep two sets, and always expand the smaller one. That keeps both frontiers balanced and is where most of the practical gain comes from.

python
begin_set, end_set = {beginWord}, {endWord}
while begin_set and end_set:
    if len(begin_set) > len(end_set):
        begin_set, end_set = end_set, begin_set     # always expand the smaller
    next_set = set()
    for word in begin_set:
        for i in range(len(word)):
            for ch in 'abcdefghijklmnopqrstuvwxyz':
                candidate = word[:i] + ch + word[i+1:]
                if candidate in end_set:
                    return steps + 1                # the frontiers met
                if candidate in words:
                    words.discard(candidate)
                    next_set.add(candidate)
    begin_set = next_set
    steps += 1

Bidirectional search only works when you know the target, and when edges can be traversed in both directions. Both hold here.

The wildcard preprocessing alternative ​

Another way to find neighbours: build a map from patterns like "h*t" to the words matching them, in one pass. Then a word's neighbours are the union of the lists for its L patterns.

O(N \times L) to build, and each lookup is then cheap. It wins when the alphabet is large — the 26L candidate generation would become |\Sigma| \times L — and it costs more memory.

Naming both approaches and the condition that picks each is a strong answer.

Where this goes next ​

  • Word Ladder II — return all shortest sequences. BFS to find the distances, then DFS backwards from the end using only edges that decrease the distance by one. Much harder, and the two-phase structure is the thing to remember.
  • Open the Lock, Minimum Genetic Mutation, Jump Game III — all BFS over an implicit graph where neighbours are generated by a rule.
  • Sliding Puzzle — the nodes are board states, and neighbours are the legal moves.

The recognition cue for the whole family: states, moves between them, and "the fewest moves". That is BFS over an implicit graph, and you never build the graph.

What the interviewer will push on ​

"Why BFS?" Fewest steps with equal-cost edges. DFS finds a path, not the shortest.

"How do you find neighbours?" Generate 26L candidates and test membership, rather than comparing against every word. Give both complexities.

"When do you mark a word visited?" On enqueue, by deleting it from the set.

"Why does steps start at 1?" Both endpoints are counted.

"How would you speed it up?" Bidirectional BFS, expanding the smaller frontier, with the 2b^{d/2} against b^d argument.

"What if you needed every shortest path?" Word Ladder II: BFS for distances, then DFS backwards along decreasing distances.

One thing to volunteer: say the modelling sentence first. "Each word is a node and an edge joins words differing by one letter, so this is a shortest path on an unweighted graph — BFS." Everything else follows, and the candidates who struggle here are the ones who never said it.

Next: 4.21 adds weights and the structures that go with them — Dijkstra, minimum spanning trees, and the union-find applications that show up as innocent-looking puzzles.