Shortest word transformation
Given a beginWord, an endWord and a dictionary wordList of words of
the same length, a transformation sequence starts at beginWord, changes exactly one letter at each
step, and every word after the first must be in wordList. Return the number of words in the shortest
sequence that ends at endWord, counting both ends, or 0 if there is none.
beginWord need not be in the list.
Examples
| Input | Output | Why |
|---|---|---|
beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"] | 5 | hit → hot → dot → dog → cog. |
beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"] | 0 | "cog" is not in the list, so it cannot be used. |
beginWord = "lead", endWord = "gold", wordList = ["load","goad","gold","lean","mead"] | 4 | lead → load → goad → gold. |
Constraints
- 1 ≤ word length ≤ 10
- 1 ≤ wordList.length ≤ 5000
- Lowercase letters only; the list has no duplicates
Hints
Hint 1
Each word is a node, with an edge between words that differ in one letter. The question asks for a shortest path: breadth-first search.
Hint 2
Comparing every pair of words costs O(n² · L). Generate neighbours instead: change each position to each of the 26 letters and look the result up in a set.
Hint 3
Remove a word from the set when it is queued, so it is never queued twice.
Where people lose marks
- Returning the number of steps (edges) instead of the number of words: off by one.
- Allowing the end word when it is not in the list.
- Building all n² edges up front, which times out at 5,000 words.
Dry run on "hit","cog",["hot","dot","dog","lot","log","cog"]
Read it one frame at a time, the way you would trace the code on paper in an interview. Each frame shows the state after the step it describes.
-
Step 1 / 5
Start at "hit": a ladder of one word. Its neighbours are generated by changing one letter at a time and keeping dictionary words.
-
Step 2 / 5
Level 2: "hot", each one letter from a word in the previous level and not seen before.
-
Step 3 / 5
Level 3: "dot" and "lot", each one letter from a word in the previous level and not seen before.
-
Step 4 / 5
Level 4: "dog" and "log", each one letter from a word in the previous level and not seen before.
-
Step 5 / 5
Level 5: "cog", each one letter from a word in the previous level and not seen before. "cog" is reached at level 5: the shortest ladder has 5 words.
How to think about it
Treat each word as a node and join two words that differ in exactly one position. Every step costs the same, so the shortest ladder is a breadth-first search from the begin word, and the first time the end word comes off the queue its distance is the answer.
The graph is never built. A word's neighbours are generated when it is processed: for each of its L positions, try the other 25 letters, and keep the candidates that are still in the dictionary set. Deleting a word from the set as it is queued doubles as the visited mark. That costs O(n · L · 26) lookups in total, instead of comparing all pairs.
Complexity. Time O(n · L² · 26) with string building · Space O(n · L)
The solution, in six languages
Each one is compiled and run against the test table before it is published. Java is reviewed by hand.
from collections import deque
from string import ascii_lowercase
def ladder_length(begin_word, end_word, word_list):
words = set(word_list)
if end_word not in words:
return 0
words.discard(begin_word)
q = deque([(begin_word, 1)]) # (word, words in the ladder so far)
while q:
word, length = q.popleft()
if word == end_word:
return length
for i in range(len(word)): # neighbours: change one position
for ch in ascii_lowercase:
cand = word[:i] + ch + word[i + 1:]
if cand in words:
words.remove(cand) # removing it marks it visited
q.append((cand, length + 1))
return 0#include <queue>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
int solve(std::string beginWord, std::string endWord, const std::vector<std::string>& wordList) {
std::unordered_set<std::string> words(wordList.begin(), wordList.end());
if (!words.count(endWord)) return 0;
words.erase(beginWord);
std::queue<std::pair<std::string, int>> q; // (word, words in the ladder so far)
q.push({beginWord, 1});
while (!q.empty()) {
auto [word, length] = q.front();
q.pop();
if (word == endWord) return length;
for (size_t i = 0; i < word.size(); ++i) {
std::string cand = word;
for (char ch = 'a'; ch <= 'z'; ++ch) { // change position i
cand[i] = ch;
if (words.erase(cand)) q.push({cand, length + 1}); // erase marks it visited
}
}
}
return 0;
}import java.util.*;
class Solution {
public int ladderLength(String beginWord, String endWord, String[] wordList) {
Set<String> words = new HashSet<>(Arrays.asList(wordList));
if (!words.contains(endWord)) return 0;
words.remove(beginWord);
ArrayDeque<String> q = new ArrayDeque<>();
q.add(beginWord);
for (int length = 1; !q.isEmpty(); length++) { // one level per ladder length
for (int k = q.size(); k > 0; k--) {
String word = q.poll();
if (word.equals(endWord)) return length;
char[] cand = word.toCharArray();
for (int i = 0; i < cand.length; i++) {
char keep = cand[i];
for (char ch = 'a'; ch <= 'z'; ch++) {
cand[i] = ch;
String s = new String(cand);
if (words.remove(s)) q.add(s); // remove marks it visited
}
cand[i] = keep;
}
}
}
return 0;
}
}function ladderLength(beginWord, endWord, wordList) {
const words = new Set(wordList);
if (!words.has(endWord)) return 0;
words.delete(beginWord);
const q = [[beginWord, 1]]; // [word, words in the ladder so far]
for (let head = 0; head < q.length; head++) {
const [word, length] = q[head];
if (word === endWord) return length;
for (let i = 0; i < word.length; i++) {
for (let code = 97; code <= 122; code++) { // a..z at position i
const cand = word.slice(0, i) + String.fromCharCode(code) + word.slice(i + 1);
if (words.has(cand)) { words.delete(cand); q.push([cand, length + 1]); }
}
}
}
return 0;
}use std::collections::{HashSet, VecDeque};
fn solve(begin_word: &str, end_word: &str, word_list: &[&str]) -> i64 {
let mut words: HashSet<String> = word_list.iter().map(|w| w.to_string()).collect();
if !words.contains(end_word) {
return 0;
}
words.remove(begin_word);
let mut q = VecDeque::from([(begin_word.to_string(), 1i64)]); // (word, ladder length)
while let Some((word, length)) = q.pop_front() {
if word == end_word {
return length;
}
let mut cand: Vec<u8> = word.into_bytes();
for i in 0..cand.len() {
let keep = cand[i];
for ch in b'a'..=b'z' {
cand[i] = ch;
let s = String::from_utf8(cand.clone()).unwrap();
if words.remove(&s) { // remove marks it visited
q.push_back((s, length + 1));
}
}
cand[i] = keep;
}
}
0
}import qualified Data.Set as S
import Data.Sequence (Seq (..), (|>))
import qualified Data.Sequence as Q
solve :: String -> String -> [String] -> Int
solve begin end list
| not (S.member end dict) = 0
| otherwise = bfs (Q.singleton (begin, 1)) (S.delete begin dict)
where
dict = S.fromList list
-- the set holds the words not yet reached; taking a word out marks it visited
bfs Empty _ = 0
bfs ((w, len) :<| q) left
| w == end = len
| otherwise =
let cands = [take i w ++ [ch] ++ drop (i + 1) w | i <- [0 .. length w - 1], ch <- ['a' .. 'z']]
fresh = S.toList (S.fromList (filter (`S.member` left) cands))
in bfs (foldl (|>) q [(c, len + 1) | c <- fresh]) (foldr S.delete left fresh)
Variants to try
- Return every shortest ladder: record all parents at each level, then enumerate paths backwards.
- Search from both ends at once and stop when the frontiers meet (bidirectional search).
- Letters cost different amounts to change: Dijkstra on the same implicit graph.
Next problems
Shortest path through a grid
Breadth-first search reaches every cell in the fewest moves because it explores in rings of equal distance. Why it beats depth-first search is the lesson.
Open the problem → Medium20 minDistance to the nearest zero
For every cell of a 0/1 grid, return the number of steps to the nearest 0. One breadth-first search that starts from all the zeros at once.
Open the problem → Medium18 minMinutes until every orange rots
Start the search from every rotten orange at once. The answer is the number of rings it takes to reach the last fresh one.
Open the problem →Write a solution and run it against the real test table.
Keep going
Shortest path through a grid
Breadth-first search reaches every cell in the fewest moves because it explores in rings of equal distance. Why it beats depth-first search is the lesson.
Open the problem → Medium20 minDistance to the nearest zero
For every cell of a 0/1 grid, return the number of steps to the nearest 0. One breadth-first search that starts from all the zeros at once.
Open the problem → Medium18 minMinutes until every orange rots
Start the search from every rotten orange at once. The answer is the number of rings it takes to reach the last fresh one.
Open the problem → GraphsThe pattern behind it
Grids, networks and dependencies are all graphs. Breadth-first search finds shortest paths, depth-first search and union-find find what is connected, Dijkstra handles weighted edges, and a queue of nodes with no remaining prerequisites gives an order.
Read the pattern →Preparing for a real process? Quant interview preparation, or book a free 20-minute call.