广度优先搜索BFS算法详解:从模板到最短路径实战
1. 先搞懂BFS到底在干嘛1.1 一个类比讲清广度优先BFSBreadth-First Search广度优先搜索这名字起得很直白搜索时优先把同一距离内的节点全部扫一遍再往外扩一层。想象往水面丢一颗石子波纹是逐圈扩散的——BFS就是那个波纹永远不会跳过内圈直接冲到外圈。说个更生活化的例子。你在公司群里问一句谁认识做支付网关的同事大家只会推荐自己身边直接熟识的人比如我组里的张三跟支付团队熟如果没人接话你才会托人问谁认识认识支付团队的人。这种由近及远的打听方式本质上就是BFS第一圈是你的直接人脉第二圈是朋友的朋友第三圈是朋友的朋友的朋友……谁最先被找到谁就是离你最近的那个人。这种逐层推进的特性让BFS在计算机科学里有两个极其关键的用途一是在无权图中求最短路径路径长度就是层数二是天然带出层级和距离信息。队列的先进先出FIFO特性正好匹配这个节奏先把起点入队每次从队头取一个节点往外扩展扩展出来的新节点放到队尾这样先入队的老节点永远先被处理层次顺序就牢牢保住了。1.2 BFS的三件套队列、访问标记、距离数组写BFS别急着上手码代码先确认三样东西备齐了。第一件是队列。Python用collections.dequeC用std::queueJava用ArrayDeque。这里有个小坑用普通list模拟队列pop(0)的时间复杂度是 O(n)数据量一大就卡到怀疑人生deque.popleft()是 O(1)这才是生产级写法。第二件是访问标记数组 visited。BFS最怕回头路——尤其在有环图里不标记的话同一个节点会被反复入队轻则队列膨胀重则死循环。visited可以用布尔数组、哈希集合有些场景还能直接用dist数组是否被初始化过来代替省一个数组的空间。第三件是距离数组 dist或者叫步数计数器。最短路径场景用它记录每个节点到起点的距离树的层序遍历稍微特殊距离可以简化成逐层累加的 depth 变量。三件套备齐BFS的骨架其实只有几行。这个模板说真的背下来就能应对绝大多数树和图的问题后面我会把它拆开逐行讲明白。2. BFS代码模板从逻辑推演到落地2.1 一套能吃遍树和图的模板先给出最标准的写法以Python为例from collections import deque def bfs(start, graph): q deque([start]) # 1. 队列初始只有起点 visited {start} # 2. 访问标记起点先标记 dist {start: 0} # 3. 距离起点到自己的距离为0 while q: # 队列非空就继续 node q.popleft() # 4. 取出队头节点 for neighbor in graph[node]: # 5. 遍历当前节点的所有邻居 if neighbor in visited: continue # 已经访问过跳过 visited.add(neighbor) # 6. 入队前先标记 dist[neighbor] dist[node] 1 q.append(neighbor) # 7. 邻居入队 return dist这个模板看着简单但很多人第一次写都会在标记时机上翻车。核心准则是入队时就要标记visited而不是出队时标记。为什么你品一下如果出队时才标记某个节点可能在被多个邻居扩展时都满足未访问条件被重复入队多次。在层数多一点、分叉多一点的图上队列里会堆满重复节点性能爆炸甚至因互相入队导致死循环。还有一点容易被忽略初始化时起点就要标记visited。很多新手只把起点放进队列忘了标记导致扩展起点邻居时又把起点当作未访问节点重新入队。单源图还好多源图里这个错几乎是必犯的。2.2 脑内流程图BFS和DFS的画面差异网上搜bfs和dfs算法流程图会出来一堆花花绿绿的图别被图绕晕。BFS和DFS的流程差异一句话就能概括BFS用队列DFS用栈或递归。BFS的画面是这样的一个队列横在面前每次都从队头取一个节点把它所有的邻居推到队尾。你取到第n个节点时队列里已经同时存在第n1层甚至更后面的节点。DFS的画面完全不同抓住一条路走到黑撞墙才回头回头后再换一条路继续走到底——它用的是栈的后进先出特性或者更常见的递归调用栈。流程上要格外小心一点BFS的队列里同一时刻可能并存多个层次的节点。如果想统计每一层有多少个节点不能拿动态的队列长度来做循环范围必须在进入每一层前先记录当前队列的size快照然后只处理size个节点。这个细节是二叉树层序遍历的经典做法也是无数面试题埋的坑后面单独展开。3. BFS在算法题里的四种典型形态3.1 二叉树层序遍历最小号的BFS演示最直观的BFS应用就是二叉树的层序遍历给一棵二叉树按层从上到下输出节点值。这个问题可以说是BFS的最小演示工程也是检验你有没有吃透队列快照技巧的试金石。from collections import deque def levelOrder(root): if not root: return [] res [] q deque([root]) while q: level [] for _ in range(len(q)): # 关键快照当前层节点数 node q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(level) return res注意for _ in range(len(q))这里的len(q)只在循环开始时计算一次不会随着每次popleft或append而变化。Python的range参数在构造时就固定了所以这个写法天然就是只处理当前层的节点。如果你写for _ in range(len(q) * 2)或者用while加动态判断队列会越扩越大层就分不清了。这个问题的变体很多锯齿形层序遍历交替反转level、最右视图每层最后一个值、填充next指针层内串联。核心思想完全一样就是快照size加逐层处理。能把层序遍历吃透BFS的八成手感就有了。3.2 网格迷宫最短路径BFS解决最短路问题的黄金场景第二个典型场景是矩阵/网格上的最短路径。比如从左上角走到右下角每次只能上下左右走一步有障碍物就不能走求最短步数。这是BFS的看家本领因为网格相邻格子的移动代价都是1天然满足BFS求最短路的前提。网格题的关键在于三件事方向数组、边界检查、距离记录。方向数组我通常这样写directions [(1, 0), (-1, 0), (0, 1), (0, -1)]四个方向覆盖下、上、右、左。边界检查和障碍检查合并成一个ifif 0 nx rows and 0 ny cols and grid[nx][ny] ! 1 and not visited[nx][ny]:这里有个省内存的操作距离数组可以复用grid本身。把起点grid值改成0每走一步给新格子赋当前距离加1最后直接读终点的值就是最短步数。但反过来要小心别把障碍物标记覆盖了也别把已经走过的路径值误判成障碍。我在实际刷题中还发现一个习惯问题坐标入队用元组(x, y)直观好读可当矩阵规模到上千乘上千时元组构造销毁的开销会变得可感知。追求性能的场景可以把坐标编码成单个整数pos x * cols y解码时x, y pos // cols, pos % cols。编码解码各一行实测在百万格子级别时性能差异明显。3.3 多源BFS与0-1 BFS进阶玩法多源BFS的场景很经典比如矩阵中每个格子到最近的0的距离。如果对每个1分别做一次BFS复杂度和写代码的心情都会爆炸正确做法是把所有0同时当作起点塞进队列然后由近及远逐层扩展。这就是多源BFS——它把问题从每个1找最近的0变成多个0同时向外抢占可达格。为什么多源BFS是对的因为BFS的层数属性保证第一次到达某个格子的那个源就是离它最近的源。这就好比城市里同时亮起好几盏灯光波从各个灯源同步向外扩张某个点先被哪盏灯的光照到它离那盏灯就最近。网格距离的同步扩张模型可以非常直观地映射到队列的操作顺序上。进阶还有一个0-1 BFS当图中每条边的权重不是1而是0或1时可以用双端队列来优化。权重为0的边插入队头权重为1的边插入队尾这样队列里的节点仍然按距离单调递增最短距离可以在线性时间内求出。这个属于进阶内容面试中偶尔出现知道原理和应用场景已经足够。3.4 双向BFS把搜索空间砍到根号级如果题目把起点和终点都明确给了比如单词接龙、打开转盘锁这类状态空间搜索题有个非常实用的优化叫双向BFS。思路是从起点和终点同时做BFS每次扩展节点数少的那一侧直到两侧的访问集合出现交集。为什么快单向BFS的搜索空间大约是指数级 b^d双向BFS把深度对半分两侧各自扩展约 b^(d/2)合起来约是 2 * b^(d/2)。在状态空间大的题目里这个差距不是常量级别的快一点而是直接跑完和超时的区别。实现细节有几个坑。第一两侧各自维护一个队列和一个visited集合第二每次优先扩展节点数少的那一侧而不是死板地交替第三当扩展出来的某个节点出现在另一侧的visited中时说明搜索树相遇返回当前步数之和但要看清题目要求的边界条件。我在LeetCode 127题单词接龙上做过对比单向BFS个别数据点要跑三秒双向BFS基本几十毫秒就出结果差距非常直观。4. BFS和DFS对照一张表看透异同4.1 复杂度、数据结构、适用场景的全方位对比既然标题热词总把bfs和dfs算法放一起那这一节就把两者摊开对比方便你面试时一句话说清差异。维度BFSDFS核心数据结构队列FIFO栈 / 递归调用栈LIFO遍历顺序按层扩展由近及远沿一条路径走到底再回溯时间复杂度O(VE)O(VE)空间复杂度最坏O(V)队列可能存一整层最坏O(V)链状图时递归栈很深天然求最短路径是边权为1或0-1时否要枚举完才知道最优层级信息天然携带需要额外记录典型应用最短路径、层序遍历、拓扑排序的BFS版排列组合、回溯、连通分量、拓扑排序的DFS版剪枝配合一般回溯剪枝很强大时间复杂度和DFS完全一致都是 O(VE)。但空间表现有差异BFS在宽的图上占内存更大DFS在深的图上递归层数更危险。比如一棵深度十万的链状树BFS队列里最多一两个节点毫无压力DFS递归直接可能导致栈溢出除非改迭代。4.2 什么时候选BFS什么时候选DFS我的经验可以浓缩成四条决策准则按顺序套就能选对。第一题目里出现最少步数最短路径最少变化次数这类字眼且每一步代价相同无脑上BFS。这是BFS的主场也是DFS很难优雅替代的领域。第二题目要求枚举所有方案、所有路径、所有排列组合或者需要回溯到某个决策点重新尝试优先DFS。比如全排列、N皇后、岛屿数量这类把所有情况走完的题DFS加剪枝才是正解。第三树的遍历要看输出顺序。按层输出、求最大深度BFS更自然先序、中序、后序遍历用DFS更好写。这里没有谁碾压谁纯粹是匹配题目对顺序的要求。第四当图特别深、递归容易爆栈时优先考虑BFS或迭代版DFS。如果你一定要DFS且深度可控记得把递归深度限制调大但这是权宜之计迭代手写栈才是稳妥做法。另外提一个折中思想迭代加深DFSIDDFS。它用DFS的空间叠加限制深度逐次加深的操作从而具备BFS的最短路径性质。状态空间大、深度不确定、又不想爆内存的搜索题IDDFS是一个很优雅的中间方案。知道这个思想在系统设计讨论里往往能让你显得更老练。5. 实操经验与避坑清单5.1 最容易翻车的5个细节BFS代码不长翻车点却非常集中。我把实操里踩过的坑和帮人debug见过的错整理成一份速查表照着自查能省很多时间。序号问题正确做法出错后果1visited标记时机入队时立即标记重复入队、性能恶化、死循环2起点是否标记入队前标记起点起点被重复扩展逻辑混乱3层序遍历范围用快照size层分不清结果全错4网格方向与边界检查越界障碍越界崩溃或错误路径5状态去重维度按完整状态去重剪枝不够搜索爆炸第4条网格题多说一句方向数组写漏方向太常见了我见过有人写了四个方向但漏掉向上导致答案完全不对还不报错。写完网格BFS先跑个2x2的迷你用例一跑就知道方向齐不齐。第5条状态去重维度要展开说。像单词接龙这种题状态是当前单词去重用的visited集合存字符串就够了但像转盘锁这种题状态是四个位置当前次数吗不状态只是锁的四位数序列本身步数是由BFS层数携带的。识别出真正的状态是什么、该用什么维度去重是建模能力的体现也是BFS题的核心考察点。我的建模步骤是先明确节点是什么再明确邻居怎么生成最后明确访问状态怎么定义。三步走通题目就解了一半。还有一个我在工程代码里会注意的点什么时候用数组visited什么时候用集合。节点编号连续且范围已知时用布尔数组更快也更省内存节点是字符串或区间极大且稀疏时用哈希集合。数组和集合的性能差异在百万级节点时会被放大平时养成选型习惯到大型图处理时能少踩不少坑。5.2 调试BFS的三板斧BFS的bug不直观因为它不像普通程序那样能靠报错信息快速定位。我自己调试BFS有固定三板斧每次都能奏效。第一板斧打印队列状态。在while循环开头加一行print(q)把队列内容打出来。只要看队列里有没有重复节点、层次顺序对不对立刻就能看出标记时机和入队逻辑的问题。别觉得土这是我实际排查效率最高的手段。第二板斧打印dist变化序列。用一个极小的人工构造图比如3层二叉树手动推演一遍期望的dist值再对着代码输出比对。人工推演10分钟往往能发现代码里的隐性错误。第三板斧单步断点看入队时机。在IDE里对popleft和append分别打条件断点观察每个节点的入队和出队顺序是否与BFS理论一致。特别适合排查某个节点为什么提前出队这种恍惚型bug。调试完了再做性能优化。我的优化清单按优先级排序list模拟队列换成deque元组坐标换整数编码visited集合换数组如果条件允许双向BFS代替单向BFS预处理邻居映射代替每次都完整扫描字典。这些优化做完我遇到过的最夸张的一次是从超时到几十毫秒的跨度。5.3 建模能力才是BFS的真正天花板刷过几百道图相关的题之后我越来越确信一件事BFS模板本身不值钱值钱的是建模能力——把一个实际问题抽象成节点、边、状态的图模型再套用BFS求解。以非常经典的单词接龙为例。节点是wordList里的每个单词相邻边是两个单词之间只差一个字母。起点是beginWord终点是endWord求从起点到终点经过的最少单词数。这个模型一建立BFS就是标准模板麻烦的是如何高效生成邻居。我的做法是预处理把每个单词按字母位置分桶比如abc在位置0的桶里是*bc在位置1的桶里是a*c在位置2的桶里是ab*。扩展一个节点时直接查它三个位置的桶就能O(1)拿到所有只差一个字母的邻居而不必每步都遍历整个wordList做全量比对。这个预处理让单词接龙的性能从勉强通过变成轻松高效是典型的建模优于暴力案例。建模能力没有捷径只能靠多练多总结。但有一个立竿见影的训练方法每拿到一道BFS题先别急着写代码强制自己在纸上写出三句话——节点是____邻居生成方式是____visited状态定义为____。写完再动手正确率和速度都会明显提升。这个方法我带过的新人几乎都反馈有效算是从实践里沉淀出来的硬经验。我个人在持续使用BFS的过程里最深的体会是算法本身一点不玄它就是把由近及远这个朴素思想用队列翻译给计算机听。真正拉开代码水平差距的永远是题干建模的那一步能不能看穿题目的图论本质能不能把状态转换写干净能不能把去重维度设计准确。把模板练成肌肉记忆之后多把精力花在模型抽象上你会发现自己解BFS题的思路会越来越顺畅。