LeetCode #127 Hard

Word Ladder

Word Ladder is LeetCode 127 (Hard). Return the length of the shortest transformation sequence from beginWord to endWord, changing one letter at a time, where every intermediate word must appear in wordList. Return 0 if no such sequence exists.

The answer counts words in the sequence, not the number of letter changes. With up to 5000 words of length 10, how the neighbours are found decides whether the solution is fast or quadratic.

Constraints
  • 1 <= beginWord.length <= 10
  • endWord.length == beginWord.length
  • 1 <= wordList.length <= 5000
  • wordList[i].length == beginWord.length
  • beginWord, endWord, and wordList[i] consist of lowercase English letters.
  • beginWord != endWord
  • All the words in wordList are unique.
bfsimplicit-graphstrings
Open on LeetCode ↗
02

Intuition

Every edge costs one transformation, so the shortest word ladder is a BFS. The only real question is what the graph is, and the answer is that it never needs to exist:

  • Don't build it. Comparing every pair of words to find those one letter apart costs O(n²·L) before the search even starts.
  • Generate it on the fly. For the current word, try all 26 letters at each of its L positions and keep the candidates in the word set — O(26·L) per word, however many words there are.

Removing a word from the set as it is enqueued doubles as the visited marker, so nothing is explored twice.

How to spot this pattern

This is implicit graph bfs: the nodes are words and an edge is a single-letter change, but the edges are computed on demand instead of stored. Whenever the neighbours of a state are cheap to generate from the state itself, generating beats building. Minimum Genetic Mutation is the same problem with a four-letter alphabet.

03

Approach

Try it first

Before reading on: count what it costs to find every pair of words differing by one letter when there are 5000 words. Then ask what you could generate from a single word instead, without looking at any of the others. Aim for O(n·L·26) time.

1

Check the end word is in the list first

If endWord is absent from wordList, no shortest transformation sequence can finish on it, so return 0 immediately. The guard costs one lookup and saves searching the entire reachable set before discovering the same thing.

2

Generate neighbours instead of building a graph

For the current word, replace each of its L positions with each of the 26 letters and keep the results found in the word set. Every candidate is one letter away by construction, so no comparison step is needed — at 5000 words that is what lets the solution pass.

3

Keep the word list in a hash set

Membership of each generated candidate must be O(1). With a list instead of a set each check is a linear scan, which quietly reintroduces the quadratic cost the generation step was meant to avoid.

4

Remove each word from the set as it is enqueued

Delete the word from the set the moment it enters the queue:

  • On enqueue: each word is added once, by whichever predecessor reaches it first.
  • On pop (too late): several words in the same layer can all add it before it is popped, and the queue fills with duplicates.
5

Return as soon as the end word is generated

Every edge has the same cost, so word ladder bfs reaches each word by its shortest route first. The moment endWord appears, its distance is already minimal and the search can stop rather than finishing the layer.

6

Count words, not changes

The distance starts at 1 for beginWord and increases by one per layer, because the problem asks for the number of words in the ladder. Starting at 0 gives an answer one too small, which is the usual wrong submission.

04

Word Ladder solution in Python | C++ | Java

▶1class Solution:
▶2 def ladderLength(self, beginWord, endWord, wordList):
▶3 from collections import deque
▶4 words = set(wordList)
▶5 if endWord not in words:
▶6 return 0
▶7 q = deque([(beginWord, 1)])
▶8 words.discard(beginWord)
▶9 while q:
▶10 word, d = q.popleft()
▶11 if word == endWord:
▶12 return d
▶13 for i in range(len(word)):
▶14 for ch in 'abcdefghijklmnopqrstuvwxyz':
▶15 cand = word[:i] + ch + word[i+1:]
▶16 if cand in words:
▶17 words.discard(cand)
▶18 q.append((cand, d+1))
▶19 return 0
wordsbegin hit, end coghitnot reachedhotnot reacheddotnot reacheddognot reachedcognot reachedevery word is a node, one letter apart is an edge
beginhitnot required to be in the list
endcogmust be in the list
words4candidates
Start. Comparing every pair of words to find the one-letter edges costs O(n2·L) before the search even begins. Instead the neighbours are generated: swap each position for each of the 26 letters and keep whatever is in the word set.
wordsbegin hit, end cogqueueemptyhitstep 1hotnot reacheddotnot reacheddognot reachedcognot reachedexpanding hit, step 1
wordhittaken off the queue
distance1words in the ladder so far
queueempty0 waiting
Take hit off the queue. Everything reachable in 1 word(s) is expanded before anything that needs more, which is what makes the first arrival the shortest.
wordsbegin hit, end cogqueueemptyhitstep 1hotnot reacheddotnot reacheddognot reachedcognot reachedposition 0 gives nothing new
position0letter being replaced
tried26 letterseach checked against the set
new wordsnoneall already seen or absent
Replacing position 0 of hit yields nothing new — every match is either absent from the list or already reached by a route no longer than this one.
wordsbegin hit, end cogqueuehothitstep 1hotstep 2dotnot reacheddognot reachedcognot reachedposition 1 gives hot
position1letter being replaced
tried26 letterseach checked against the set
new wordshotdistance 2
Replacing position 1 of hit with each of the 26 letters finds hot in the word set. Each is removed from the set as it is queued, so no other word can add it again.
wordsbegin hit, end cogqueuehothitstep 1hotstep 2dotnot reacheddognot reachedcognot reachedposition 2 gives nothing new
position2letter being replaced
tried26 letterseach checked against the set
new wordsnoneall already seen or absent
Replacing position 2 of hit yields nothing new — every match is either absent from the list or already reached by a route no longer than this one.
wordsbegin hit, end cogqueueemptyhitstep 1hotstep 2dotnot reacheddognot reachedcognot reachedexpanding hot, step 2
wordhottaken off the queue
distance2words in the ladder so far
queueempty0 waiting
Take hot off the queue. Everything reachable in 2 word(s) is expanded before anything that needs more, which is what makes the first arrival the shortest.
wordsbegin hit, end cogqueuedothitstep 1hotstep 2dotstep 3dognot reachedcognot reachedposition 0 gives dot
position0letter being replaced
tried26 letterseach checked against the set
new wordsdotdistance 3
Replacing position 0 of hot with each of the 26 letters finds dot in the word set. Each is removed from the set as it is queued, so no other word can add it again.
wordsbegin hit, end cogqueuedothitstep 1hotstep 2dotstep 3dognot reachedcognot reachedposition 1 gives nothing new
position1letter being replaced
tried26 letterseach checked against the set
new wordsnoneall already seen or absent
Replacing position 1 of hot yields nothing new — every match is either absent from the list or already reached by a route no longer than this one.
wordsbegin hit, end cogqueuedothitstep 1hotstep 2dotstep 3dognot reachedcognot reachedposition 2 gives nothing new
position2letter being replaced
tried26 letterseach checked against the set
new wordsnoneall already seen or absent
Replacing position 2 of hot yields nothing new — every match is either absent from the list or already reached by a route no longer than this one.
wordsbegin hit, end cogqueueemptyhitstep 1hotstep 2dotstep 3dognot reachedcognot reachedexpanding dot, step 3
worddottaken off the queue
distance3words in the ladder so far
queueempty0 waiting
Take dot off the queue. Everything reachable in 3 word(s) is expanded before anything that needs more, which is what makes the first arrival the shortest.
wordsbegin hit, end cogqueueemptyhitstep 1hotstep 2dotstep 3dognot reachedcognot reachedposition 0 gives nothing new
position0letter being replaced
tried26 letterseach checked against the set
new wordsnoneall already seen or absent
Replacing position 0 of dot yields nothing new — every match is either absent from the list or already reached by a route no longer than this one.
wordsbegin hit, end cogqueueemptyhitstep 1hotstep 2dotstep 3dognot reachedcognot reachedposition 1 gives nothing new
position1letter being replaced
tried26 letterseach checked against the set
new wordsnoneall already seen or absent
Replacing position 1 of dot yields nothing new — every match is either absent from the list or already reached by a route no longer than this one.
wordsbegin hit, end cogqueuedoghitstep 1hotstep 2dotstep 3dogstep 4cognot reachedposition 2 gives dog
position2letter being replaced
tried26 letterseach checked against the set
new wordsdogdistance 4
Replacing position 2 of dot with each of the 26 letters finds dog in the word set. Each is removed from the set as it is queued, so no other word can add it again.
wordsbegin hit, end cogqueueemptyhitstep 1hotstep 2dotstep 3dogstep 4cognot reachedexpanding dog, step 4
worddogtaken off the queue
distance4words in the ladder so far
queueempty0 waiting
Take dog off the queue. Everything reachable in 4 word(s) is expanded before anything that needs more, which is what makes the first arrival the shortest.
wordsbegin hit, end cogqueuecoghitstep 1hotstep 2dotstep 3dogstep 4cogstep 5position 0 gives cog
position0letter being replaced
tried26 letterseach checked against the set
new wordscogdistance 5
Replacing position 0 of dog with each of the 26 letters finds cog in the word set. Each is removed from the set as it is queued, so no other word can add it again.
wordsbegin hit, end coghitstep 1hotstep 2dotstep 3dogstep 4cogstep 5answer 5
answer5words in the shortest ladder
Answer 5. That counts the words in the ladder, including hit and cog — not the number of letter changes, which is one fewer. Starting the count at 0 instead of 1 is the usual wrong submission.
05

Bidirectional BFS

Grow a frontier from each end and stop when they touch. Each round expands whichever side is currently smaller, which keeps both frontiers shallow; the moment a generated word appears in the opposite frontier, the two halves join and their depths add up to the answer.

▶1class Solution:
▶2 def ladderLength(self, beginWord, endWord, wordList):
▶3 words = set(wordList)
▶4 if endWord not in words:
▶5 return 0
▶6 front, back = {beginWord}, {endWord}
▶7 words.discard(beginWord)
▶8 length = 1
▶9 while front and back:
▶10 if len(front) > len(back):
▶11 front, back = back, front
▶12 nxt = set()
▶13 for word in front:
▶14 for i in range(len(word)):
▶15 for ch in "abcdefghijklmnopqrstuvwxyz":
▶16 cand = word[:i] + ch + word[i + 1 :]
▶17 if cand in back:
▶18 return length + 1
▶19 if cand in words:
▶20 words.discard(cand)
▶21 nxt.add(cand)
▶22 front = nxt
▶23 length += 1
▶24 return 0
06

Common pitfalls

Comparing every pair of words to build edges

✗ Wrong
for a in words:
    for b in words:
        if differs_by_one(a, b): adj[a].append(b)
✓ Right
for i in range(len(word)):
    for ch in 'abcdefghijklmnopqrstuvwxyz':

That's O(N²·L) in the word count. Generating the 26·L neighbours of a word and testing set membership is O(26·L) per word, independent of how many words exist.

Not removing words when enqueuing

✗ Wrong
if cand in words:
    q.append((cand, d + 1))
✓ Right
if cand in words:
    words.discard(cand)
    q.append((cand, d + 1))

The same word would be reached from several predecessors and enqueued repeatedly, blowing up the queue exponentially. Since BFS finds the shortest route first, later arrivals can be discarded outright.

Skipping the endWord membership check

✗ Wrong
q = deque([(beginWord, 1)])
✓ Right
if endWord not in words:
    return 0

If the target isn't in the word list no transformation sequence exists, and the BFS would exhaust the entire reachable set before returning 0. The check is immediate and states the precondition.

07

Edge cases

endWord is not in wordList

Return 0 before any search — no legal sequence can end on a word that is not a permitted intermediate.

No path exists even though endWord is present

The queue drains without generating endWord, and the function returns 0.

beginWord is not in wordList

That is allowed. It is the starting point, not an intermediate, so it never has to be a member.

Words in the list that are unreachable

They are simply never generated as neighbours, so they cost nothing beyond their place in the set.

08

Complexity

Time
O(n * L * 26)
Space
O(n * L)
n = number of words, L = word length. No pairwise comparison is ever performed.