☰
BFS广度优先搜索:城市路径问题的最短路径算法实战
2026/10/14 17:26:19 网站建设 项目流程

提到“城市路径问题”,很多自学算法的朋友第一时间想到的就是广度优先搜索(BFS)——比如你站在地铁站里,想知道从A站到F站最少换乘几次,或者从老城区到新城区最少经过多少个路口。地图App给出的“最少换乘”方案,背后核心就是BFS。这篇博客我准备围绕城市路径问题,把广度优先搜索算法从原理到代码、从“能跑”到“好用”完整拆一遍:先讲怎么把城市路网变成程序能理解的图,再给BFS的完整实现和路径回溯方案,接着整理我在实际写这类代码时踩过的坑,最后聊聊BFS的边界和优化思路。适合刚学完树和队列、想在真实场景里用BFS的读者,也适合面试前突击算法题的同学。

1. 把城市路网变成一张图:BFS解决的问题到底是什么

1.1 从“最少换乘”切入,理解抽象的图模型

先想一个问题:地铁图从A站到F站,怎么判断最少换乘几次?很多人的第一反应是“打开地图看看”,但程序不会看地图,程序只能处理数据。要把城市路径问题交给计算机,第一步是把路网抽象成图。

一个图由节点和边组成。在地铁场景里,每个站点是一个节点,相邻站点之间的直达关系是一条边。注意,这里的“边”不是地理上的公里数,而是“乘坐一条线路能不能直接到达”。比如一号线从A站到B站中间没有其他站,那A和B之间就有一条边;如果A站和C站中间隔了一个站,它们之间就没有直接的边。这样建出来的图叫无权图,因为每条边的通行代价统一记为1次乘车,不区分A到B是2公里还是10公里。

为什么要把距离信息丢掉?因为你要回答的问题是“最少换乘几次”,而不是“最短里程是多少公里”。这两种目标对应完全不同的算法。最少换乘只看“经过多少条边”,每条边都算一步;最短里程要看每步的长短,需要给边加权重。很多新手把这两个问题混在一起,后面会发现BFS在某些场景下算出的并不是距离最短路线,这个后面详细说。

我经常用“朋友的朋友”来类比BFS的行为。你想认识一个陌生人,最少通过几层朋友关系才能联系上?你直接认识的朋友算第一层,朋友的朋友算第二层,朋友的朋友的朋友算第三层。BFS做的就是这件事:先把身边所有朋友问一遍,问不到谁认识目标,再向朋友的朋友打听,一层一层往外扩。城市路径问题里的BFS,就是把“朋友”换成“相邻站点”,把“几层关系”换成“换乘几次”。

1.2 为什么无权图最短路径非BFS莫属

这里要解释BFS为什么能找到最少换乘方案,也就是正确性问题。

BFS按层扩展,每一层代表“从起点出发经过固定步数能到达的所有节点”。第0层是起点自己,第1层是起点坐一站能到的所有站点,第2层是坐两站能到的所有站点,依此类推。当我们第一次遇到目标站点时,它所在的层数就是从起点出发所需的最少乘车次数。

这个结论成立的核心前提是:每条边的代价完全相同,都是1。BFS把扩展过的节点当作“已经确定最短步数”的节点,而因为所有边代价一样,先被访问到的节点一定比后访问到的节点更短或相等。如果边权不同,比如A到B是10公里、B到C是1公里,那步数少不代表里程短,这时候用BFS就可能给出错误的“最短路径”,得换Dijkstra算法。

还有一个容易被忽略的点:BFS天然具备“全局无死角”的特性。它不会像DFS那样沿着一条路走到底再回头,而是同步扩展所有方向。这意味着只要目标可达,BFS一定能找到路径,不会因为方向选择问题而漏掉。对于城市路径这种需要保证“只要存在路线,程序就必须给出答案”的场景,这个特性非常重要。

2. BFS的底层逻辑与完整实现

2.1 队列、访问标记、前置节点:BFS的三个核心要素

先把BFS用到的数据结构摆出来,理解它们为什么存在。

第一个是队列。队列的特点是先进先出,好比食堂打饭排队,先来的人先打饭走人。BFS需要保证“先发现的节点先扩展”,这样才能一层一层向外推进。如果用栈的话,就会变成深度优先搜索,最后走进一条岔路不回头。所以队列是BFS的骨架。

第二个是访问标记集合(visited)。城市路网几乎一定有环:地铁线路本身是闭合的,换乘站会把多条线连接成复杂的网状结构。没有访问标记,程序就会在一个环里反复转圈,比如A到B、B到C、C又回到A,永远停不下来。访问标记的作用是告诉程序“这个节点我已经处理过了,别再来了”。

第三个是前置节点记录(predecessor)。如果只想知道“能不能从A站到F站”,BFS跑完就能回答。但现实中我们需要的是完整路径,比如“A站坐1站到B站,换乘二号线,坐2站到D站,再换乘三号线到F站”。前置节点数组记录“每个节点是从哪个节点走过来的”,等BFS结束时从目标节点往前倒推,就能还原整条路径。

这三个要素缺一不可:队列管顺序,visited管收敛,predecessor管结果。我在给项目做技术选型的时候经常跟团队强调,不要只背BFS模板,要理解每个变量存在的意义。面试时你把这三个点讲清楚,比默写十遍代码更能体现水平。

2.2 城市路网的代码建模:邻接表这样建

假设现在要处理一个小型城市的地铁网络,包含6个站点和若干条线路,每条线路是双向的。程序里最常用的图表示方式是邻接表。

邻接表的核心思想是:为每一个站点维护一个列表,列表里存它直接相连的站点。用Python的字典来实现非常直观:

graph = { "A站": ["B站", "C站"], "B站": ["A站", "D站"], "C站": ["A站", "D站", "E站"], "D站": ["B站", "C站", "F站"], "E站": ["C站", "F站"], "F站": ["D站", "E站"] }

这样表示的意思是:从A站出发,可以直接到达B站和C站;B站又连回A站和D站。

实际构建邻接表时,最常见的数据来源是“线路表”。比如一号线经过A、B、C、D四个站,那么线路表中记录的是有序列表["A站", "B站", "C站", "D站"]。把这条线路变成邻接表,需要遍历列表中的每个站点,把它和前后相邻的站点互相建立连接。需要注意地铁线路通常是双向的,所以A要连向B,B也要连向A。

def build_adjacency_list(lines): graph = {} for line in lines: for i in range(len(line) - 1): station_a = line[i] station_b = line[i + 1] if station_a not in graph: graph[station_a] = [] if station_b not in graph: graph[station_b] = [] graph[station_a].append(station_b) graph[station_b].append(station_a) return graph

这段代码逻辑简单,但有一个容易踩坑的地方:如果两条线路在同一个换乘站相接,邻接表只会记录“相邻站点”,不会自动记录“换乘关系”。比如一号线在D站停靠,二号线也在D站停靠,你从一号线某站上车,到了D站下车换二号线,在图的层面看,你其实是在D站节点上继续走二号线的邻接边。这个模型天然支持换乘,不需要额外处理。换乘次数在BFS中体现为“路径经过了几个节点”,从步数换算过来即可。

2.3 从起点到终点:BFS主流程的代码实现

有了图之后,就可以写BFS主流程了。下面这段代码实现了从指定起点出发,搜索目标站点,并返回是否可达。

from collections import deque def bfs_can_reach(graph, start, target): if start == target: return True queue = deque([start]) visited = set([start]) while queue: node = queue.popleft() for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) if neighbor == target: return True queue.append(neighbor) return False

这段代码的执行流程是这样的:先把起点放入队列,标记为已访问。然后循环取出队首节点,遍历它的所有邻居。如果邻居是目标,直接返回True。如果邻居没访问过,就标记并加入队列。队列为空说明从起点出发能到达的节点都找遍了,目标仍然没出现,返回False。

注意这里visited标记的处理时机。我在上面代码里是“邻居入队前标记”,这是正确的做法。如果改成“节点出队时再标记”,同一个节点可能被多个邻居重复放入队列,造成重复计算,严重时在大图上会拖慢程序甚至内存溢出。这个问题在后面“常见问题”部分还会展开。

3. 从“能不能到”到“怎么走”:记录路径的关键技巧

3.1 前置节点数组:让路径可回溯

上面代码只回答了“能不能到”,但城市路径问题真正要的是“怎么走”。要拿到完整路径,必须引入前置节点数组。

前置节点数组的设计思想是“倒推”。BFS在扩展过程中,每发现一个之前没见过的节点,就记下“它是从哪个节点来的”。当BFS到达目标节点时,从目标节点开始,反复往前找前置节点,直到回到起点,再把顺序反过来,就是完整路径。

这个思路很像查族谱:你从自己这一代往上找父亲、祖父、曾祖父,一路找到祖先,再把链条倒过来念一遍,就是你的家族继承路径。用代码实现如下:

def reconstruct_path(predecessor, start, target): path = [] node = target while node is not None: path.append(node) node = predecessor[node] path.reverse() return path

前提是predecessor[start]要设置成None,表示起点没有前置节点,搜索到这里就停止了。

3.2 完整代码改造:输出“站到站”的换乘方案

把上一节的predecessor整合进BFS,做一个能返回完整路径的版本。

from collections import deque def bfs_shortest_path(graph, start, target): if start == target: return [start] queue = deque([start]) visited = set([start]) predecessor = {start: None} while queue: node = queue.popleft() for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) predecessor[neighbor] = node if neighbor == target: return reconstruct_path(predecessor, start, target) queue.append(neighbor) return None def reconstruct_path(predecessor, start, target): path = [] node = target while node is not None: path.append(node) node = predecessor[node] path.reverse() return path

用前面给出的6站点邻接表测试,从A站到F站,程序返回的路径是:

["A站", "C站", "D站", "F站"]

这条路径的含义是:从A站坐到C站,从C站坐到D站,从D站坐到F站,一共坐了3站。换乘次数怎么算?路径中有4个站点,从起点到终点经过了3条边,也就是3次乘车;换乘次数等于乘车次数减1,即2次。但严格说,如果你只坐同一线路,3站不需要换乘。这里的“换乘次数”在不同语境定义不同。最稳妥的做法是结合真实线路信息,统计路径中相邻站点属于哪条线路,线路切换的次数就是换乘次数。这需要在邻接表中额外记录“边属于哪条线”,属于进阶改造,后面讲。

3.3 算法复杂度分析:BFS为什么高效

BFS的时间复杂度是O(V+E),V是节点数,E是边数。为什么是这个数?因为每个节点最多入队一次、出队一次,访问节点总耗时O(V);遍历每个节点的邻接表时,每条边最多被检查两次(双向图),总耗时O(E)。对于城市路网这样的稀疏图,边数通常和节点数成正比,所以BFS实际跑起来非常快,几万个站点的网络也能在毫秒级给出答案。

空间复杂度是O(V),主要消耗在三个地方:队列最多装下同一层的所有节点,最坏情况下节点总数;visited集合和predecessor字典也都是O(V)。对现代计算机来说,几万个节点的城市路网占用空间完全可以忽略。真正导致内存问题的是构建邻接表时不小心把边重复添加,这在第4部分会讲到。

4. 实战中的常见问题与排查实录

4.1 死循环与访问标记遗漏

我在带项目时经常看到初学者写出卡死的BFS,十次有八次是访问标记的问题。

最常见的错误是在节点出队时才把它加入visited:

# 错误的写法 while queue: node = queue.popleft() if node in visited: continue visited.add(node) for neighbor in graph[node]: queue.append(neighbor)

这种写法在无环图里表现正常,但在有环的城市路网里,同一节点可能被多个邻居重复入队。比如A的邻居是B和C,B和C的邻居又都是D,D就会在B、C出队时被加入两次,甚至更多次。图稍微复杂一点,队列里全是重复节点,不仅效率降低,极端情况下内存也会被打爆。

正确的做法是在节点入队之前就检查visited并标记,保证一个节点只入队一次。也就是我在第2.3节代码里的写法。

排查技巧也分享一下:如果程序跑很久没结果,先在代码里加一个计数器,统计入队次数。入队次数明显大于节点总数,基本可以断定是重复入队。此时优先检查visited标记的位置。

4.2 内存膨胀与邻接表构建的坑

第二个高频问题出在构建邻接表的时候。

第一个坑是单向边。有些同学直接用线路列表,只把前一个站连向后一个站,忽略了双向。比如线路表是["A站", "B站"],只写了graph["A站"].append("B站"),没写graph["B站"].append("A站")。结果BFS从A出发永远到不了B的下一站,因为B站没有邻居。排查方法很简单:打印邻接表,看每个节点的邻居数是否合理。

第二个坑是重复添加边。如果有两条线路共享同一个相邻段,比如一号线和二号线都经过D站和E站,且这两个站相邻,那么构建时D到E的边会被添加两次。BFS本身不介意重复边,只要visited标记正确,多几条重复边只是多几次无效检查。但如果重复添加次数很多,邻接表会变得很臃肿,内存和耗时都会上升。建议在构建时先检查是否已存在,或者最后对每个节点的邻居列表去重。

我给某团队做过一次代码评审,他们的城市数据有几十万条公交记录,构建邻接表时没有去重,内存直接飙到几个G,程序秒崩。后来改成set去重,内存降了一个数量级。这个优化非常值得做。

4.3 场景变体:从地铁最少换乘到公交步行组合

城市路径问题不只有地铁一种场景。假设你要结合公交和步行,BFS还能用吗?

答案是可以用,但要小心建模。如果你把“每走一步路”和“每坐一站公交”都当作代价为1的边,BFS可以算出最少步数,但这个结果可能不符合直觉。例如你从小区门口走到公交站算1步,公交坐10站算10步,方案“走2步+公交10站”的总步数是12,可能比“走5步+公交5站”的10步更差,但实际通勤中人们更看重总时间或总距离,而不是步数。

所以当场景变成“最短时间”或“最短距离”,就不能再用普通BFS,需要给边赋值权重,改用Dijkstra算法,或者对图做分层处理。我在很多项目里见过团队硬用BFS处理带权图,调了好几天参数都调不对,最后发现方向错了。BFS适合的是“所有选择代价相同”的场景,比如最少换乘次数、最少红绿灯路口数,这些场景天然符合无权图假设。

如果想扩展BFS处理时间最优,可以把“换乘”和“乘坐”拆成不同层,层间边权不同,但BFS本身不支持边权,所以更实际的是直接切换算法。这一点必须想清楚。

5. 经验之谈:BFS的边界与失效场景

5.1 带权图与最短距离:BFS的边界在哪

上一节提到了带权图问题,这里再展开说明。很多面试题喜欢问“BFS能不能求最短路径”,答案是在无权图中可以,在带权图中不可以。

举一个反例:假设从起点S到终点T有两条路,第一条经过3条边但每条边长度为100公里,第二条经过4条边但每条边长度为1公里。BFS会认为经过3条边的方案更优,因为它的步数少,但真实距离是300公里对4公里,明显第二条更优。这就是边权破坏BFS最短性的典型场景。

所以记住这个判断标准:只有所有边的代价相等,BFS才能保证最短。如果题目给的是红绿灯次数、换乘次数、操作步骤数这类统一计数的指标,BFS是首选;如果给的是距离、时间、金钱这类连续变量,老实改用Dijkstra。

5.2 双向BFS:城市规模变大时的优化手段

当城市路网规模变大,比如某个区域有几千个站点,BFS从起点单向扩散到终点,可能需要扩展很多无关节点。一个非常实用的优化是双向BFS。

双向BFS的思路是:同时从起点和终点分别做BFS,各扩展一层,当两个搜索方向相交时,就找到了最短路径。它的优势在于把搜索范围从“一个大圆”变成“两个小圆”,相交时两个圆半径之和就是路径长度,实际扩展的节点数大幅减少。

我在处理一个模拟城市路网的项目时,单向BFS平均要扩展800多个节点才能找到路径,双向BFS只需要扩展不到100个节点,速度快了将近一个数量级。实现上需要维护两个queue、两个visited、两个predecessor,每次选择节点数少的一侧扩展,当发现当前节点出现在另一侧visited中时,把两条路径拼接起来即可。

但双向BFS有一个前提:图的边必须是双向的,或者至少反向也能走。地铁线路通常是双向的,适用。如果是单向道路,比如某些公交单行线,双向BFS就需要谨慎处理反向搜索的合法性。

5.3 几点实操心得

BFS这块代码虽然短,但真要在工程里用好,有几个心得值得记录。

第一,不要假设数据是干净的。真实城市数据里经常有孤立站点、重复线路、缺失连接。BFS前先对邻接表做基础校验,比如每个节点的邻居数是否合理、是否存在自环,能省很多调试时间。

第二,predecessor字典在路径很长时可能占用不少内存。如果只需要最短步数而不需要完整路径,可以去掉predecessor,省下一块空间。反过来,如果确实需要路径,务必在记录路径时注意方向反转,这是最容易出bug的位置。

第三,编号和名称的问题。城市站点可能有中文名、英文名、数字编号,BFS完全不在乎节点是什么类型,只要哈希一致、可比较就行。但如果用自定义对象作为节点,记得实现好等价性和哈希函数,否则visited集合会出现严重Bug。

第四,调试BFS最好用可视化。我写BFS时习惯把每个节点的层数打印出来,或者在小图上用表格标注访问顺序,这样能直观看到“扩展层”是否合理。等确认逻辑正确后再接入真实大数据,不要在复杂图上直接调试。

最后再分享一个小技巧:BFS的队列初始化和visited标记要始终绑在一起。无论怎么变体,只要发现某段代码里出现了“先入队后标记”或者“入队前不标记”,多半就是性能瓶颈或死循环的源头。把“入队即标记”当成一条铁律,能省去很多排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询