在计算机科学的算法海洋中,图论问题总是既迷人又充满挑战。今天,我想和你深入探讨一个经典的进阶问题:单词接龙 II。如果你已经熟悉了基础的广度优先搜索(BFS),那么这个问题将是你技能树的绝佳试金石。与只需要找到“一条”最短路径的基础版不同,这次我们的任务更加艰巨:我们需要找到从起始词到目标词的所有最短转换序列。
这听起来似乎只是“量”的变化,但实际上,它对算法的设计提出了完全不同的要求。在这篇文章中,我将带你一步步拆解这个问题,展示为什么单纯的暴力搜索会失效,以及我们如何通过巧妙结合BFS构建图和DFS回溯路径来构建一个优雅且高效的解决方案。准备好迎接挑战了吗?让我们开始吧。
01. 问题重述:我们到底要解决什么?
在深入代码之前,让我们先统一对问题的理解。想象一下,我们有一本字典,一个起始词,一个目标词。我们的目标是像接龙一样,把起始词变成目标词。但这其中有几条铁律:
- 一步一变:每次只能改变单词中的一个字母。
- 字典为准:每次变换后得到的中间词,必须存在于给定的字典中。
- 始作俑者:起始词可以不在字典中,但目标词必须在,否则无解。
- 全都要:我们需要列出长度最短的那一条(或几条)路径。如果有多个长度相同的最短路径,全部都要。
举个例子:
假设起始词是 INLINECODE88d23c67,目标词是 INLINECODE668f7c8a,字典是 [{"hot", "dot", "dog", "lot", "log", "cog"}]。
我们不仅要知道长度是 5,还要列出具体的路线:
hit -> hot -> dot -> dog -> cog
hit -> hot -> lot -> log -> cog
02. 为什么不能只用简单的 BFS?
提到最短路径,你的第一反应可能和以前的我一样:广度优先搜索(BFS)。没错,BFS 是寻找无权图最短路径的王者。
陷阱在这里:
如果我们只是在 BFS 的遍历过程中,把每一步生成的路径都存储在队列中,会发生什么?
在一个密集的图中(例如很多单词只有一两个字母不同),从同一个父节点可能会发散出大量的子节点,而这些子节点又会发散出更多的路径。这会导致路径的数量呈指数级爆炸,直接撑爆内存。
解决方案的核心思想:
不要在 BFS 阶段存储具体的路径。BFS 只负责一件事:构建最短路径的“地图”。 我们只记录“谁是谁的父节点”。既然 BFS 保证了一层层向外扩散,那么第一次访问某个节点时的路径长度一定是最短的。如果我们记录下所有的这些“最短父节点”,我们就得到了一个有向无环图(DAG)。
有了这张地图,我们再用 DFS(深度优先搜索) 去走迷宫,就能轻松地找出所有路径。这就是“BFS 构建图 + DFS 寻找路径”的双重策略。
03. 算法演示:构建父节点映射
让我们通过刚才的例子来看看算法是如何工作的。为了清晰,我们用一个 Map 来存储父节点关系 parents = {子节点: [父节点1, 父节点2...]}。
初始状态:
- 队列:
[hit] - 父节点映射:
{hit: []}
第 1 层:
- 出队 INLINECODE8d4df843。变换得到 INLINECODE4319cb68(以及其他无效词)。
hot未被访问。 - 队列:
[hot] - 父节点映射更新:
{hot: [hit]}
第 2 层:
- 出队 INLINECODE4c8dc860。变换得到 INLINECODE83e677f0,
lot。都未被访问。 - 队列:
[dot, lot] - 父节点映射更新:
{dot: [hot], lot: [hot]}
第 3 层:
- 出队 INLINECODE690c8694。变换得到 INLINECODE91967b70。未被访问。
* 映射:{dog: [dot]}
- 出队 INLINECODEe3cfbab3。变换得到 INLINECODE910f0af8。未被访问。
* 映射:{log: [lot]}
- 出队 INLINECODE92b1b2b3。变换得到 INLINECODEb1a6f77f。未被访问。
* 映射:{cog: [dog]} —— 注意:找到目标了!但别急!
- 关键点: 虽然 INLINECODE61bc25b1 已经找到了,但在第 3 层出队的还有 INLINECODEe0c782b8,INLINECODE53ca9d53 也能变到 INLINECODE1c8be644。如果现在停止 BFS,我们就会漏掉
log -> cog这条路。所以,我们必须处理完当前这一层的所有节点才能停止。 - 出队 INLINECODE4964cc40。变换得到 INLINECODEd993c45a。INLINECODEaf14b450 已经被访问过了,而且在当前层(第 4 层)。这说明 INLINECODE1d109643 有多个父节点。
* 映射更新:{cog: [dog, log]}
至此,BFS 结束。我们手里有了一张完美的父节点地图。
04. 实战代码:构建基础框架
让我们把这个逻辑转化为 Python 代码。为了保证高效,我们会使用 INLINECODE30cd7568 来存储字典,利用 INLINECODEe8c2bf4e 来简化父节点的管理。
from collections import deque, defaultdict
class Solution:
def findSequences(self, beginWord: str, endWord: str, wordList: list[str]) -> list[list[str]]:
# 1. 数据预处理
# 将字典转换为集合,实现 O(1) 的查找速度
word_set = set(wordList)
# 如果目标词根本不在字典里,直接返回空,避免无谓计算
if endWord not in word_set:
return []
# 2. 初始化 BFS 结构
queue = deque([beginWord])
# visited 结构不仅记录是否访问过,还记录它是在哪一层被访问的
# 这里的 value 表示距离 beginWord 的步数
visited = {beginWord: 0}
# 核心结构:父节点映射表
# 结构:{子单词: [父单词1, 父单词2, ...]}
parents = defaultdict(list)
found = False # 标记是否在本层找到了目标词
level = 0
# 3. 开始 BFS 逐层遍历
while queue and not found:
level_size = len(queue)
# 必须遍历完当前层的所有节点,以保证找到该层所有的父节点
for _ in range(level_size):
current_word = queue.popleft()
# 尝试改变 current_word 的每一个字符
for i in range(len(current_word)):
# 遍历 a-z
for c in ‘abcdefghijklmnopqrstuvwxyz‘:
if c == current_word[i]:
continue
# 构建邻居单词
next_word = current_word[:i] + c + current_word[i+1:]
# 只有当邻居存在于字典中时才处理
if next_word in word_set:
# 情况 A:这个单词是第一次被访问
if next_word not in visited:
visited[next_word] = level + 1
parents[next_word].append(current_word)
queue.append(next_word)
# 如果找到了目标,标记 found,但不要 break!
# 必须继续处理完当前层的其他节点
if next_word == endWord:
found = True
# 情况 B:这个单词已经访问过了
# 关键判断:必须是在当前层(level + 1)被访问的
# 这样才能保证是最短路径的另一个父节点
elif visited[next_word] == level + 1:
parents[next_word].append(current_word)
level += 1
# 4. 如果 BFS 结束后没找到目标,返回空
if not found:
return []
# 5. DFS 回溯生成所有路径
results = []
def dfs_backtrack(node, path):
# 回溯的终点:回到起始词
if node == beginWord:
# 因为是从终点往回找,所以要将路径反转后再存入结果
results.append(path[::-1])
return
# 遍历当前节点的所有父节点
for parent in parents[node]:
path.append(parent)
dfs_backtrack(parent, path)
path.pop() # 回溯步骤,撤销选择
# 从终点开始逆向寻找
dfs_backtrack(endWord, [endWord])
return results
05. 深入剖析:你需要注意的细节
代码写出来了,但这只是第一步。作为一名追求卓越的工程师,我们需要深入理解其中的几个关键细节,这往往是面试中拉开分数的地方。
#### 1. 层级遍历的重要性
我们使用 INLINECODEe951e497 这种写法,是为了强制进行层级遍历。这是解决 Word Ladder II 的核心。如果我们只是简单地 INLINECODE748ec33d,当我们在某一层遇到 INLINECODEee576a61 时,可能队列里还有上一层的节点没处理完(虽然在标准 BFS 中不太可能,但在双向 BFS 或并行处理时容易混淆)。更重要的是,正如之前演示的,必须允许同一层的多个节点共同作为 INLINECODE7b7a2ca6 的父节点。
#### 2. 访问数组 visited 的判断逻辑
代码中的这一行至关重要:
elif visited[next_word] == level + 1:
如果不加 == level + 1 的判断,我们可能会把更长路径的父节点也记录进去,甚至产生环。只有层级刚好比当前大 1,说明它是我们在 BFS 树上的直接子节点关系,必须记录。
06. 进阶优化:双向 BFS 与预处理
上面的解法在大多数情况下已经足够优秀。但如果字典非常大(例如有 10,000 个单词),上面的 BFS 依然可能较慢。我们可以引入两个高级技巧来进一步优化。
#### 技巧 A:预构建通用状态图
我们现在的做法是:取出单词 -> 变换 26 个字母 -> 查找字典。这意味着每个单词都要做 M * 26 次哈希查找(M 是单词长度)。
更高效的做法是逆向思维:
我们可以把所有单词预处理成“通用状态”。例如 INLINECODE82dbbafa 可以变成 INLINECODEd00b41c2, INLINECODEa2402a6c, INLINECODE66208455。
我们构建一个映射:{*it: [hit, hot], h*t: [hit, hat]...}。
这样,在 BFS 时,对于 INLINECODEfb6aab42,我们只需生成 3 个通配符 key(INLINECODE2bd195e5, INLINECODE78d99e37, INLINECODE4454beb7),然后直接查表获取所有邻居。这大大减少了无效的 26 次循环尝试,尤其是当单词很长时效果显著。
# 优化思路:通用状态映射示例
from collections import defaultdict
# 假设 wordList 很大
pre_map = defaultdict(list)
for word in wordList:
for i in range(len(word)):
# 将 dog 变为 *og
pattern = word[:i] + ‘*‘ + word[i+1:]
pre_map[pattern].append(word)
# 在 BFS 中查找邻居时:
# for i in range(len(current_word)):
# pattern = current_word[:i] + ‘*‘ + current_word[i+1:]
# for neighbor in pre_map[pattern]:
# # 处理 neighbor
#### 技巧 B:双向 BFS
既然我们要从 Start 找 End,为什么不从两头同时找呢?
双向 BFS 的核心思路是:每次选择当前队列较少的那一端向外扩散一层。当两端的搜索在某一点相遇时,我们就找到了最短路径。这通常能将搜索空间从 $O(N^2)$ 降低到 $O(N)$。
实现难点:
在 Word Ladder II 中使用双向 BFS 会比较复杂,因为我们需要合并来自两个方向的路径。通常的做法是让 BFS 的一方作为父节点(例如从 Start 出发的),当 End 方向的搜索碰撞到 Start 方向已访问的节点时,开始构建路径。虽然实现繁琐,但它是面对海量数据时的终极武器。
07. 复杂度分析:心里要有数
最后,让我们从数学角度看看这个算法到底消耗多少资源。
- 时间复杂度:$O(N^2 \times M)$ (通用状态优化下为 $O(N \times M)$)
* $N$ 是字典中单词的数量,$M$ 是单词的长度。
* 在最坏情况下,我们可能需要处理所有的单词。
* DFS 生成路径的部分虽然看起来复杂,但由于受到父节点映射(DAG)的限制,其路径数量是有限的,且远低于暴力搜索的指数级。
- 空间复杂度:$O(N \times M)$
* 我们需要存储 INLINECODE0fe4552d 字典和 INLINECODEa53fa5b1 映射表。如果使用通用状态优化,还需要额外的空间存储 pre_map。空间换取时间是算法优化中常见的 trade-off。
08. 总结与展望
通过这篇文章,我们从简单的 BFS 出发,一步步克服了内存爆炸的陷阱,最终构建出了结合 BFS 与 DFS 的 Word Ladder II 解决方案。我们不仅学会了如何写代码,更重要的是学会了如何拆解问题:将“找路径”拆分为“建图”和“走图”两个子问题。
在实际的工程应用中,这种图搜索技巧不仅用于单词游戏,还广泛应用于社交网络的关系分析(如 LinkedIn 的几度人脉)、网络路由的最短路径计算以及知识图谱的构建。
你的下一步行动:
我建议你亲手实现一下上面提到的通用状态优化版本。如果觉得轻松,不妨尝试挑战一下双向 BFS 的实现。只有将这些逻辑烂熟于心,你才能在面试或实际开发中游刃有余。
希望这篇深入的技术文章对你有所启发。算法的学习之路虽然充满挑战,但每解开一道难题,你的编程内力就会精进一分。让我们在代码的世界里继续探索,下篇文章见!