Blind Plus 100 Visualized

#89 ยท Graphs

Word Ladder

Given beginWord, endWord and a dictionary wordList, return the number of words in the shortest transformation sequence from beginWord to endWord, where each step changes exactly one letter and every intermediate word is in wordList; return 0 if none exists.

HardLeetCode
  • Current
  • On path / queued
  • Done
  • Cycle / blocked
step 1 / 1

Pointers

Variables

Hash map / table

Stack / queue

Foundations & data-structure selection

Implicit graph + queue (BFS)

Words are nodes; two words are adjacent if they differ in one letter. The graph is never built explicitly. BFS visits words in order of distance, so the first time it reaches endWord it has found the shortest ladder.

Memory layout

A FIFO queue of words (with their distance) and a visited set of at most N words.

OperationCost
BFS visitO(N) nodes, each with cost of finding neighbours

Wildcard buckets

Each word of length L contributes L patterns with one letter replaced by * ("hot" โ†’ "*ot", "h*t", "ho*"). Words sharing a pattern are neighbours, so neighbours of a word are found by looking up its L patterns.

Memory layout

A hash map from pattern to the list of words, with N ยท L entries.

OperationCost
buildO(N ยท Lยฒ)
neighbour lookupO(L) patterns

Two frontiers (bidirectional BFS)

Run BFS from beginWord and from endWord simultaneously, always expanding the smaller frontier. The searches meet in the middle, and the number of explored words grows like b^(d/2) instead of b^d.

Memory layout

Two hash sets for the frontiers; the dictionary set doubles as the visited set.

OperationCost
worst casesame O(N ยท Lยฒ), far fewer nodes in practice

Why it fits

  • Every transformation costs one step, so the problem is a shortest path in an unweighted graph: BFS is exactly the right algorithm.
  • Building all Nยฒ word-pair comparisons is wasteful; wildcard buckets or letter substitution find neighbours without them.
  • Bidirectional search helps because the BFS frontier typically grows geometrically; meeting in the middle halves the depth explored from each side.

Why alternatives degrade

DFS:
DFS finds a path, not the shortest one, unless it explores all paths (exponential).
Dijkstra:
All edges cost 1, so a plain BFS queue is both simpler and faster than a priority queue.
Precomputing the full adjacency matrix:
O(Nยฒ ยท L) time and O(Nยฒ) memory, which is far too much for N = 5000.

Four approaches, from naive to production

1 ยท beginner

BFS, scanning the whole list for neighbours

time O(Nยฒ ยท L)space O(N)

Run BFS from beginWord. To find the neighbours of a word, compare it with every word in the list and keep those that differ by one letter and have not been reached.

int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
    if (find(wordList.begin(), wordList.end(), endWord) == wordList.end()) return 0;
    auto differByOne = [](const string& a, const string& b) {
        if (a.size() != b.size()) return false;
        int diff = 0;
        for (size_t i = 0; i < a.size(); i++) if (a[i] != b[i] && ++diff > 1) return false;
        return diff == 1;
    };
    unordered_map<string, int> dist{{beginWord, 1}};
    vector<string> queue{beginWord};
    for (size_t head = 0; head < queue.size(); head++) {
        string word = queue[head];
        if (word == endWord) return dist[word];
        for (const string& next : wordList) {
            if (!dist.count(next) && differByOne(word, next)) {
                dist[next] = dist[word] + 1;
                queue.push_back(next);
            }
        }
    }
    return 0;
}

Flaws & bottlenecks

  • Each dequeued word is compared with all N words: quadratic in the list size.
  • Each comparison costs O(L).
  • N = 5000 gives about 25 million comparisons per letter of word length.

Interviewer cross-questions

Why does BFS give the shortest ladder?

Say: BFS visits words in non-decreasing distance order, so the first time endWord is dequeued its distance is minimal.

Do you need the visited set?

Say: Yes: without it the same word is re-enqueued many times, and cycles make the search infinite.

How do you find neighbours faster?

Say: Try every letter substitution (26 ยท L candidates) or use wildcard buckets.

2 ยท intermediate

BFS with letter substitution

time O(N ยท L ยท 26 ยท L)space O(N ยท L)

Store the dictionary in a hash set. For each word, replace each position with every letter aโ€“z and keep the candidates that are in the set and unseen.

int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
    unordered_set<string> words(wordList.begin(), wordList.end());
    if (!words.count(endWord)) return 0;
    vector<pair<string, int>> queue{{beginWord, 1}};
    unordered_set<string> seen{beginWord};
    for (size_t head = 0; head < queue.size(); head++) {
        auto [word, steps] = queue[head];
        if (word == endWord) return steps;
        for (size_t i = 0; i < word.size(); i++) {
            for (int c = 97; c < 123; c++) {
                string next = word; next[i] = (char)c;
                if (words.count(next) && !seen.count(next)) {
                    seen.insert(next);
                    queue.push_back({next, steps + 1});
                }
            }
        }
    }
    return 0;
}

Flaws & bottlenecks

  • Generates 26 ยท L candidate strings per word, mostly not words.
  • Each candidate costs O(L) to build and hash.
  • Assumes a 26-letter lowercase alphabet.

Interviewer cross-questions

When is substitution better than scanning the list?

Say: When N is large compared with 26 ยท L: each word costs 26 ยท L hash lookups instead of N comparisons.

What if the alphabet were Unicode?

Say: The 26 would become the alphabet size, and wildcard buckets then win because their cost does not depend on it.

How can the 26 factor go away?

Say: Group words by wildcard pattern so neighbours are found by lookup.

3 ยท advanced

BFS through wildcard buckets

time O(N ยท Lยฒ)space O(N ยท Lยฒ)

Precompute a map from each pattern (one letter replaced by *) to the words that match it. A word's neighbours are the other members of its L buckets; clear a bucket after using it.

int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
    if (find(wordList.begin(), wordList.end(), endWord) == wordList.end()) return 0;
    unordered_map<string, vector<string>> buckets;
    vector<string> all{beginWord}; all.insert(all.end(), wordList.begin(), wordList.end());
    for (const string& word : all) {
        for (size_t i = 0; i < word.size(); i++) {
            string pattern = word.substr(0, i) + '*' + word.substr(i + 1);
            buckets[pattern].push_back(word);
        }
    }
    vector<pair<string, int>> queue{{beginWord, 1}};
    unordered_set<string> seen{beginWord};
    for (size_t head = 0; head < queue.size(); head++) {
        auto [word, steps] = queue[head];
        if (word == endWord) return steps;
        for (size_t i = 0; i < word.size(); i++) {
            string pattern = word.substr(0, i) + '*' + word.substr(i + 1);
            for (const string& next : buckets[pattern]) {
                if (!seen.count(next)) { seen.insert(next); queue.push_back({next, steps + 1}); }
            }
            buckets[pattern].clear();
        }
    }
    return 0;
}

Flaws & bottlenecks

  • The buckets hold N ยท L entries, each key being a string of length L.
  • Building keys costs O(L) each.
  • Does not reduce the number of BFS levels explored.

Interviewer cross-questions

Why is clearing the bucket safe?

Say: Everything in the bucket was enqueued when the bucket was first used, so a second visit could add nothing.

Is the graph ever built?

Say: No: the buckets are an index of the graph; edges are implicit between members of the same bucket.

How does this compare with bidirectional BFS?

Say: Buckets make each expansion cheap; bidirectional search makes fewer expansions. They combine well.

4 ยท expert

Bidirectional BFS

time O(N ยท Lยฒ) worst case; much faster in practicespace O(N ยท L)

Search from both ends at once, always expanding the smaller frontier. Removing words from the dictionary set when they are first reached doubles as the visited check. Stop when a candidate lies in the other frontier.

int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
    unordered_set<string> words(wordList.begin(), wordList.end());
    if (!words.count(endWord)) return 0;
    if (beginWord == endWord) return 1;
    unordered_set<string> front{beginWord};
    unordered_set<string> back{endWord};
    words.erase(beginWord);
    words.erase(endWord);
    int steps = 1;
    while (!front.empty() && !back.empty()) {
        if (front.size() > back.size()) swap(front, back);
        unordered_set<string> next;
        for (const string& word : front) {
            for (size_t i = 0; i < word.size(); i++) {
                for (int c = 97; c < 123; c++) {
                    string cand = word; cand[i] = (char)c;
                    if (back.count(cand)) return steps + 1;
                    if (words.erase(cand)) next.insert(cand);
                }
            }
        }
        front = next;
        steps++;
    }
    return 0;
}

Flaws & bottlenecks

  • More delicate invariants: swap the frontiers, delete begin and end from the set first.
  • Gives only the length, not the path (parent maps would need both directions).
  • The worst case (a long chain) is no better than one-directional BFS.

Interviewer cross-questions

Why expand the smaller frontier?

Say: Frontier size drives the cost; always growing the smaller side keeps the total number of expansions near the minimum.

Why is steps + 1 returned on meeting?

Say: steps counts the words from the start side up to the current front; the meeting candidate is one more word on the ladder.

What are the edge cases?

Say: endWord missing from the list (0), beginWord equal to endWord (1), beginWord also in the list, and words of different lengths (which can never be neighbours).

Synchronized code walkthrough

Showing: BFS, scanning the whole list for neighbours โ€” shares the player above; click any step to jump.

int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
    if (find(wordList.begin(), wordList.end(), endWord) == wordList.end()) return 0;
    auto differByOne = [](const string& a, const string& b) {
        if (a.size() != b.size()) return false;
        int diff = 0;
        for (size_t i = 0; i < a.size(); i++) if (a[i] != b[i] && ++diff > 1) return false;
        return diff == 1;
    };
    unordered_map<string, int> dist{{beginWord, 1}};
    vector<string> queue{beginWord};
    for (size_t head = 0; head < queue.size(); head++) {
        string word = queue[head];
        if (word == endWord) return dist[word];
        for (const string& next : wordList) {
            if (!dist.count(next) && differByOne(word, next)) {
                dist[next] = dist[word] + 1;
                queue.push_back(next);
            }
        }
    }
    return 0;
}