1. BFS到底是什么:从排队扩散到层序遍历
每个人学算法,早晚都会撞上BFS这个名字。BFS全称Breadth First Search,广度优先搜索,也有人叫宽度优先搜索。它在“暴力枚举算法”这个大类里是最朴实也最值钱的一张牌。
一句话先把它说清楚:BFS就是从一个起点出发,像水波一样一层一层往外扩;先处理距离起点为1的所有节点,再处理距离起点为2的所有节点,再处理距离3的……每层的节点都完整扫过,才进入下一层。它解决的问题往往非常具体:在迷宫里的最短步数是多少;社交网络里A能不能通过几层朋友找到B;一个状态经过若干次操作能不能变成目标状态。只要是“最少几步”“最短路”“最早相遇”这类诉求,BFS几乎都是第一个该考虑的答案。
适合谁看?算法刚入门的在校学生、准备算法工程师面试的候选人,以及工作中要写图遍历、路径规划、状态搜索代码的研发。这篇东西不是教科书式的列定义,而是把我实际写BFS时囤下来的理解、代码、踩坑经验全摊开,给你一套可以直接照着用的打法。
1.1 遍历家族里,BFS和DFS到底差在哪
BFS最好的参照物是DFS(深度优先搜索)。这两种搜索算法关系就像是扫楼和钻地道:BFS一层一层扫过整栋楼,先把二楼所有房间排查完再去三楼;DFS则是一条道走到黑,进了A房间发现能通地下室,就先钻到底再回头。
这个差异直接决定了两者适合的场景:
| 对比项 | BFS | DFS |
|---|---|---|
| 遍历顺序 | 按距离逐层推进 | 沿一条分支深入到底再回溯 |
| 核心数据结构 | 队列(FIFO) | 栈(或递归函数栈) |
| 能否找到最短路径 | 能,第一个到达目标的路径就是最短的 | 不能保证,找到的第一条路径可能绕远 |
| 空间复杂度 | 可能很大(同层节点都要进队列) | 通常较省(只记录一条路径上的节点) |
| 典型应用 | 迷宫最短路、单词接龙、层级扩散 | 连通性判断、回溯排列组合、拓扑排序的DFS版 |
算法面试里经常有人在这两个里纠结。我的建议很简单:问“最少多少次、最短几步”就倾向BFS;问“是否存在一条可行路径,路径具体长什么样都行”,DFS往往更快写出代码。深度强化学习算法、PPO这类名字看着高级,但在处理确定性质的最短操作序列时,底层依然绕不开这种朴素的图搜索思路。
1.2 核心数据结构:队列加上visited,缺一不可
写BFS你只需要两样东西:一个队列,一个访问标记数组。
队列负责管理“下一批要处理谁”。BFS的天然顺序是“先来的先处理”,正好就是FIFO队列的语义。每次从队头取一个节点,然后把它所有能走到的、还没走过的邻居,全部塞到队尾。下一轮再从队头取下一个,整个过程像流水线一样稳定。
visited标记负责防呆。没有它,BFS会在图里原地打转。比如一个无向图里A的邻居是B,B的邻居又包含A,如果你不标记A已经被访问过,算法会无限把A、B互相塞进队列,内存直接爆炸,永远跑不完。所有正经的BFS实现,入队那一刻就会给节点打上标记,而不仅仅是出队时才标记。这个细节很多新手容易漏,后果就是队列里出现大量重复节点,后面我单独写一节专门讲这个坑。
1.3 为什么BFS能找到最短路径:从队列性质说起
很多人背过结论但说不清理由。BFS能找到最短路径,关键在于它按层扩散这一个铁律。
第一次从起点出发时,起点本身是第0层,它的所有邻居是第1层。第1层的邻居(去掉已访问的)是第2层。可以证明一个简单事实:当一个节点第一次被从队列里取出并访问到的时候,它所在的层数,就是起点到这个节点的最短距离。因为BFS保证第k层的所有节点一定在第k+1层节点之前被处理完,不存在“我绕了一条远路反而先到达某个节点”的可能——远路意味着要经过更多边,那它一定属于更高的层,一定更晚被访问。
用在无向无权图上,每条边的权重都当作1,那么从起点出发到任意节点的层数,就是边数意义上的最短路径长度。这个性质在代码实现上的表现就是:BFS处理过程中第一次遇到target,就可以直接返回,不需要继续把所有图都遍历完。实际工程里这个提前终止往往省下大量时间。
2. 写一个能跑的BFS:迷宫最短路径实战拆解
理论说多了容易飘,直接上手写一个经典例题:给定一个二维迷宫矩阵,0表示空地,1表示墙,从左上角走到右下角,上下左右四个方向移动,每移动一步计1,求最短步数。这是BFS最标准的训练场,也是算法工程师面试里出现频率极高的基础题。
2.1 把迷宫抽象成图:坐标系与方向数组
迷宫天然就是图:每个坐标是节点,相邻的空地之间连一条边。写代码前先把坐标系定下来,我习惯用(row, col)表示行列,避免和数学里的(x, y)混淆。方向数组直接写四个:
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]这四个元组分别对应上、下、左、右。注意顺序无所谓,但保持统一可以减少出错的概率。边界判断很关键:新坐标必须在0到rows-1和0到cols-1之间,同时对应位置不能是墙。
有人会把方向和边界检查写进循环里,也有人在循环外单独抽一个neighbors函数。我建议小规模项目里直接写在循环里,可读性更强;如果迷宫的邻接关系复杂,比如不规则地图,再抽出函数不迟。
2.2 完整BFS代码:每一步为什么这么写
我直接给出一个非常直观、适合学习也适合面试手写的版本:
from collections import deque def bfs_shortest_path(maze, start, end): rows = len(maze) cols = len(maze[0]) visited = [[False] * cols for _ in range(rows)] dist = [[0] * cols for _ in range(rows)] # 起点就是终点,防御性写法,避免多余的遍历 if start == end: return 0 q = deque() q.append(start) visited[start[0]][start[1]] = True while q: r, c = q.popleft() # 到这里时,dist[r][c]已经是从起点到当前格子的最短步数 for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nr, nc = r + dr, c + dc if not (0 <= nr < rows and 0 <= nc < cols): continue if maze[nr][nc] == 1: # 墙,跳过 continue if visited[nr][nc]: continue visited[nr][nc] = True # 入队时就标记,防止重复入队 dist[nr][nc] = dist[r][c] + 1 if (nr, nc) == end: return dist[nr][nc] q.append((nr, nc)) return -1 # 走不到终点这段代码有几个地方是特意雕琢过的。
一是visited标记的位置放在了入队时而不是出队时。这能保证同一个节点最多进队列一次,方便又安全。如果放在出队时才标记,同一个节点很可能被多个邻居在它出队之前反复塞进队列,队列里就会出现大量冗余,复杂情况下内存和耗时都会明显上升。
二是dist数组跟着BFS同步更新。这里因为我们是首次访问就更新,所以dist记录的必然是最短距离。你要是只想判断连通性,dist可以完全省掉,直接返回True/False。
三是提前终止。当(nr, nc) == end时,既不等到出队也不继续扩散,立刻返回。这是BFS求最短路径里非常标准的优化,因为第一层扩散到终点时,一定已经是用最少步数到达了。
2.3 不只算步数:如何同时拿到完整路径
面试题里经常追加一问:除了最短步数,还要输出最短路径本身。这也不难,只需要额外维护一个parent数组。
from collections import deque def bfs_shortest_path_with_path(maze, start, end): rows, cols = len(maze), len(maze[0]) visited = [[False] * cols for _ in range(rows)] parent = [[None] * cols for _ in range(rows)] q = deque([start]) visited[start[0]][start[1]] = True while q: r, c = q.popleft() if (r, c) == end: break for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nr, nc = r + dr, c + dc if not (0 <= nr < rows and 0 <= nc < cols): continue if maze[nr][nc] == 1 or visited[nr][nc]: continue visited[nr][nc] = True parent[nr][nc] = (r, c) q.append((nr, nc)) if not visited[end[0]][end[1]]: return None # 从终点回溯到起点 path = [] cur = end while cur is not None: path.append(cur) cur = parent[cur[0]][cur[1]] path.reverse() return pathparent数组里存的是“我是从哪个格子走过来的”。终点一旦被访问,一路回溯就能倒推出整条路径。注意回溯之后要reverse一下,因为倒推出来的顺序是从终点到起点。这个技巧在后续处理更复杂的状态搜索时同样适用。
2.4 这些细节我踩过坑:方向顺序、边界判断与坐标编码
方向数组四个元组的顺序,实际影响的是同层节点的遍历顺序。在没有特殊要求时哪个在前都行,但如果你希望输出“字典序最小”的路径,就必须把方向按字典序排列,比如上、左、右、下这样,而且BFS天然会先找到字典序小的路径,因为它按层推进且每层内按方向数组顺序入队。
边界判断上,我见过很多新手用if nr < 0 or nr >= rows or nc < 0 or nc >= cols这样的写法,完全可以,但要注意and/or别写错。另一种更省事的方式是给迷宫外围加一圈墙,比如把原矩阵扩成(rows+2) x (cols+2),边界上的墙值刚好挡掉非法越界。这样代码里就能少写一组边界判断,压位和判重也更干净。缺点是要多处理一层内存,小数据无所谓,大数据要考虑。
坐标编码也是一个实用技巧。当queue里直接存tuple时,Python的开销并不大;但如果是在性能敏感场景或C++里,很多人会把(r, c)编码成r * cols + c这样一个整数,放进队列。比较时直接用整数比较,还能用一维数组做visited,比二维数组快不少。这个技巧在处理网格类BFS时特别好用,尤其是后面讲状态压缩的时候会再次用到。
3. BFS的经典应用:从连通性到状态空间搜索
迷宫最短路只是把BFS当“地图巡路器”用。现实中BFS能干的远不止这个,它可以算连通块、可以排依赖顺序、甚至可以把“某个局面的快照”当成节点来搜,这就是所谓状态空间搜索。这一节我按难度从低到高拆几个高频场景。
3.1 连通性:数一数图里有几块“岛屿”
最经典的题目是LeetCode 200“岛屿数量”:一个二维网格里,1表示陆地,0表示水,上下左右相邻的1构成一座岛屿,问总共有几座。
思路就是遍历每一个格子,遇到没访问过的1就从它开始做一次BFS,把这一整块岛屿所有格子都访问并标记掉。每触发一次BFS,答案加1,因为每个连通块只会被它的某个代表格子触发一次。
from collections import deque def num_islands(grid): rows, cols = len(grid), len(grid[0]) visited = [[False] * cols for _ in range(rows)] ans = 0 for i in range(rows): for j in range(cols): if grid[i][j] == '1' and not visited[i][j]: ans += 1 q = deque([(i, j)]) visited[i][j] = True while q: r, c = q.popleft() for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]: nr, nc = r + dr, c + dc if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1' and not visited[nr][nc]: visited[nr][nc] = True q.append((nr, nc)) return ans这种“逐点扫描+一次BFS标记一整个连通区域”的模式,本质就是在做连通分量计数。它不止出现在岛屿题里,图像处理里的连通域标记、社交网络里的朋友圈划分、点云聚类里的近邻连通判断,全是同一套逻辑,只是节点类型和邻居定义不同。所以把这个模板吃透,很多看着陌生的实际问题都能往里面套。
3.2 拓扑排序:用BFS梳理依赖关系
有一类经典问题长这样:有n门课,有些课必须修完前置课才能选,比如“学算法分析前要先学离散数学”,给你所有课程的先修关系,判断能不能把所有课学完。这题有两种主流解法:DFS和BFS。BFS版本尤其清爽,叫Kahn算法。
思路:统计每个节点的入度,入度为0的节点说明没有任何依赖,可以先处理。把这些入度为0的节点全部放进队列,依次弹出,每弹出一个就去“解除”它对邻居的依赖——让邻居的入度减1,如果邻居的入度因此变为0,就说明它的所有前置都处理完了,可以入队。最终如果处理过的节点数等于总数,说明图里没有环,可以学完;否则说明存在循环依赖。
这个算法虽然没有传统“按层扩散”的外形,但它确实用的是队列加逐层消解的思想,本质上是BFS家族的一员。工程里,论文引用顺序、编译依赖、构建流程的依赖分析,全是它在幕后干活。
3.3 状态空间搜索:把整个局面当成一个节点
当BFS的节点不再是一个坐标,而是一个完整的状态快照时,问题就从“图上的寻路”升维成“状态空间里的寻路”。
举个例子,八数码问题:3x3棋盘上有1到8八个数字和一个空格,目标是把棋盘从初始状态通过移动空格变成目标状态。每个棋盘排布就是一个“状态节点”,移动空格一次就产生一个新状态。从初始状态出发,用BFS一层层生成所有可达状态,第一次生成目标状态时走过的层数就是最少移动次数。
这种题对新手最大的冲击是“怎么给状态判重”。坐标题里visited是二维数组,但状态题里每个状态是一个排列,没有办法用简单数组标记。常规做法是给状态算一个哈希值或序列化字符串,然后放进哈希表。比如八数码可以把棋盘按行展开成一个9位字符串,用set判重。
再比如LeetCode 752“打开转盘锁”:一个四位转盘锁,每步可以转动一个拨轮一格,有些状态是死亡数字不能用,求从0000转到target的最少次数。这里的节点就是四位数如“0001”,把“转动一格”看作移动一步,BFS搜最短路径。代码写起来就是标准的“四位数字+上下两个方向”的邻居扩展问题。
这类状态空间搜索在游戏AI里特别常见,华容道、魔方、推箱子、拼图类游戏的求解器,核心都是BFS或A*加状态压缩。所以“BFS会搜状态空间”这个认知,比单纯会写迷宫代码重要得多。搜索热词里的“剪枝算法”“暴力枚举算法”,很多就是在这种状态空间搜索场景里用来砍掉不必要分支的手段。
3.4 现代算法里的影子:BFS为何是基础课中的基础
有人会问:现在都聊深度强化学习、PPO、DQN,数据库里还有各种高级图算法,BFS这种半世纪前的东西还有必要学吗?
我的看法是,BFS不但是基础课,还是后续很多复杂搜索范式的地基。深度强化学习里的探索策略很多用的是随机采样,但如果要给智能体提供一条可行路径作为初始示范,或者在一个离散规划问题里计算最短距离作为奖励函数的标尺,图搜索算法就会被拿出来用。A算法本身就是在BFS骨架上加入启发函数,双向搜索、迭代加深深度优先搜索(IDA)这些优化都建立在对BFS层序扩散模型的理解上。换句话说,你要是能把BFS彻底吃透,后面理解A*、Dijkstra、甚至某些强化学习里的规划组件都会顺畅很多。
4. 决定BFS效率的细节:剪枝、双向BFS与空间权衡
BFS的弱点非常明显:空间爆炸。在一个分支因子很大的状态空间里,BFS每一层产生的节点数量可能呈指数增长。比如八数码平均分支因子约2.7,深度20层时理论节点数就是个天文数字。所以实战里单纯写个“裸BFS”往往不够,还需要搭配剪枝、双向搜索这些工程手段。这一节我重点讲三个能立刻提升你BFS实用性的技巧。
4.1 剪枝:搜索之前先砍掉一批不可能的分支
剪枝这个词看起来高级,本质是你提前判断某个节点不值得扩展,于是人为跳过它。放在BFS里就是:生成邻居后,先做几个廉价检查,不合格的邻居直接不压入队列。经典的剪枝类型有这么几种:
第一是合法性剪枝。比如迷宫题目里越界和撞墙就是合法性剪枝,这个我们前面已经写在边界判断里了。第二是重复性剪枝,就是visited标记,同一个状态只处理一次。第三是可行性剪枝,从当前节点出发,按最乐观估计都不可能到达目标时直接放弃。最常见的写法是估计“剩余距离的下界”,比如八数码里可以算当前状态和目标状态之间有多少个棋子不在正确位置,如果即使这些棋子在最快情况下全部归位,需要的步数也不够达到某个预设上界,就放弃。
还有一种是“对称性剪枝”。在转盘锁问题里,如果两个状态本质等价(比如转1和转9都能得到同一个拨轮位置),可以只保留一个代表。工程上这个优化写起来复杂,但对缩小搜索空间极其有效。
剪枝的目的是将搜索空间缩小到一个可控范围。写剪枝时的核心原则是:检查必须比扩展更便宜,如果剪枝本身的判断复杂度比处理一个节点还高,那就得不偿失了。
4.2 双向BFS:两个端点同时出发,面积直接开根
BFS的搜索开销和搜索半径的关系很敏感。设每个节点的分支因子是b,搜索深度是d,普通BFS要访问的状态数大约是 O(b^d)。如果起点和目标点同时各扩一半,两边各扩 d/2 层,那么总访问量就变成 O(b^(d/2) + b^(d/2)),约等于 O(b^(d/2))。指数函数里,这个收益是颠覆性的:b=10、d=10时,单侧是100亿量级,双向各5层只要20万量级,差了好几个数量级。
双向BFS的实现套路:维护两个队列,一个从起点扩展,一个从终点扩展(注意反向扩展时邻居方向要反过来)。每次选择队列较小的一端扩展一层,检查扩展出的新状态是否已经出现在另一端访问过的集合里,一旦出现交集,就找到了一条路径。
from collections import deque def bidirectional_bfs(start, target, neighbors_func): if start == target: return 0 q_start, q_target = deque([start]), deque([target]) visited_start, visited_target = {start}, {target} dist_start, dist_target = {start: 0}, {target: 0} while q_start and q_target: if len(q_start) <= len(q_target): for _ in range(len(q_start)): cur = q_start.popleft() for nxt in neighbors_func(cur): if nxt in visited_start: continue if nxt in visited_target: return dist_start[cur] + 1 + dist_target[nxt] visited_start.add(nxt) dist_start[nxt] = dist_start[cur] + 1 q_start.append(nxt) else: for _ in range(len(q_target)): cur = q_target.popleft() for nxt in neighbors_func(cur): if nxt in visited_target: continue if nxt in visited_start: return dist_start[nxt] + 1 + dist_target[cur] visited_target.add(nxt) dist_target[nxt] = dist_target[cur] + 1 q_target.append(nxt) return -1注意这里每次只扩展一层,而不是一次扩展一个节点,这样保证两边始终严格保持“层”的概念,距离计算才不会出错。还有一点:双向BFS要求反向扩展时能正确定义邻居,比如有向图里反向时要用入边而不是出边。
4.3 空间复杂度是隐藏的杀手:状态压缩三板斧
BFS另一个比时间更隐蔽的瓶颈是内存。队列里同时住着一整层的节点,如果一层有百万节点,每个节点还是一个复杂对象,内存可能直接被打爆。
常用的压内存三板斧按投入产出比排序:
第一,用整数代替复杂对象。网格问题里坐标压成一个int,尤其是C++里很常见。Python里虽然整数本身也是对象,但比tuple还是明显省内存。关键是可以直接拿它做一维数组的下标或set里的键,比存二维tuple快。
第二,位压缩。如果状态本质上是一个布尔数组或有限选择,可以用一个int的二进制位来表示整个状态。比如一个8x8棋盘上某些格子的占用情况,可以压成一个64位整数,判重直接放进set ,内存比存数组小好几倍。
第三,去冗余。如果有大量状态本质等价,尽量把它们归并成一个代表状态再进队列。比如旋转或翻转对称的棋盘局面,可以只保留一个。
很多算法工程师面试里对BFS的空间复杂度描述都会提“O(节点数量)”这种模糊结论,但真到了工程场景,这个O的常数项可能直接决定你的程序是跑完还是被OOM杀掉。所以写BFS之前先估算一下预期能到达多少状态,如果数量超过几百万,就要认真考虑状态压缩方案了。
4.4 BFS和A*的取舍:什么时候该换启发式搜索
BFS虽然一定能找到最短路径,但它盲目地把所有方向都扩散了一遍,效率有时很低。A*算法在BFS框架里加入了一个“估计函数”f(n)=g(n)+h(n),g是从起点到当前节点的实际代价,h是当前节点到目标节点的估计代价。它会优先扩展f值最小的节点,而不是严格按层扩展。这样启发函数给力时,能大幅减少搜索量。
那么什么时候还坚持用BFS,什么时候换A*?我的判断标准很简单:如果图的规模小、深度浅,BFS足够;如果状态空间巨大但存在好的启发函数,比如棋类问题的曼哈顿距离、游戏里的欧氏距离,就值得考虑A*。但A也有它的坑:启发函数如果不可采纳(高估目标距离),会丢掉最优解;实现也比BFS复杂,需要考虑开闭表、f值更新等问题。所以算法面试、工程选型里,我一般先默认BFS,只有数据规模逼到必须优化时才认真考虑A。
5. 踩坑记录与排查实录:BFS常见问题的排查流程
技术文章一百篇也顶不过自己现场踩一个坑来得深刻。这一节我把自己写BFS时真遇到的几个问题整理出来,每个都附上排查方法,你以后遇到类似现象可以快速定位。
5.1 队列无限膨胀、内存爆掉:先查visited的标记时机
症状:程序跑着跑着内存直线上升,最后OOM。
排查顺序:第一步,确认有没有visited。第二步,确认visited标记是在入队前还是出队时。前面已经说过入队时标记才对。如果是在出队时才标记,同一节点可能被多个邻居重复入队,队列中积压大量重复项。第三步,确认不同的状态是否都被正确判重了。比如状态是坐标时用二维数组,但有些坐标序列化之后才不一样,你必须确保visited的“粒度”足够细。
有一类隐蔽问题是“状态没接住”:比如你压进队列的是一个对象,但每次新生成的对象内容相同却地址不同,哈希判重时如果没override哈希函数,set就会认为它们是不同状态。所以状态压缩和判重必须用“内容的标准化表示”,例如排好序的tuple或者序列化字符串。
5.2 路径长度永远不对:别把层数搞错
症状:BFS能跑通,但返回的距离莫名其妙地大一倍或小一半。
最常见原因是在层扩展时没有按层处理。有些新手会用“每次从队列只取一个节点,然后立刻扩展它的所有邻居”这种写法,严格说也能工作,但如果你在扩展时同时修改队列长度并且循环条件写成while len(q) > 0,会导致某层的节点还没处理完就进入了下一层的节点,层号错乱。
正确做法是套一层内循环:先记录当前队列长度size = len(q),然后只处理这size个节点,每处理一个时把它的新邻居追加到队列尾部,但不立刻处理。这就是BFS“按层前进”的标准写法。很多模板代码里读到的for _ in range(len(q))就是这么来的。
5.3 双向BFS碰不到:确认终止条件和距离计算
双向BFS最常见的坑是交集判断写错。注意当我在某一侧扩展出新节点nxt时,如果nxt已经在另一侧被访问过,那么最短路径长度应该是这一侧当前节点距离+1+另一侧那个节点距离,而不是单纯的两个距离相加。这是代码里最容易被忽略的地方。另外,两个队列中有一个变空就说明那一侧所有可达状态都搜完了,可以直接返回无解,不要再继续。如果两侧都不空,但始终没有交集,说明起点和终点根本不在同一个连通分量里。
5.4 边界条件一碰就挂:起点终点重合、无解、空图
我建议所有BFS函数开头都加上这几个防御性判断:
if not maze or not maze[0]: return -1 # 空图 if start == end: return 0 # 已经在目标位置这类判断看着不起眼,但能救命。测试用例里最喜欢塞的就是这种极端情况。无解的情况也要想好返回什么,习惯上返回-1或None。如果你的接口要返回路径,记得无解时返回空列表而不是None,避免调用方多写一层判断。
5.5 排查问题速查表
| 症状 | 最可能的根因 | 快速修复 |
|---|---|---|
| 死循环或内存爆掉 | visited缺失或标记时机不对 | 入队时立即标记visited |
| 结果偏长 | 没按层扩展 | 用for _ in range(len(q))实现按层 |
| 结果偏短 | 把入队但未出队的节点也计入了距离 | 确认距离只在首次出队访问时更新或入队时就确定 |
| 漏解 | visited判重粒度过粗 | 让visited能区分每个状态 |
| 双向BFS找不到解 | 交集判断、反感邻接定义错误 | 检查反向邻接、起始状态处理 |
| 坐标越界 | 边界判断或方向数组写错 | 先打印坐标序列逐个验证 |
6. 从面试到工程:BFS的进阶路线与实战建议
如果你看到这里,BFS这个知识点已经比较扎实了。最后这部分我再聊聊怎么把它用到真实的场景里,以及面试里会被追问到哪些点。
6.1 识别“这是BFS题”的信号:最短步数、无权图、状态转换
我拿到一个题目时,判断要不要用BFS的顺序非常机械:首先问自己,问题是不是在求“最少、最短、最快”这样的最优级指标?如果要求的是“是否存在一条路径”而不是“最短路径”,DFS也不一定差,但BFS仍然可以接受。接着问自己,图的边权是不是全部相同?无权图或者边权均为1的图,BFS是最短路的最优选择;边权不同则要上Dijkstra。再问自己,搜索空间是不是“状态”而非“坐标”?如果是状态转换问题,大概率也要考虑BFS作为兜底方案。
这些识别信号比背题目清单靠谱。因为你不可能刷完所有题,但你可以把所有题目归类成“求最短”“找连通”“判依赖”“状态转换”几种模式,遇到新题时往里套。
6.2 面试高频题目背后的BFS套路
很多著名的算法题,本质都是在BFS框架上套了一个不同的壳,我这里快速点几个名:
- “单词接龙”:每个单词是节点,每次改一个字母可以得到邻居单词。BFS + 剪枝。
- “打开转盘锁”:四位数是状态,转动是边,BFS + 状态哈希判重。
- “二进制矩阵中的最短路径”:纯裸BFS,只考代码功底和边界处理。
- “最小基因变化”:和单词接龙几乎一样,换一层包装。
- “判断二分图”:BFS染色法,广度搜索时交替染色。
刷这些题时我建议你刻意用同一套模板起手:先定义状态类型和邻居生成函数,再写visited,最后写主循环。模板固定了,出错的概率小,面试时也不需要临时想怎么写。
6.3 面试官追问:复杂度分析和优化意识比背诵更重要
面试里最容易被追问的并不是“会不会写BFS”,而是“这个BFS的时间复杂度和空间复杂度怎么分析”“为什么它是正确的”“能不能优化”。
时间复杂度的标准回答是O(V+E),V是节点数,E是边数,因为每个节点入队/出队常数次,每条边在扩展邻接时被检查常数次。如果你想更精确,可以用“每个节点至多被访问一次,每个邻居至多被生成一次”来解释。空间复杂度是O(V),比如visited数组加队列。状态空间搜索题里V就是所有可能状态数,这个数字可能很大,所以才会引申出双向BFS和剪枝的讨论。
正确性的证明一般用反证法:假设BFS找到的不是最短路径,那么一定存在一条更短的路径,设它的终点在第k层被访问到;但BFS按层展开,所有第k层的节点一定在第k+1层任何节点之前被处理,矛盾。把这个逻辑链讲顺,面试官往往就满意了。
优化方向先说双向BFS,再说状态压缩,再说剪枝。这三招的优先级也是这个顺序,因为双向BFS收益最直观,压缩和剪枝需要结合具体问题权衡。
6.4 最后再分享一个小技巧
我自己写BFS有个习惯:先用一个很小的手工构造测试用例,把所有坐标和状态变化打印出来,肉眼确认每一层的节点顺序没问题,再放开规模去跑。这个习惯帮我省了非常多调bug的时间。毕竟BFS的代码本身很简单,真正难的是你在复杂状态空间里保持层序、距离和判重三者不错位。你如果从今天开始练BFS,我建议你也把这个打印层序的习惯刻进肌肉记忆里,它对你后面学A*、IDDFS这些进阶搜索会有实实在在的帮助。