目录

题目描述

面试题 17.22. 单词转换

题意分析

给定起始单词、目标单词和一本字典,每次只能改动一个字母,且改完之后得到的单词必须在字典里,要求返回一条完整的变换序列(含首尾),无解则返回空列表。注意题目要的是路径本身而不是路径长度,这一点直接决定了必须额外记录「每个单词是从谁变过来的」。

题面还有两个容易忽略的细节:所有单词长度相同,且都由小写字母组成。长度相同意味着变换只有「替换某一位」这一种形式,不存在插入或删除;只含小写字母意味着每一位的候选替换值最多 25 个(排除原字符),这是一个非常小的常数。

字典规模在几千到上万量级,单词长度不超过十几。这两个数字放在一起给出了强烈的信号:枚举「所有可能的邻居字符串」再查字典,比两两比较字典中的单词更划算。前者对每个单词是 $L \times 25$ 次查表,后者是 $O(n L)$ 次逐字符比对,当 n 远大于 $25L$ 时前者胜出。

边界上要考虑:起始词和目标词可能相同,此时答案就是只含一个词的序列;目标词可能根本不在字典里,直接无解;起始词自身不一定在字典里,不能想当然地把它当成字典元素处理;字典里也可能存在孤立的单词群,导致从起点根本走不到终点。

解法:BFS 最短路径

核心思路

最朴素的做法是从起始词开始做深度优先枚举:每一步尝试所有可达的字典单词,一路递归到目标词为止。这在稠密的字典上会退化成指数级的路径枚举——同一个中间单词可能被无数条前缀不同的路径重复访问,而它们后续的探索完全一样,做的是大量重复劳动。

瓶颈的本质是没有把「单词」当成状态去做去重。事实上,一旦确定当前手里是哪个单词,接下来能走到哪些地方就完全确定了,与「我是怎么走到这里的」无关。这正是图论模型成立的条件。

于是把每个单词看成图上的一个点,两个单词若只差一个字母就在它们之间连一条无权边。问题立刻变成「在这张图上求从起点到终点的一条路径」。由于边权全为 1,逐层向外扩展的搜索顺序天然保证:任何单词第一次被访问到时,走过的步数就是从起点到它的最短距离,这就是可以对每个单词只访问一次、直接打上已访问标记的理论依据。

算法维持的不变量有两条。其一,队列中的元素按到起点的距离非递减排列,同一轮循环处理的是同一层;其二,parent[w] 一旦被写入就不再修改,它记录的是首次到达 w 时的前驱,因而沿 parent 从终点一路回溯,得到的必然是一条合法且最短的路径。

邻居的产生方式选择「构造而非比较」:把当前单词拆成字符数组,对每一位依次替换成其余 25 个字母,拼出新串后到字典哈希集合里查一次。这样每个单词的扩展代价是 $O(L \times 26)$ 次哈希查询,与字典总规模无关,比遍历整本字典逐个比较要稳得多。

最后,因为题目只要求返回任意一条合法序列而非全部序列,一旦目标词入队就可以立刻停止扩展——继续搜下去也只会找到不更短的路径。

解题步骤

  • 先处理起点等于终点的退化情况,直接返回只含它自己的列表。这是因为后续的搜索逻辑建立在「至少变换一次」之上,起点从不进入邻居枚举的判定,不特判会走到「未找到」分支返回空列表。
  • 把字典装进哈希集合,并检查目标词是否在其中,不在则立刻返回空。装集合是为了把「某个拼出来的串是否合法」降到常数判定;提前检查目标词则能在无解时省掉整轮搜索,也避免了搜索成功却无法回溯的矛盾状态。
  • 初始化队列放入起点,同时把起点加入已访问集合。访问标记必须在入队时打,而不是出队时打,否则同一层里两个不同单词可能各自把同一个邻居塞进队列,造成重复扩展和 parent 被覆盖。
  • 按层循环:先记下当前队列长度 size,再恰好弹出 size 个元素。这样每一轮内部处理的都是距离相同的一批单词,层与层之间界限清晰;虽然本题只要一条路径、不严格依赖分层,但这个写法能让「首次到达即最短」的性质一目了然,也便于扩展成需要层数的变体。
  • 对每个出队单词,逐位尝试替换成 'a' 到 'z',跳过与原字符相同的情形。跳过是因为替换成自己等于没变,会生成当前单词本身,白白多一次查表。替换后拼出新串,若不在字典中或已访问过则跳过;否则打标记、写 parent、入队。
  • 每处理完一位后必须把该位复原。字符数组是复用的,不还原会导致后续位的枚举建立在已被污染的串上,生成大量本不存在的邻居。
  • 一旦生成的邻居就是目标词,置标志位并层层跳出所有循环。三层循环各有一次 break 判断,是为了在不使用异常或标签跳转的前提下尽快退出。
  • 从目标词沿 parent 反向回溯到起点,再整体反转。反转是必要的,因为回溯天然给出的是从终点到起点的逆序;起点的 parent 不存在(Java 中为 null、Go 中为空串),正好作为循环终止条件。

beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"] 走一遍。起点终点不同,字典集合建好,cog 在其中,继续。队列初始为 ["hit"],visited = {hit}。

第一层:弹出 hit。改第 0 位得到 ait…zit,其中只有…都不在字典;改第 1 位得到 hat、hbt…,其中 hot 在字典且未访问,于是 visited 加入 hot、parent["hot"] = "hit"、入队;改第 2 位得到 hia、hib… 均不在字典。本层结束,队列为 ["hot"]

第二层:弹出 hot。改第 0 位时试到 dot(在字典、未访问),记 parent["dot"] = "hot" 入队;继续试到 lot,记 parent["lot"] = "hot" 入队。改第 1 位得到 hat、hbt… 均不在字典(hit 虽在起点但不在字典里,即使拼出来也会被字典判定挡掉)。改第 2 位得到 hoa、hob… 也都不在字典。本层结束,队列为 ["dot", "lot"]

第三层:弹出 dot,改第 2 位试到 dog(在字典、未访问),记 parent["dog"] = "dot" 入队;dog 不等于 cog,继续。再弹出 lot,改第 2 位试到 log,记 parent["log"] = "lot" 入队。本层结束,队列为 ["dog", "log"]

第四层:弹出 dog,改第 0 位试到 cog,它在字典且未访问,记 parent["cog"] = "dog" 入队,同时发现它等于终点,found 置真,逐层 break 退出。

回溯阶段:从 cog 出发,path = [cog];parent["cog"] = "dog",path = [cog, dog];parent["dog"] = "dot",path = [cog, dog, dot];parent["dot"] = "hot",path = [cog, dog, dot, hot];parent["hot"] = "hit",path = [cog, dog, dot, hot, hit];hit 没有 parent,循环结束。反转得到 ["hit", "hot", "dot", "dog", "cog"],每相邻两词恰好差一个字母,且除首词外全部来自字典,答案合法。

代码实现

// 广度优先搜索 从 beginWord 出发,首次到达 endWord 即为最短路径。
class Solution {
    public List<String> findLadders(String beginWord, String endWord, List<String> wordList) {
        if (beginWord.equals(endWord)) {
            return List.of(beginWord);
        }

        Set<String> dict = new HashSet<>(wordList);
        if (!dict.contains(endWord)) {
            return new ArrayList<>();
        }

        Queue<String> queue = new ArrayDeque<>();
        Map<String, String> parent = new HashMap<>();
        Set<String> visited = new HashSet<>();

        queue.offer(beginWord);
        visited.add(beginWord);

        boolean found = false;
        while (!queue.isEmpty() && !found) {
            int size = queue.size();
            for (int i = 0; i < size; i++) {
                String cur = queue.poll();
                char[] arr = cur.toCharArray();

                for (int p = 0; p < arr.length; p++) {
                    char old = arr[p];
                    for (char c = 'a'; c <= 'z'; c++) {
                        if (c == old) {
                            continue;
                        }
                        arr[p] = c;
                        String next = new String(arr);
                        if (!dict.contains(next) || visited.contains(next)) {
                            continue;
                        }

                        visited.add(next);
                        parent.put(next, cur);
                        queue.offer(next);

                        if (next.equals(endWord)) {
                            found = true;
                            break;
                        }
                    }
                    arr[p] = old;
                    if (found) {
                        break;
                    }
                }
                if (found) {
                    break;
                }
            }
        }

        if (!found) {
            return new ArrayList<>();
        }

        List<String> path = new ArrayList<>();
        String cur = endWord;
        while (cur != null) {
            path.add(cur);
            cur = parent.get(cur);
        }

        Collections.reverse(path);
        return path;
    }
}
// 广度优先搜索 从 beginWord 出发,首次到达 endWord 即为最短路径。
func findLadders(beginWord string, endWord string, wordList []string) []string {
    if beginWord == endWord {
        return []string{beginWord}
    }

    dict := map[string]bool{}
    for _, w := range wordList {
        dict[w] = true
    }
    if !dict[endWord] {
        return []string{}
    }

    queue := []string{beginWord}
    visited := map[string]bool{beginWord: true}
    parent := map[string]string{}

    found := false
    for head := 0; head < len(queue) && !found; {
        size := len(queue) - head
        for i := 0; i < size; i++ {
            cur := queue[head]
            head++

            arr := []byte(cur)
            for p := 0; p < len(arr); p++ {
                old := arr[p]
                for c := byte('a'); c <= byte('z'); c++ {
                    if c == old {
                        continue
                    }
                    arr[p] = c
                    next := string(arr)
                    if !dict[next] || visited[next] {
                        continue
                    }

                    visited[next] = true
                    parent[next] = cur
                    queue = append(queue, next)

                    if next == endWord {
                        found = true
                        break
                    }
                }
                arr[p] = old
                if found {
                    break
                }
            }
            if found {
                break
            }
        }
    }

    if !found {
        return []string{}
    }

    path := make([]string, 0)
    cur := endWord
    for cur != "" {
        path = append(path, cur)
        cur = parent[cur]
    }

    for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
        path[i], path[j] = path[j], path[i]
    }

    return path
}

复杂度分析

  • 时间复杂度:$O(n \times L^2)$,其中 n 为字典单词数、L 为单词长度。凭什么?每个单词最多出队一次(已访问集合保证),出队后要枚举 L 个位置各 25 个替换字符,共约 $26L$ 次尝试;每次尝试都要用 new String(arr) 构造一个长度为 L 的新串并做哈希,构造与哈希各是 $O(L)$,于是单个单词的扩展代价是 $O(26 L^2)$,乘以 n 个单词、去掉常数即得。回溯路径最多经过 n 个单词,量级更低不影响总和。
  • 空间复杂度:$O(n \times L)$。凭什么?字典集合、已访问集合、parent 映射和队列各自最多保存 n 个单词,每个单词占 L 个字符;这四个结构都是线性于字典规模的,没有二维邻接表这类平方级开销。

关键点总结

  • 状态无后效性是图建模的前提:能否把问题抽象成图,判据是「当前状态是否唯一决定了后续所有可能」。本题中「手里是哪个单词」就是完整状态,历史路径不影响未来,因此可以对状态去重,把指数级枚举压成线性遍历。遇到「每步做一个小改动、问能否到达/最少几步」的题,先问自己这个问题。
  • 边权全为 1 时逐层扩展即最短路:这是不需要优先队列、不需要松弛操作的关键理由。一旦题目给某些变换加上不同代价,这个性质立刻失效,必须换成带优先队列的最短路算法——面试中被追问「如果每种字母替换代价不同呢」,标准答案就是这个。
  • 邻居的产生方式要按规模选择:「构造候选串再查字典」的代价是 $O(26L^2)$ 且与字典大小无关,「遍历字典逐个比较」的代价是 $O(nL)$。字典大而单词短时选前者,字典小而单词极长时选后者。能说清这个取舍,比背下模板更能体现工程判断。
  • 要路径就得记前驱,且前驱只写一次:距离和路径是两种不同的输出需求。只要答案是路径,就必须维护 parent 映射,并且严格保证「首次到达时写入、之后永不覆盖」——这条不变量正是回溯结果最短且无环的保证。
  • 访问标记的时机决定正确性与效率:入队即标记,可以避免同一层内多个单词把同一邻居重复入队;若改成出队才标记,队列规模会成倍膨胀,且 parent 可能被后写的路径覆盖。
  • 面试视角:这题是「单词接龙」的简化版,考察点集中在建模而非算法本身。答题时先明确说出「把单词当节点、差一个字母连边、边权为 1」,再讲搜索顺序和 parent 回溯,最后主动提一句「如果要返回所有最短路径,就要把 parent 换成前驱列表并在同层允许多次记录」,能显著拉开与只会套模板的候选人的差距。

易错点总结

  • 错误写法:忘记特判起点等于终点。用例 beginWord = "a", endWord = "a", wordList = ["a","b"] → 搜索时枚举邻居会跳过与原字符相同的替换,永远生成不出 a 自己,found 保持假,返回空列表;正确答案是 ["a"]
  • 错误写法:不检查终点是否在字典中。用例 beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog"] → 搜索会把整个连通块跑完才发现无解,虽然结果仍是空列表,但在字典达到万级时白白多跑一整轮;更糟的是若把「拼出的串等于终点」作为命中条件而绕过字典判定,会返回一条包含非法单词的路径。
  • 错误写法:出队时才把单词加入已访问集合。用例 beginWord = "hit", endWord = "cog", wordList = ["hot","hog","cog","dot","dog"] → 同一层的 dothot 都能生成 dog,两次都入队;第二次入队时 parent["dog"] 被覆盖成后来者,回溯出的前驱与实际最短路不符,且队列中出现重复元素导致后续层被重复扩展。
  • 错误写法:替换字符后忘记把该位复原。用例 beginWord = "hit", wordList = ["hot","hop"] → 处理完第 1 位得到 hot 后,若不把 arr[1] 还原成 i,枚举第 2 位时基串已变成 ho?,会拼出 hoahoz 这批本不应从 hit 一步到达的串,一旦其中有字典单词(如 hop)就会连出一条实际需要两步的假边,返回的序列相邻两词差了两个字母。
  • 错误写法:枚举替换字符时不跳过原字符。用例 beginWord = "hot", wordList = ["hot","dot"] → 会生成 hot 自己,若它恰好在字典中且未被标记(例如起点未加入 visited),就会自己指向自己入队,parent 形成自环,回溯阶段陷入死循环直到栈溢出或超时。
  • 错误写法:回溯路径后忘记反转。用例 beginWord = "hit", endWord = "cog" → 返回 ["cog","dog","dot","hot","hit"],方向完全相反,判题按序列首元素必须等于 beginWord 校验,直接判错。
  • 错误写法:找到终点后不跳出,继续把整层扩展完。用例字典规模上万且终点在第一层就被找到 → 仍要枚举完当前层所有单词的全部邻居,多做数万次哈希查询;虽然结果正确,但在卡常数的数据下会超时。三层循环都要有跳出判断。
  • 错误写法:Go 里用 for cur != "" 回溯却让某个单词本身是空串。用例 wordList 含空字符串(越界数据)→ 循环提前终止,路径被截断;更常见的是把 parent 声明为 map[string]string 后误以为访问缺失键会 panic 而加了 ok 判断却写反条件,导致起点被漏进路径。稳妥做法是显式判断 cur == beginWord 后再终止。
  • 错误写法:Java 里用 String 拼接生成邻居,例如 cur.substring(0,p) + c + cur.substring(p+1)。用例字典一万词、单词长度 10 → 每次拼接产生三个临时对象,总共数百万次对象分配,虽然复杂度同阶但常数极大,在时限紧的判题环境下容易 TLE;复用 char[] 数组是标准做法。
  • 错误写法:把 beginWord 也当作字典成员加入 visited 之外的结构,或反过来假设它一定在字典里。用例 beginWord = "hit"wordList 不含 hit → 若代码在初始化时执行 dict.remove(beginWord) 之类的操作没问题,但若在扩展时要求「当前单词必须在字典中」才继续,起点会被直接排除,第一层就无法扩展,返回空列表。

相似题目

题目 难度 考察点
127. 单词接龙 困难 只要最短长度不要路径,可省去 parent 映射并用双向扩展把搜索规模开方
126. 单词接龙 II 困难 要求列出全部最短路径,前驱必须存成列表且同层允许重复记录,去重时机完全不同
433. 最小基因变化 中等 字符集只有 ACGT 四种、串长固定为 8,可以用位压缩把状态编码成整数
752. 打开转盘锁 中等 邻居由数字环上下拨动生成而非查字典,还多了一组必须绕开的死亡状态
815. 公交路线 困难 节点应建在路线而非站点上,考察如何选择合适的状态定义来压缩边数
1091. 二进制矩阵中的最短路径 中等 图结构由网格八连通直接给出,无需构造邻居,重点在越界与障碍判定