题目描述

✅ 127. 单词接龙

image-20260928215026963

image-20260928215026964

题意分析

每次恰好改变一个小写字母,变换后的单词必须在词表中,求从 beginWord 到 endWord 的最短序列包含多少个单词。起点不必在词表中,终点必须在词表中;题目保证两端不同且所有单词等长。无法接龙时返回 0,答案包含起点和终点。

解法:双向 BFS

核心思路

[!blue]

把单词看作节点,恰好相差一个字母的两个单词之间连边。每次转换的代价都相同,因此最少转换次数就是无权图的最短距离。无需显式建图:枚举一个单词的每个位置,再尝试其他小写字母,即可找出所有可能的邻居。

从起点和终点同时进行 BFS。front、back 分别保存两侧尚未扩展的当前层,同一集合中的单词到对应起点的距离相同。每轮扩展节点较少的一侧,生成完整下一层;交换集合只改变扩展方向,不改变每侧按层推进的顺序。

dictionary 只保存两侧都未访问过的单词。候选在另一侧当前层中,说明两条搜索路径可以通过这次转换接起来;否则,只有候选仍在词典中时才加入下一层,并立即从词典删除,防止多个节点重复加入它。相遇检查必须在词典检查之前,因为另一侧当前层的单词也已经从词典删除。

双向 BFS 仍能保证第一次相遇最短:两侧更浅的层都已经扩展,若有更短路径,就应当在这些层之间更早相遇。只需检查另一侧当前层,因为更早的层已经检查过全部邻居;一条通往这些旧层的连接,不会等到以后才由未访问节点发现。扩展较小一侧只减少本轮工作量,不会跳过任一侧的层。

设当前两侧层到各自起点的边数为 a、b,代码维护 steps = a + b + 1。两层之间再连一条边后,共有 a + b + 1 次转换,即 steps + 1 个单词。每完成一层扩展,一侧深度增加 1,所以 steps++;即使交换两侧,深度之和也不变。

每个字符位置尝试完后恢复原字符,保证下一位置仍然只改动一位。若任一侧前沿耗尽仍未相遇,该侧已经无法继续到达新单词,两端不连通,返回 0。

解题步骤

  1. 将词表放入哈希集合;若不含 endWord,直接返回 0。
  2. front 从 beginWord 出发,back 从 endWord 出发,并从待访问词典中删除两端。
  3. 若 front 比 back 大,就交换两侧集合,保证扩展较小的 front。
  4. 枚举 front 中每个单词的一位替换:若候选在 back 中,返回 steps + 1;若仍在词典中,删除并加入下一层。
  5. 当前层扩展完后令 front = nextLevel、steps++;任一侧为空仍未相遇则返回 0。

代码实现

class Solution {
    public int ladderLength(String beginWord, String endWord, List<String> wordList) {
        Set<String> dictionary = new HashSet<>(wordList);

        if (!dictionary.contains(endWord)) {
            return 0;
        }

        Set<String> front = new HashSet<>();
        Set<String> back = new HashSet<>();

        front.add(beginWord);
        back.add(endWord);
        dictionary.remove(beginWord);
        dictionary.remove(endWord);

        int steps = 1;

        while (!front.isEmpty() && !back.isEmpty()) {
            if (front.size() > back.size()) {
                Set<String> temp = front;

                front = back;
                back = temp;
            }

            Set<String> nextLevel = new HashSet<>();

            for (String word : front) {
                char[] chars = word.toCharArray();

                for (int idx = 0; idx < chars.length; idx++) {
                    char original = chars[idx];

                    for (char ch = 'a'; ch <= 'z'; ch++) {
                        if (ch == original) {
                            continue;
                        }

                        chars[idx] = ch;
                        String next = new String(chars);

                        // 先查另一侧前沿,其中节点已经从未访问词典移除。
                        if (back.contains(next)) {
                            return steps + 1;
                        }

                        if (dictionary.remove(next)) {
                            nextLevel.add(next);
                        }
                    }

                    // 这一位的所有替换结束后恢复,再枚举下一个位置。
                    chars[idx] = original;
                }
            }

            front = nextLevel;
            steps++;
        }

        return 0;
    }
}
func ladderLength(beginWord string, endWord string, wordList []string) int {
    dictionary := make(map[string]bool, len(wordList))
    for _, word := range wordList {
        dictionary[word] = true
    }
    if !dictionary[endWord] {
        return 0
    }

    front := map[string]bool{beginWord: true}
    back := map[string]bool{endWord: true}
    delete(dictionary, beginWord)
    delete(dictionary, endWord)

    steps := 1
    for len(front) > 0 && len(back) > 0 {
        if len(front) > len(back) {
            front, back = back, front
        }

        nextLevel := make(map[string]bool)
        for word := range front {
            chars := []byte(word)
            for idx := 0; idx < len(chars); idx++ {
                original := chars[idx]
                for ch := byte('a'); ch <= byte('z'); ch++ {
                    if ch == original {
                        continue
                    }
                    chars[idx] = ch
                    next := string(chars)
                    // 先查另一侧前沿,其中节点已经从未访问词典移除。
                    if back[next] {
                        return steps + 1
                    }
                    if !dictionary[next] {
                        continue
                    }
                    delete(dictionary, next)
                    nextLevel[next] = true
                }
                // 这一位的所有替换结束后恢复,再枚举下一个位置。
                chars[idx] = original
            }
        }
        front = nextLevel
        steps++
    }
    return 0
}

复杂度分析

  • 时间复杂度:最坏为 $O(n \cdot 26 \cdot L^2)$,n 为词表大小,L 为单词长度。每个单词至多被扩展一次,枚举 $26L$ 个候选,构造字符串及哈希查询需 $O(L)$。双向搜索主要减少实际访问节点数,不改变最坏上界。
  • 空间复杂度:$O(nL)$,用于词典和两侧搜索集合。

关键点总结

[!green]

  • 这是隐式无权图最短路:动态生成邻居,不必显式建图。
  • 每轮扩展完整一层,较小前沿决定扩展方向,steps 始终等于两侧深度之和加一。
  • 访问标记必须在加入下一层时完成,而不是出队时完成。
  • 相遇时连接的是两侧当前层,返回转换次数加一,才是题目要求的单词数。

易错点总结

[!yellow]

  • 未先确认 endWord 在词表中,会接受题目规定之外的终点。
  • steps 从 0 开始会返回变换次数,而题目要的是单词个数。
  • 替换某一位后忘记恢复原字符,会污染下一位置的候选。
  • 访问到候选却不立刻从词典删除,会让同一层重复加入该单词。
  • 先检查另一前沿再查词典:前沿中的单词已经被删除,顺序反过来会漏掉相遇。

相似题目

题目 难度 关联与区别
433. 最小基因变化 中等 同样在单字符变化构成的状态图上求最少变化,原题基因串固定长度且候选较少。
126. 单词接龙 II 困难 同样求最短单词变化,原题还需保留所有最短前驱以输出全部路径。
752. 打开转盘锁 中等 把合法状态及一次操作建成无权图进行 BFS;本题相差一个字符的单词之间连边,该题按转动一位数字扩展状态。
773. 滑动谜题 困难 把合法状态及一次操作建成无权图进行 BFS;本题相差一个字符的单词之间连边,该题按空位交换扩展棋盘状态。
1091. 二进制矩阵中的最短路径 中等 把合法状态及一次操作建成无权图进行 BFS;本题相差一个字符的单词之间连边,该题按可通行的相邻单元扩展路径。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/35062296
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!