尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

LeetCode 127单词接龙:从BFS到双向BFS与A*的图论算法精解

LeetCode 127单词接龙:从BFS到双向BFS与A*的图论算法精解 1. 项目概述从“单词接龙”到图论建模的思维跃迁看到“单词接龙”这个题目很多人的第一反应可能是小时候玩过的文字游戏。但在LeetCode 127题中它被包装成了一个经典的图论搜索问题。这道题的核心远不止是找到一条变换路径那么简单它是一次绝佳的思维训练如何将一个看似是字符串处理的问题抽象成一个标准的图论模型并运用合适的算法高效求解。题目要求很简单给定一个起始单词、一个结束单词和一个单词列表每次只能改变一个字母找出从起始词到结束词的最短转换序列的长度。如果转换不了就返回0。这听起来就像是在一个由单词构成的迷宫里找最短路径。我最初做这道题时觉得用BFS暴力搜索邻居就行但随着深入我发现这里面门道很深从最基础的单源无权最短路BFS到优化的双向BFS再到引入启发式搜索的A*算法每一步优化都对应着对问题本质更深刻的理解和对算法效率的极致追求。今天我们就来彻底拆解这道题不仅告诉你怎么写代码更要讲清楚为什么要这么做以及在实际面试或竞赛中如何根据数据规模选择最合适的策略。2. 核心思路拆解将文字游戏抽象为图搜索2.1 问题本质与数学建模为什么说这道题是图论问题我们先把题目里的元素映射到图论的术语中顶点Vertex每一个单词就是一个顶点。边Edge如果两个单词之间可以通过改变一个字母相互转换那么它们之间就存在一条无向边。例如“hit”和“hot”之间有一条边因为只改变了一个字母‘i’-‘o’。图Graph由所有单词包括起始词、结束词和列表中的词以及它们之间的边构成的网络就是一个无向图。最短路径Shortest Path题目要求的最短转换序列就是在这个无向图中从起始顶点到结束顶点的最短路径。由于每次变换的代价都是1改变一个字母所以这是一个边权为1的无权图单源最短路径问题。这个建模过程是关键的第一步。很多同学卡在如何高效寻找“只差一个字母”的邻居上如果直接两两比较所有单词复杂度是O(N^2 * L)其中N是单词数L是单词长度在N很大时不可接受。更聪明的做法是使用“虚拟节点”或“通用状态”法。实操心得邻居发现的高效策略对于单词word长度为 L我们有两种高效找邻居的思路两重循环法遍历单词的每个位置i从0到L-1对于每个位置将原单词的第i个字符依次替换为‘a’到‘z’排除自身生成一个新字符串去检查它是否在合法的单词集合中。复杂度为 O(26 * L)。通配符哈希法为每个单词创建 L 个“通用状态”。例如单词 “hit”可以生成 “it”, “ht”, “hi*” 这三个模式。所有能映射到同一个通用模式的单词彼此之间都只差一个字母是邻居。我们可以用哈希表预先建立模式 - [单词列表]的映射。查找一个单词的所有邻居时只需生成它的L个模式然后从哈希表中取出对应的单词列表即可。虽然预处理需要 O(N * L) 的时间和空间但后续每个单词找邻居是 O(L)在需要多次查询邻居时如BFS遍历更高效。在LeetCode 127的场景下两种方法均可但通配符法在概念上更贴近图论的“虚拟节点”思想值得掌握。2.2 算法选型逻辑从BFS到A*的演进明确了是单源无权最短路我们自然想到广度优先搜索BFS。因为BFS的特性就是按“层”扩散第一次遍历到目标节点时经过的层数就是最短距离。这是本题最基础、最必须掌握的解法。但是当单词列表很大比如上千甚至上万个而单词长度也不短时朴素的BFS可能会探索非常大的搜索空间导致超时。这时就需要优化。优化的核心思路是减少搜索的盲目性或者从两端同时向中间搜索以降低搜索的广度。双向BFS这是对朴素BFS最直接有效的优化。同时从起点和终点开始进行BFS。每一轮我们选择当前待扩展节点数较少的一端进行扩展。当两端搜索相遇时即某个单词同时被起点和终点访问到路径就找到了。为什么有效假设搜索空间是一棵二叉树从根到叶子的节点总数是2^d-1。单向BFS需要遍历大约一半的节点才能到达底层。而双向BFS从根和叶子同时开始理想情况下在中间相遇遍历的节点数大约是2 * 2^(d/2)这比2^d指数级地减少了。在本题中这能显著降低时间和空间开销。A-Star (A) 搜索*这是一种启发式搜索适用于知道终点位置的情况。它为每个待探索的节点计算一个代价估计f(n) g(n) h(n)。其中g(n)是从起点到节点n的实际代价h(n)是从节点n到终点的估计代价启发函数。A* 总是优先扩展f(n)最小的节点。对于本题一个常用的启发函数h(n)是当前单词与目标单词不同字符的个数汉明距离。这个函数是“可采纳的”admissible即永远不会高估实际代价因此A* 能保证找到最短路径。在启发函数有效的情况下A* 可以比BFS更早地“瞄准”终点方向跳过一些明显绕远的路径从而减少探索的节点数。注意在无权图中Dijkstra算法会退化成BFS。因为所有边权为1优先队列的行为和队列是一样的。所以不需要使用Dijkstra。3. 核心实现与代码解析我们将按照算法进阶的顺序给出Python实现并详细讲解关键细节。3.1 基础解法朴素BFS实现这是必须掌握的“保底”解法。思路清晰使用队列进行层次遍历用集合记录已访问单词避免重复和死循环。from collections import deque class Solution: def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) - int: # 将单词列表转为集合实现O(1)的查找 word_set set(wordList) if endWord not in word_set: return 0 # BFS队列存储(当前单词, 当前路径长度) queue deque([(beginWord, 1)]) # 记录已访问单词避免走回头路 visited set([beginWord]) while queue: current_word, level queue.popleft() # 遍历当前单词的每一个字符位置 for i in range(len(current_word)): # 将单词转为字符列表便于修改 word_chars list(current_word) original_char word_chars[i] # 尝试将当前位置字符替换为a到z for c in abcdefghijklmnopqrstuvwxyz: if c original_char: continue # 跳过自身 word_chars[i] c next_word .join(word_chars) # 如果找到终点直接返回结果注意层数要1 if next_word endWord: return level 1 # 如果新单词在字典中且未被访问过加入队列 if next_word in word_set and next_word not in visited: visited.add(next_word) queue.append((next_word, level 1)) # 恢复原字符准备尝试下一个位置 word_chars[i] original_char # BFS结束仍未找到终点返回0 return 0关键点解析使用集合wordList转set是常规操作将查找复杂度从O(N)降至O(1)。层级记录在将节点入队时同步记录当前路径长度level。当从队列中取出时该level就是从起点到这个节点的最短距离。访问标记visited集合至关重要。图可能成环没有它BFS会陷入无限循环。必须在将邻居节点入队时就标记为已访问而不是出队时否则同一层的其他节点可能会重复发现该邻居导致重复入队。提前终止在生成新单词后立即判断是否为endWord可以提前返回这是一个有效的剪枝。3.2 优化实现双向BFS详解双向BFS的实现比朴素BFS稍复杂核心是维护两个队列和两个已访问集合并交替或选择较小的一端进行扩展。from collections import deque class Solution: def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) - int: word_set set(wordList) if endWord not in word_set: return 0 # 初始化双向队列和已访问集合 queue_begin deque([beginWord]) # 从前向后搜索的队列 queue_end deque([endWord]) # 从后向前搜索的队列 visited_begin {beginWord: 1} # 记录从起点出发访问到的单词及其对应层级 visited_end {endWord: 1} # 记录从终点出发访问到的单词及其对应层级 while queue_begin and queue_end: # 选择当前待扩展节点数较少的一端进行扩展优化搜索效率 # 总是扩展较小的一端能更快相遇 if len(queue_begin) len(queue_end): result self._visit_node(queue_begin, visited_begin, visited_end, word_set) direction begin else: result self._visit_node(queue_end, visited_end, visited_begin, word_set) direction end if result ! -1: return result return 0 def _visit_node(self, queue, visited_from, visited_to, word_set): 扩展当前队列的一层节点。 :param queue: 当前要扩展的队列 :param visited_from: 当前方向的已访问字典 {word: level} :param visited_to: 另一个方向的已访问字典 :param word_set: 单词集合 :return: 如果相遇则返回总路径长度否则返回-1 current_word queue.popleft() current_level visited_from[current_word] for i in range(len(current_word)): word_chars list(current_word) original_char word_chars[i] for c in abcdefghijklmnopqrstuvwxyz: if c original_char: continue word_chars[i] c next_word .join(word_chars) # 核心相遇判断如果next_word在另一个方向的已访问集合中 if next_word in visited_to: # 总路径长度 从起点到当前词的长度 从终点到相遇词的长度 return current_level visited_to[next_word] # 如果新单词有效且未被当前方向访问过 if next_word in word_set and next_word not in visited_from: visited_from[next_word] current_level 1 queue.append(next_word) word_chars[i] original_char return -1 # 本次扩展未相遇双向BFS的要点与避坑指南交替还是选择扩展代码中采用了选择当前待扩展节点数较少的一端进行扩展的策略。这比简单交替更优因为它总是先扩展规模较小的那一侧能更快地让两端的搜索“体积”趋于平衡从而加速相遇。这是双向BFS的一个经典优化。如何判断相遇在扩展一端的一个节点时如果发现其某个邻居节点已经存在于另一端的已访问集合中则说明两端搜索连通了。总路径长度是两端层数之和。注意起点和终点本身的层数都算作1所以当起点“hit”和终点“cog”直接相连时总长度是11-11不对实际是2。因为我们的visited字典记录的是从该端起点到该单词的转换次数包含起点。例如从起点访问到Avisited_begin[A]2起点-A变换1次。当从终点访问到同一个A时visited_end[A]3终点-...-A。两者相加时A被计算了两次而路径是起点-...-A-...-终点A是中间点只应算一次。因此总路径长度应为visited_begin[current_word] visited_to[next_word] - 1等等仔细看我们的代码current_level是visited_from[current_word]当发现next_word在visited_to中时路径是起点 - ... - current_word - next_word - ... - 终点。current_word到next_word是一条边。所以从起点到next_word的距离是current_level 1。而从终点到next_word的距离是visited_to[next_word]。因此总长度是(current_level 1) visited_to[next_word] - 1这里容易混乱。更清晰的逻辑是我们在扩展current_word时发现它的邻居next_word已经被另一端访问过了。那么路径就是从起点到current_word距离current_level再走一步到next_word1然后从next_word到终点距离visited_to[next_word]。所以总长度是current_level 1 visited_to[next_word]。但注意visited_to[next_word]包含了从终点到next_word的步数而next_word本身在两条路径里被算了两次不对current_level是起点到current_word的步数current_word到next_word是第current_level1步。visited_to[next_word]是终点到next_word的步数。所以总步数是两者相加。例如最简单情况起点和终点直接相连。起点扩展时发现邻居endWord在visited_end中此时current_level1起点自身visited_end[endWord]1。总长度112。正确。所以代码中直接返回current_level visited_to[next_word]是正确的。已访问集合的数据结构这里使用了字典而不是集合因为我们需要记录到达每个单词时的层级路径长度以便在相遇时计算总长度。复杂度分析最坏情况时间复杂度仍是O(N * 26 * L)但实际搜索的节点数通常是朴素BFS的平方根级别显著提升性能。3.3 进阶探索A*搜索算法实现A*算法需要用到优先队列通常用堆实现并定义一个启发函数。对于本题我们使用当前单词与目标单词不同字符的个数作为启发函数h(n)。import heapq class Solution: def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) - int: word_set set(wordList) if endWord not in word_set: return 0 # 启发函数汉明距离不同字符的个数 def heuristic(word, target): # 如果单词长度一致直接计算不同字符数 # 本题中所有单词长度相同所以可以这样用 return sum(1 for a, b in zip(word, target) if a ! b) # 优先队列元素为 (f_score, g_score, word) # f_score g_score h(word) # g_score 是从起点到当前word的实际代价 open_set [] heapq.heappush(open_set, (heuristic(beginWord, endWord), 0, beginWord)) # 记录到达每个单词的最佳g_score实际代价 g_score {beginWord: 0} # 记录路径本题不需要重建路径但有时需要 came_from {} while open_set: current_f, current_g, current_word heapq.heappop(open_set) # A*优化如果当前节点的f_score不是最优因为同一个单词可能以不同的g_score被多次加入跳过 if current_g g_score.get(current_word, float(inf)): continue # 找到目标返回路径长度转换序列包含的单词数所以是 g_score 1 if current_word endWord: return current_g 1 # 扩展当前节点的邻居 for i in range(len(current_word)): word_chars list(current_word) original_char word_chars[i] for c in abcdefghijklmnopqrstuvwxyz: if c original_char: continue word_chars[i] c neighbor .join(word_chars) if neighbor not in word_set: continue # 计算从起点到neighbor的临时g_score tentative_g current_g 1 # 边权为1 # 如果找到一条到neighbor的更短路径 if tentative_g g_score.get(neighbor, float(inf)): # 更新路径记录 came_from[neighbor] current_word g_score[neighbor] tentative_g # 计算f_score并入队 f_score tentative_g heuristic(neighbor, endWord) heapq.heappush(open_set, (f_score, tentative_g, neighbor)) word_chars[i] original_char return 0A*算法的关键细节启发函数的选择汉明距离对于本题是一个很好的可采纳启发函数。它计算简单且永远不会高估从当前单词到目标单词所需的最少变换次数因为每次只能改一个字母实际最少变换次数至少是不同字母的个数。这保证了A*能找到最优解。优先队列与重复节点同一个单词可能会通过不同的路径被多次加入到优先队列中open_set但每次的g_score实际代价可能不同。我们使用g_score字典来记录到达每个单词的已知最佳代价。当我们从堆中弹出一个节点时需要检查其g_score是否与字典中记录的最佳值一致。如果不一致说明这个节点状态已经过时有一条更优的路径已经更新了它的g_score可以直接跳过。这个检查对于A*的正确性和效率非常重要。路径重建came_from字典记录了每个节点的前驱节点用于在找到终点后回溯出完整路径。本题只要求长度所以可以省略但保留它以展示完整流程。性能表现A的性能高度依赖于启发函数的质量。在本题的单词接龙场景中汉明距离提供的“指引”效果有时很好能快速导向终点但在某些情况下比如单词间差异很大其效果可能和BFS相近。理论上A在最坏情况下的时间复杂度与BFS相同但在平均情况下好的启发函数能显著减少探索的节点数。4. 算法对比与场景选择为了更直观地理解三种算法的差异我们可以从几个维度进行对比特性维度朴素BFS双向BFSA*搜索核心思想盲目、逐层扩散从起点和终点同时扩散减少搜索半径利用启发函数评估优先搜索最有希望的节点数据结构队列 (FIFO)两个队列、两个已访问字典优先队列最小堆、g_score字典空间复杂度O(N)O(N)O(N)时间复杂度(最坏)O(N * 26 * L)O(N * 26 * L)O(N * 26 * L)实际搜索节点数多较少约BFS的平方根依赖于启发函数可能很少也可能接近BFS实现难度简单中等中等偏上适用场景数据规模小作为基础解法和理解模板最常用、最推荐的优化方案在数据规模较大时表现稳定优秀当有一个良好的、可采纳的启发函数时可能获得极佳性能个人经验与选择建议面试场景务必掌握朴素BFS这是基础。如果时间允许可以阐述双向BFS的思路这能体现你的优化意识。实现双向BFS是加分项。竞赛或高频题练习双向BFS是解决此类问题的“标配”它的优化效果非常显著且稳定代码复杂度可控应作为首选优化实现。探索与学习实现A* 有助于深入理解启发式搜索和图算法是很好的练习。但在本题中由于单词变换的启发函数汉明距离提供的引导性有限其性能提升不一定比双向BFS明显且实现更复杂。一个常见的误解有人认为A一定比BFS快。在不佳的启发函数或特定图结构下A可能退化成类似Dijkstra的搜索甚至因为优先队列的操作开销而比BFS更慢。不要盲目追求“高级”算法理解其适用条件更重要。5. 常见问题与调试技巧在实际编写和调试过程中你可能会遇到以下问题Q1: 为什么我的BFS超时了A1: 首先检查是否使用了visited集合并在入队时标记。这是最常见的错误遗漏会导致指数级重复访问。其次检查找邻居的方法。如果是两两比较单词列表复杂度是O(N^2)必须改用“每个位置替换26个字母”或“通配符哈希”的方法。最后如果单词列表很大5000即使算法正确也可能在边界情况下超时此时应考虑使用双向BFS。Q2: 双向BFS中为什么相遇时路径长度是两端层数相加起点和终点层数都是1相加是2但直接相连的路径长度应该是2这不对吗A2: 你的理解是对的直接相连时路径长度是2hit - cog。在双向BFS中我们从起点和终点同时开始。假设起点为第1层。从起点扩展找到邻居cog此时起点端记录visited_begin[cog] 2。同时终点端记录visited_end[cog] 1终点自己。当从起点端扩展出cog时发现cog已经在visited_end中此时计算总长度visited_begin[current_word] visited_to[next_word]。这里current_word是hit层数1next_word是cog在终点端层数1。但我们的代码逻辑是扩展current_word(hit)时发现它的一个邻居next_word(cog)在另一端的集合里。所以路径是起点-hit (1步) - cog (再1步)。从cog到终点是0步因为cog就是终点。所以总步数应该是2。而current_level(即visited_begin[hit]) 是1visited_end[cog]是1相加等于2。正确。所以公式current_level visited_to[next_word]是成立的。关键在于current_level是当前扩展节点的层级next_word是它的邻居。路径是起点...-current_word-next_word-...终点。总长度 (起点到current_word) 1 (终点到next_word) - 1不对再仔细推演设起点到current_word距离为d1终点到next_word距离为d2。那么 current_word 到 next_word 有一条边。所以起点到next_word距离为d11。而终点到next_word距离为d2。所以从起点到终点经过next_word的路径总长度是 (d11) d2。而我们的current_level就是d1visited_to[next_word]就是d2。所以总长度 current_level 1 visited_to[next_word]。等等这和代码不一样这是一个极易混淆的点。让我们回到代码中的相遇判断逻辑if next_word in visited_to:。此时next_word已经被另一端访问过了。current_word是当前正在扩展的节点next_word是它的邻居。路径应该是起点 - ... - current_word - next_word - ... - 终点。注意next_word是这条路径上的一个点。从起点到current_word的距离是current_level。从current_word到next_word距离是1。从next_word到终点的距离是visited_to[next_word]。所以总距离 current_level 1 visited_to[next_word]。但我们的代码返回的是current_level visited_to[next_word]。这里代码有误吗不代码是正确的。因为我们的visited字典记录的是从该端起点到该单词的转换次数。注意visited_begin[beginWord] 1表示起点本身算1次转换序列长度为1。当我们说“距离”时通常指边数。在本题中ladderLength返回的是序列中的单词数也就是节点数。从hit到cog节点序列是 [hit, cog]长度是2。visited_begin[hit] 1表示从起点到hit序列有1个单词不对起点就是hit所以visited_begin[hit]应该是1表示序列包含hit自己。visited_end[cog] 1序列包含cog自己。当在hit处发现邻居cog已被终点端访问时总序列是 hit - cog。hit是当前节点cog是邻居。从起点到hit的序列长度是visited_begin[hit] 1只有hit。从cog到终点的序列长度是visited_end[cog] 1只有cog。合并后序列为 [hit, cog]长度为2。而visited_begin[hit] visited_end[cog] 2。正确所以代码中的current_level就是visited_from[current_word]它表示从该端起点到current_word的序列单词数。当我们发现邻居next_word在另一端时总序列单词数就是两端之和。因为current_word和next_word是相邻的它们属于同一条路径上的连续节点不会重复计算。所以公式current_level visited_to[next_word]正确。之前的混淆在于把“距离”理解为边数还是节点数。本题要求返回的是节点数。Q3: 如何测试算法正确性A3: 准备几个有代表性的测试用例简单案例beginWord hit, endWord cog, wordList [hot,dot,dog,lot,log,cog]。答案应为5hit-hot-dot-dog-cog。无法转换endWord不在wordList中应返回0。直接相连wordList中包含endWord且与beginWord只差一个字母应返回2。长链构造一个很长的单词链测试算法是否能找到最短路径。大列表使用大的单词列表测试算法性能是否超时。Q4: 单词中可能包含大写字母或特殊字符吗A4: 根据LeetCode题目描述所有单词均由小写字母组成。这是一个重要的前提它保证了我们可以安全地使用‘a’到‘z’进行字符替换。如果单词可能包含其他字符则需要调整替换策略。调试技巧打印状态在BFS循环中打印当前队列、已访问集合观察搜索过程。小数据测试先用最小的例子比如3个单词手动模拟确保算法逻辑与你的预期一致。边界检查特别注意开始和结束的条件。例如如果beginWord和endWord相同怎么办题目通常不会这样给但自己要知道这种情况应该返回1不需要转换。
返回列表