Skip to main content

Word Ladder II

Description

A transformation sequence from word beginWord to word endWord using a dictionary wordList is a sequence of words beginWord -> s1 -> s2 -> ... -> sk such that:

  • Every adjacent pair of words differs by exactly one letter.
  • Every si for 1 <= i <= k is present in wordList. Note that beginWord does not need to be in wordList.
  • sk == endWord

Given two words, beginWord and endWord, and a dictionary wordList, return all the shortest transformation sequences from beginWord to endWord, or an empty list if no such sequence exists. Each sequence should be returned as a list of the words [beginWord, s1, s2, ..., sk].

In other words, you need to find every possible way to transform beginWord into endWord by changing one letter at a time (using only words from the dictionary), and return only those paths that use the fewest steps.

Graph showing word transformation network: hit connects to hot, which connects to dot and lot, forming two paths to cog
Graph showing word transformation network: hit connects to hot, which connects to dot and lot, forming two paths to cog

Examples

Example 1

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

Output: [["hit","hot","dot","dog","cog"],["hit","hot","lot","log","cog"]]

Explanation: There are two shortest transformation sequences, both of length 5:

  • "hit" → "hot" → "dot" → "dog" → "cog" (change i→o, h→d, t→g, d→c)
  • "hit" → "hot" → "lot" → "log" → "cog" (change i→o, h→l, t→g, l→c)

Both paths change exactly one letter at each step, every intermediate word exists in the dictionary, and no shorter path exists.

Example 2

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

Output: []

Explanation: The endWord "cog" is not in the wordList, so there is no valid transformation sequence. Every intermediate and final word must be present in the dictionary for a path to be valid.

Example 3

Input: beginWord = "a", endWord = "c", wordList = ["a","b","c"]

Output: [["a","c"]]

Explanation: Changing 'a' to 'c' is a single-letter transformation (the word has length 1). Since "c" is in the dictionary and equals endWord, the shortest path has just 2 words.

Constraints

  • 1 ≤ beginWord.length ≤ 5
  • endWord.length == beginWord.length
  • 1 ≤ wordList.length ≤ 500
  • wordList[i].length == beginWord.length
  • beginWord, endWord, and wordList[i] consist of lowercase English letters
  • beginWord ≠ endWord
  • All the words in wordList are unique
  • The sum of all shortest transformation sequences does not exceed 10^5

Editorial

Brute Force

Intuition

The most natural first instinct is to try every possible path from beginWord to endWord and keep only the shortest ones.

Imagine standing at an intersection in a maze. You pick a direction, walk until you hit a dead end or the exit, then retrace your steps and try another direction. You write down every route that reaches the exit, then compare their lengths and keep only the shortest.

This is exactly what DFS (Depth-First Search) with backtracking does:

  1. Start at beginWord.
  2. At each word, try changing every character to every other letter (a–z).
  3. If the new word is in the dictionary and hasn't been visited on the current path, recurse deeper.
  4. If we reach endWord, record the path and its length.
  5. Backtrack (undo the choice) and try the next possibility.
  6. After exploring everything, return only paths whose length equals the shortest found.

To avoid exploring paths that are already longer than our best answer, we maintain a minLen variable and prune any path that exceeds it. This helps in practice but doesn't change the worst-case exponential running time.

Step-by-Step Explanation

Let's trace with beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]:

Step 1: Start DFS from "hit". path = ["hit"], visited = {"hit"}, minLen = ∞.

Step 2: "hit" has one valid neighbor: "hot" (change 'i' → 'o'). Recurse into dfs("hot").
path = ["hit","hot"], visited = {"hit","hot"}.

Step 3: "hot" has two neighbors: "dot" and "lot". Try "dot" first.
path = ["hit","hot","dot"], visited = {"hit","hot","dot"}.

Step 4: "dot" has neighbor "dog" (change 't' → 'g'). Recurse deeper.
path = ["hit","hot","dot","dog"], visited = {"hit","hot","dot","dog"}.

Step 5: "dog" reaches "cog" (change 'd' → 'c'). Recurse into dfs("cog").
path = ["hit","hot","dot","dog","cog"].

Step 6: "cog" equals endWord! Record Path 1: [hit,hot,dot,dog,cog], length = 5. Set minLen = 5.

Step 7: Backtrack from "cog" → "dog" → "dot" → "hot". Now try "hot"'s second neighbor: "lot".
path = ["hit","hot","lot"], visited = {"hit","hot","lot"}.

Step 8: "lot" has neighbor "log" (change 't' → 'g'). Recurse.
path = ["hit","hot","lot","log"].

Step 9: "log" reaches "cog" (change 'l' → 'c'). Recurse into dfs("cog").
path = ["hit","hot","lot","log","cog"].

Step 10: "cog" equals endWord! Path length = 5 = minLen. Record Path 2: [hit,hot,lot,log,cog].

Step 11: Backtrack fully. No more unvisited neighbors. DFS complete.

Result: [["hit","hot","dot","dog","cog"], ["hit","hot","lot","log","cog"]]

DFS Backtracking — Exploring All Transformation Paths — Watch the DFS explore every possible path through the word transformation graph, backtracking when hitting dead ends or exceeding the minimum path length.

Algorithm

  1. Convert wordList to a set for O(1) lookup. If endWord is not in the set, return empty.
  2. Initialize minLen = ∞ and an empty result list.
  3. Start DFS from beginWord with path = [beginWord] and visited = {beginWord}.
  4. At each recursive call:
    • If path.length > minLen, prune (return immediately).
    • If current word equals endWord, update minLen if shorter, clear old results if strictly shorter, and add path to results.
    • Otherwise, try changing each character (positions 0 to M-1) to each letter a–z.
    • If the new word is in the dictionary and not in visited, add it to visited and path, recurse, then backtrack (remove from both).
  5. Return the result list.

Code

class Solution {
public:
    vector<vector<string>> findLadders(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> wordSet(wordList.begin(), wordList.end());
        if (!wordSet.count(endWord)) return {};

        vector<vector<string>> result;
        int minLen = INT_MAX;

        function<void(const string&, vector<string>&, unordered_set<string>&)> dfs;
        dfs = [&](const string& word, vector<string>& path, unordered_set<string>& visited) {
            if ((int)path.size() > minLen) return;
            if (word == endWord) {
                if ((int)path.size() < minLen) {
                    minLen = path.size();
                    result.clear();
                }
                if ((int)path.size() == minLen) {
                    result.push_back(path);
                }
                return;
            }
            string temp = word;
            for (int i = 0; i < (int)temp.size(); i++) {
                char orig = temp[i];
                for (char c = 'a'; c <= 'z'; c++) {
                    if (c == orig) continue;
                    temp[i] = c;
                    if (wordSet.count(temp) && !visited.count(temp)) {
                        visited.insert(temp);
                        path.push_back(temp);
                        dfs(temp, path, visited);
                        path.pop_back();
                        visited.erase(temp);
                    }
                }
                temp[i] = orig;
            }
        };

        unordered_set<string> visited = {beginWord};
        vector<string> path = {beginWord};
        dfs(beginWord, path, visited);
        return result;
    }
};
class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: list[str]) -> list[list[str]]:
        word_set = set(wordList)
        if endWord not in word_set:
            return []

        result = []
        min_len = float('inf')

        def dfs(word, path, visited):
            nonlocal min_len
            if len(path) > min_len:
                return
            if word == endWord:
                if len(path) < min_len:
                    min_len = len(path)
                    result.clear()
                if len(path) == min_len:
                    result.append(list(path))
                return
            for i in range(len(word)):
                for c in 'abcdefghijklmnopqrstuvwxyz':
                    if c == word[i]:
                        continue
                    new_word = word[:i] + c + word[i+1:]
                    if new_word in word_set and new_word not in visited:
                        visited.add(new_word)
                        path.append(new_word)
                        dfs(new_word, path, visited)
                        path.pop()
                        visited.remove(new_word)

        dfs(beginWord, [beginWord], {beginWord})
        return result
class Solution {
    List<List<String>> result = new ArrayList<>();
    int minLen = Integer.MAX_VALUE;

    public List<List<String>> findLadders(String beginWord, String endWord, List<String> wordList) {
        Set<String> wordSet = new HashSet<>(wordList);
        if (!wordSet.contains(endWord)) return result;

        List<String> path = new ArrayList<>();
        path.add(beginWord);
        Set<String> visited = new HashSet<>();
        visited.add(beginWord);
        dfs(beginWord, endWord, wordSet, path, visited);
        return result;
    }

    private void dfs(String word, String endWord, Set<String> wordSet,
                     List<String> path, Set<String> visited) {
        if (path.size() > minLen) return;
        if (word.equals(endWord)) {
            if (path.size() < minLen) {
                minLen = path.size();
                result.clear();
            }
            if (path.size() == minLen) {
                result.add(new ArrayList<>(path));
            }
            return;
        }
        char[] chars = word.toCharArray();
        for (int i = 0; i < chars.length; i++) {
            char orig = chars[i];
            for (char c = 'a'; c <= 'z'; c++) {
                if (c == orig) continue;
                chars[i] = c;
                String newWord = new String(chars);
                if (wordSet.contains(newWord) && !visited.contains(newWord)) {
                    visited.add(newWord);
                    path.add(newWord);
                    dfs(newWord, endWord, wordSet, path, visited);
                    path.remove(path.size() - 1);
                    visited.remove(newWord);
                }
            }
            chars[i] = orig;
        }
    }
}

Complexity Analysis

Time Complexity: O(N! × M × 26) in the worst case

At each step, we potentially try N remaining dictionary words (via M × 26 character substitutions). In the worst case, the DFS explores all permutations of dictionary words, giving a factorial blowup. The pruning by minLen helps in practice but doesn't reduce the worst-case bound.

  • N = number of words in the dictionary (up to 500)
  • M = length of each word (up to 5)
  • For each word, we try M × 26 character changes, each requiring O(M) string operations

Space Complexity: O(N × L)

  • O(N) for the visited set and recursion stack depth (path can be at most N words long)
  • O(L) per stored result path, where L is the path length
  • The result itself can contain up to O(N!) paths in the worst case

Why This Approach Is Not Efficient

The brute force DFS has two critical problems:

1. No guarantee of finding shortest paths first. DFS explores deeply before broadly. It might discover a path of length 10 first, then later find one of length 5. Meanwhile, it wasted time exploring all length-6, 7, 8, 9 paths from the first branch before backtracking. The minLen pruning helps only AFTER finding an initial answer — and the first answer might not be shortest.

2. Exponential time complexity. With N = 500 words, the DFS can explore an astronomical number of paths. Even with pruning, the worst case is factorial: O(N!). For moderate dictionary sizes, this leads to Time Limit Exceeded.

The core insight for improvement: we need BFS, not DFS. BFS explores all paths of length k before exploring any path of length k+1. This guarantees that the FIRST time we reach endWord, we've found the shortest distance. We can then collect ALL paths of that shortest distance without ever exploring longer ones.

Better Approach - BFS with Path Storage

Intuition

Instead of DFS (which dives deep first), we use BFS (Breadth-First Search) which explores all words at the same distance before moving to the next distance. This is like ripples spreading from a stone dropped in water — the wavefront reaches nearby words first.

The key advantage: BFS guarantees that when we first encounter endWord, we're at the shortest possible distance. We process the entire BFS level where endWord appears, collecting ALL shortest paths, then stop.

The approach stores complete paths in the BFS queue. Each queue entry is not just a word, but the entire sequence of words from beginWord to the current word. When we dequeue a path and its last word transforms to endWord, we've found a complete shortest path.

To handle the subtlety of multiple paths reaching the same word: we use a levelVisited set. Words discovered at the current level are not added to the main visited set until the entire level is processed. This allows multiple paths from the same level to discover the same next-level word independently.

Step-by-Step Explanation

Let's trace with beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]:

Step 1: Initialize. Queue = [["hit"]], visited = {"hit"}.

Step 2: Level 0 — process ["hit"]. Transform "hit": find "hot". Enqueue ["hit","hot"]. levelVisited = {"hot"}.

Step 3: Level 0 complete. visited = {"hit","hot"}. Queue = [["hit","hot"]].

Step 4: Level 1 — process ["hit","hot"]. Transform "hot": find "dot" and "lot". Enqueue ["hit","hot","dot"] and ["hit","hot","lot"]. levelVisited = {"dot","lot"}.

Step 5: Level 1 complete. visited = {"hit","hot","dot","lot"}. Queue has 2 paths.

Step 6: Level 2 — process ["hit","hot","dot"]. Transform "dot": find "dog". Enqueue ["hit","hot","dot","dog"]. levelVisited = {"dog"}.

Step 7: Process ["hit","hot","lot"]. Transform "lot": find "log". Enqueue ["hit","hot","lot","log"]. levelVisited = {"dog","log"}.

Step 8: Level 2 complete. visited updated. Queue has 2 paths.

Step 9: Level 3 — process ["hit","hot","dot","dog"]. Transform "dog": find "cog" = endWord! Path 1: ["hit","hot","dot","dog","cog"]. Set found = true.

Step 10: Process ["hit","hot","lot","log"]. Transform "log": find "cog" = endWord! Path 2: ["hit","hot","lot","log","cog"].

Step 11: Level 3 complete. found = true. Stop BFS.

Result: [["hit","hot","dot","dog","cog"], ["hit","hot","lot","log","cog"]]

BFS with Path Storage — Level-by-Level Exploration — BFS explores all words at the same distance before moving deeper. Each complete path is stored in the queue, guaranteeing we find all shortest paths.

Algorithm

  1. Convert wordList to a set. If endWord is not in it, return empty.
  2. Initialize a BFS queue with [[beginWord]] (a queue of paths). Set visited = {beginWord}.
  3. While queue is not empty and found is false:
    a. Record levelSize = queue.size(). Initialize levelVisited = {}.
    b. Process exactly levelSize paths (current level):
    • Dequeue a path. Get the last word.
    • For each character position, try all 26 letters.
    • If new word equals endWord: add path + [endWord] to results, set found = true.
    • Else if new word is in dictionary and not in visited: enqueue path + [newWord], add to levelVisited.
      c. After the level: visited |= levelVisited.
  4. Return results.

Code

class Solution {
public:
    vector<vector<string>> findLadders(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> wordSet(wordList.begin(), wordList.end());
        if (!wordSet.count(endWord)) return {};

        vector<vector<string>> result;
        queue<vector<string>> q;
        q.push({beginWord});
        unordered_set<string> visited = {beginWord};
        bool found = false;

        while (!q.empty() && !found) {
            int levelSize = q.size();
            unordered_set<string> levelVisited;

            for (int i = 0; i < levelSize; i++) {
                vector<string> path = q.front();
                q.pop();
                string word = path.back();
                string temp = word;

                for (int j = 0; j < (int)temp.size(); j++) {
                    char orig = temp[j];
                    for (char c = 'a'; c <= 'z'; c++) {
                        if (c == orig) continue;
                        temp[j] = c;
                        if (temp == endWord) {
                            vector<string> newPath = path;
                            newPath.push_back(temp);
                            result.push_back(newPath);
                            found = true;
                        } else if (wordSet.count(temp) && !visited.count(temp)) {
                            vector<string> newPath = path;
                            newPath.push_back(temp);
                            q.push(newPath);
                            levelVisited.insert(temp);
                        }
                    }
                    temp[j] = orig;
                }
            }
            for (const string& w : levelVisited) visited.insert(w);
        }
        return result;
    }
};
from collections import deque

class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: list[str]) -> list[list[str]]:
        word_set = set(wordList)
        if endWord not in word_set:
            return []

        result = []
        queue = deque([[beginWord]])
        visited = {beginWord}
        found = False

        while queue and not found:
            level_size = len(queue)
            level_visited = set()

            for _ in range(level_size):
                path = queue.popleft()
                word = path[-1]

                for i in range(len(word)):
                    for c in 'abcdefghijklmnopqrstuvwxyz':
                        if c == word[i]:
                            continue
                        new_word = word[:i] + c + word[i+1:]
                        if new_word == endWord:
                            result.append(path + [new_word])
                            found = True
                        elif new_word in word_set and new_word not in visited:
                            queue.append(path + [new_word])
                            level_visited.add(new_word)

            visited |= level_visited

        return result
class Solution {
    public List<List<String>> findLadders(String beginWord, String endWord, List<String> wordList) {
        Set<String> wordSet = new HashSet<>(wordList);
        List<List<String>> result = new ArrayList<>();
        if (!wordSet.contains(endWord)) return result;

        Queue<List<String>> queue = new LinkedList<>();
        queue.add(new ArrayList<>(Arrays.asList(beginWord)));
        Set<String> visited = new HashSet<>();
        visited.add(beginWord);
        boolean found = false;

        while (!queue.isEmpty() && !found) {
            int levelSize = queue.size();
            Set<String> levelVisited = new HashSet<>();

            for (int i = 0; i < levelSize; i++) {
                List<String> path = queue.poll();
                String word = path.get(path.size() - 1);
                char[] chars = word.toCharArray();

                for (int j = 0; j < chars.length; j++) {
                    char orig = chars[j];
                    for (char c = 'a'; c <= 'z'; c++) {
                        if (c == orig) continue;
                        chars[j] = c;
                        String newWord = new String(chars);
                        if (newWord.equals(endWord)) {
                            List<String> newPath = new ArrayList<>(path);
                            newPath.add(newWord);
                            result.add(newPath);
                            found = true;
                        } else if (wordSet.contains(newWord) && !visited.contains(newWord)) {
                            List<String> newPath = new ArrayList<>(path);
                            newPath.add(newWord);
                            queue.add(newPath);
                            levelVisited.add(newWord);
                        }
                    }
                    chars[j] = orig;
                }
            }
            visited.addAll(levelVisited);
        }
        return result;
    }
}

Complexity Analysis

Time Complexity: O(N × M × 26) for the BFS traversal

  • N = number of words in the dictionary (up to 500)
  • M = length of each word (up to 5)
  • For each word dequeued, we try M positions × 26 letters = 130 transformations
  • Each transformation involves creating a new string in O(M) time
  • In the worst case, all N words are visited, giving O(N × M × 26 × M) = O(N × M² × 26)

However, the BFS also copies full paths when enqueuing. If P paths exist at a given level, each of length L, the copy cost is O(P × L × M). This makes the effective cost O(N × M² × 26 + P × L × M).

Space Complexity: O(P × L × M)

  • The queue stores complete paths. If there are P paths at the widest level, each of length L, and each word has M characters, the queue uses O(P × L × M) memory.
  • In the worst case, the number of paths can be exponential in N (e.g., a dense transformation graph), making the memory usage the bottleneck.
  • The visited set uses O(N × M) space.

Why This Approach Is Not Efficient

While BFS with path storage correctly finds all shortest paths and is much better than DFS brute force, it has a critical memory problem:

The queue stores complete paths, not just words. If 100 paths pass through the same word "dog" at distance 3, the queue holds 100 copies of paths up to "dog". Each path is a list of strings. For a dictionary of 500 words with paths of length 10, the queue can hold millions of string copies.

The redundancy is clear: all 100 paths share the same prefix structure dictated by the BFS tree, yet we store 100 independent copies.

Key insight for the optimal approach: Instead of storing full paths during BFS, store only a predecessor map — for each word, record which words can lead to it at the shortest distance. This map uses O(N²) space in the worst case (each word has at most N predecessors). After BFS completes, reconstruct all paths by DFS backtracking through the predecessor map. This separates the "find shortest distances" phase (BFS, lightweight) from the "enumerate paths" phase (DFS, on-demand).

Optimal Approach - BFS Predecessor Map with DFS Reconstruction

Intuition

The optimal solution splits the work into two clean phases:

Phase 1 — BFS to build a predecessor map:
Run BFS from beginWord, but instead of storing paths in the queue, store only individual words. For each word we discover, record ALL the words that can lead to it at the shortest distance. This creates a directed acyclic graph (DAG) of predecessors.

The key subtlety: when processing a BFS level, a word might be reachable from multiple words at the same level. We must record ALL of these as predecessors — not just the first one found. To achieve this, we mark all current-level words as visited BEFORE processing them, preventing same-level self-references, but we allow multiple different current-level words to independently discover the same next-level word.

Phase 2 — DFS to reconstruct paths:
Starting from endWord, follow the predecessor links backward to beginWord. Each time we reach beginWord, we've found a complete shortest path (reversed). The DFS naturally enumerates all possible paths through the predecessor DAG.

This is like building a family tree (Phase 1: record who is whose parent) and then tracing ancestry (Phase 2: walk backward from a descendant to find all ancestral lines).

Step-by-Step Explanation

Let's trace with beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]:

Phase 1: BFS — Building the Predecessor Map

Step 1: Initialize. currentLevel = {"hit"}, visited = {"hit"}, predecessors = {}.

Step 2: Process level 0: "hit". Mark "hit" as visited. Change characters: find "hot". predecessors["hot"] = {"hit"}. nextLevel = {"hot"}.

Step 3: Process level 1: "hot". Mark "hot" as visited. Find neighbors "dot" and "lot". predecessors["dot"] = {"hot"}, predecessors["lot"] = {"hot"}. nextLevel = {"dot","lot"}.

Step 4: Process level 2: "dot" and "lot". Mark both as visited.

  • From "dot": find "dog" → predecessors["dog"] = {"dot"}.
  • From "lot": find "log" → predecessors["log"] = {"lot"}.
  • nextLevel = {"dog","log"}.

Step 5: Process level 3: "dog" and "log". Mark both as visited.

  • From "dog": find "cog" = endWord → predecessors["cog"] = {"dog"}. found = true.
  • From "log": find "cog" = endWord → add "log" to predecessors: predecessors["cog"] = {"dog","log"}.
  • BFS complete. Level 3 fully processed.

Final predecessor map:

  • hot ← {hit}
  • dot ← {hot}
  • lot ← {hot}
  • dog ← {dot}
  • log ← {lot}
  • cog ← {dog, log}

Phase 2: DFS — Reconstructing All Paths from endWord

Step 6: Start DFS from "cog". path = ["cog"]. predecessors["cog"] = {"dog","log"}.

Step 7: Follow "cog" → "dog". path = ["cog","dog"]. predecessors["dog"] = {"dot"}.

Step 8: Follow "dog" → "dot" → "hot" → "hit". path = ["cog","dog","dot","hot","hit"]. "hit" = beginWord! Reverse to get Path 1: ["hit","hot","dot","dog","cog"].

Step 9: Backtrack to "cog". Follow "cog" → "log". path = ["cog","log"]. predecessors["log"] = {"lot"}.

Step 10: Follow "log" → "lot" → "hot" → "hit". "hit" = beginWord! Reverse to get Path 2: ["hit","hot","lot","log","cog"].

Result: [["hit","hot","dot","dog","cog"], ["hit","hot","lot","log","cog"]]

BFS Predecessor Map + DFS Path Reconstruction — Phase 1: BFS builds a lightweight predecessor map storing only which words lead to which. Phase 2: DFS backtracks from endWord through predecessors to reconstruct all shortest paths.

Algorithm

Phase 1: BFS — Build Predecessor Map

  1. Convert wordList to a set. If endWord is absent, return empty.
  2. Initialize currentLevel = {beginWord}, visited = {beginWord}, predecessors = {}.
  3. While currentLevel is not empty and found is false:
    a. Add all currentLevel words to visited (prevents same-level self-discovery).
    b. Initialize nextLevel = {}.
    c. For each word in currentLevel:
    • For each character position, try all 26 letters.
    • If new word equals endWord: add word to predecessors[endWord], set found = true.
    • Else if new word is in dictionary and not in visited: add to nextLevel, add word to predecessors[newWord].
      d. Set currentLevel = nextLevel.

Phase 2: DFS — Reconstruct All Paths

  1. Start DFS from endWord with path = [endWord].
  2. At each word, if it equals beginWord, reverse path and add to results.
  3. Otherwise, for each predecessor, append it to path, recurse, then backtrack (pop).
  4. Return results.

Code

class Solution {
public:
    vector<vector<string>> findLadders(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> wordSet(wordList.begin(), wordList.end());
        if (!wordSet.count(endWord)) return {};

        // Phase 1: BFS to build predecessor map
        unordered_map<string, unordered_set<string>> predecessors;
        unordered_set<string> currentLevel = {beginWord};
        unordered_set<string> visited = {beginWord};
        bool found = false;

        while (!currentLevel.empty() && !found) {
            // Mark current level as visited before processing
            for (const string& w : currentLevel) visited.insert(w);
            unordered_set<string> nextLevel;

            for (const string& word : currentLevel) {
                string temp = word;
                for (int i = 0; i < (int)temp.size(); i++) {
                    char orig = temp[i];
                    for (char c = 'a'; c <= 'z'; c++) {
                        if (c == orig) continue;
                        temp[i] = c;
                        if (temp == endWord) {
                            predecessors[temp].insert(word);
                            found = true;
                        } else if (wordSet.count(temp) && !visited.count(temp)) {
                            nextLevel.insert(temp);
                            predecessors[temp].insert(word);
                        }
                    }
                    temp[i] = orig;
                }
            }
            currentLevel = nextLevel;
        }

        if (!found) return {};

        // Phase 2: DFS backtracking from endWord
        vector<vector<string>> result;
        vector<string> path = {endWord};

        function<void(const string&)> dfs = [&](const string& word) {
            if (word == beginWord) {
                result.push_back(vector<string>(path.rbegin(), path.rend()));
                return;
            }
            for (const string& pred : predecessors[word]) {
                path.push_back(pred);
                dfs(pred);
                path.pop_back();
            }
        };

        dfs(endWord);
        return result;
    }
};
from collections import defaultdict

class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: list[str]) -> list[list[str]]:
        word_set = set(wordList)
        if endWord not in word_set:
            return []

        # Phase 1: BFS to build predecessor map
        predecessors = defaultdict(set)
        current_level = {beginWord}
        visited = {beginWord}
        found = False

        while current_level and not found:
            visited |= current_level
            next_level = set()

            for word in current_level:
                for i in range(len(word)):
                    for c in 'abcdefghijklmnopqrstuvwxyz':
                        if c == word[i]:
                            continue
                        new_word = word[:i] + c + word[i+1:]
                        if new_word == endWord:
                            predecessors[new_word].add(word)
                            found = True
                        elif new_word in word_set and new_word not in visited:
                            next_level.add(new_word)
                            predecessors[new_word].add(word)

            current_level = next_level

        if not found:
            return []

        # Phase 2: DFS backtracking from endWord
        result = []

        def dfs(word, path):
            if word == beginWord:
                result.append(path[::-1])
                return
            for pred in predecessors[word]:
                path.append(pred)
                dfs(pred, path)
                path.pop()

        dfs(endWord, [endWord])
        return result
class Solution {
    public List<List<String>> findLadders(String beginWord, String endWord, List<String> wordList) {
        Set<String> wordSet = new HashSet<>(wordList);
        List<List<String>> result = new ArrayList<>();
        if (!wordSet.contains(endWord)) return result;

        // Phase 1: BFS to build predecessor map
        Map<String, Set<String>> predecessors = new HashMap<>();
        Set<String> currentLevel = new HashSet<>();
        currentLevel.add(beginWord);
        Set<String> visited = new HashSet<>();
        visited.add(beginWord);
        boolean found = false;

        while (!currentLevel.isEmpty() && !found) {
            visited.addAll(currentLevel);
            Set<String> nextLevel = new HashSet<>();

            for (String word : currentLevel) {
                char[] chars = word.toCharArray();
                for (int i = 0; i < chars.length; i++) {
                    char orig = chars[i];
                    for (char c = 'a'; c <= 'z'; c++) {
                        if (c == orig) continue;
                        chars[i] = c;
                        String newWord = new String(chars);
                        if (newWord.equals(endWord)) {
                            predecessors.computeIfAbsent(newWord, k -> new HashSet<>()).add(word);
                            found = true;
                        } else if (wordSet.contains(newWord) && !visited.contains(newWord)) {
                            nextLevel.add(newWord);
                            predecessors.computeIfAbsent(newWord, k -> new HashSet<>()).add(word);
                        }
                    }
                    chars[i] = orig;
                }
            }
            currentLevel = nextLevel;
        }

        if (!found) return result;

        // Phase 2: DFS backtracking from endWord
        List<String> path = new ArrayList<>();
        path.add(endWord);
        buildPaths(endWord, beginWord, predecessors, path, result);
        return result;
    }

    private void buildPaths(String word, String beginWord,
                            Map<String, Set<String>> predecessors,
                            List<String> path, List<List<String>> result) {
        if (word.equals(beginWord)) {
            List<String> reversed = new ArrayList<>(path);
            Collections.reverse(reversed);
            result.add(reversed);
            return;
        }
        if (!predecessors.containsKey(word)) return;
        for (String pred : predecessors.get(word)) {
            path.add(pred);
            buildPaths(pred, beginWord, predecessors, path, result);
            path.remove(path.size() - 1);
        }
    }
}

Complexity Analysis

Time Complexity: O(N × M × 26 + P × L)

  • Phase 1 (BFS): Each of the N words is processed at most once. For each word, we try M × 26 character substitutions. Each substitution creates a new string in O(M) time. Total: O(N × M² × 26).
  • Phase 2 (DFS): We traverse P paths, each of length L. The DFS visits each predecessor edge once per path. Total: O(P × L).
  • Combined: O(N × M² × 26 + P × L). The constraint guarantees that the sum of all path lengths (P × L) does not exceed 10^5.

Space Complexity: O(N × M + N²)

  • The visited set and currentLevel sets hold up to N words of length M: O(N × M).
  • The predecessor map can have up to N entries. In the worst case, each word can have up to N predecessors (in a dense graph), giving O(N²) for the map.
  • The DFS recursion stack has depth at most L (the path length).
  • The result stores P paths of length L, contributing O(P × L × M).

This is a major improvement over the BFS-with-path-storage approach: the BFS phase uses only O(N × M) memory (one word per queue entry), and the predecessor map compactly encodes all shortest-path information in O(N²) space. Paths are reconstructed on demand rather than stored in the queue.