☰
分支限界法实现单源最短路径的实战指南
2026/10/14 1:16:13 网站建设 项目流程

接到一个模拟项目的最短路模块时,我一开始没想到会被“单源最短路径”这种基础问题绊住。需求本身不复杂:给定一个带权有向图和一个起点,求出从起点到其他所有节点的最短距离,并且为了后续扩展,希望保留搜索过程、支持自定义路径约束。教科书里的经典标号法当然能解决距离问题,但临时要加“禁止经过某节点”“最多走几步”这类限制时,改造起来特别别扭。我最后换成了分支限界法,用优先级队列做搜索树展开,反而把整个框架盘活了。这个过程里踩了不少坑,也把分支限界法的原理彻底摸了一遍。这篇文章就把我的实现思路、完整代码和实测中的边界问题都写出来,给同样需要手动实现最短路算法的读者一个可以直接参考的版本。

分支限界法看起来比贪心标号法复杂,但核心思想很直观:把所有可能的路径当成一颗搜索树,按某种优先级去探索分支,同时用一个下界函数判断哪些分支不可能成为最优解,直接砍掉。单源最短路径问题恰好是这个算法最典型的应用场景之一。下面我从搜索视角一步步拆解。

1. 先从搜索视角看单源最短路:为什么分支限界法值得一试

1.1 问题的本质是路径枚举,不是距离计算

很多人第一次接触单源最短路径时,会把注意力放在“怎么算距离”上。实际上,这个问题本质上是一个路径枚举问题:你要从起点出发,沿着边走,枚举出所有可能到达目标点的路径,再从里面挑最短的。麻烦的是路径数量可能指数级增长。一个稍微稠密一点的图,暴力枚举根本跑不完。

可优化的地方在于:并不是所有路径都值得枚举。如果我已经从起点走到节点A,累计花费是10,而另一个分支走到节点B的累计花费只有2,那么A这条分支在后续搜索中大概率不占优势。尤其是在边权非负的情况下,任何从A继续往下走的路径,总花费都不可能低于10。于是我们就可以优先扩展B分支,并延迟甚至丢弃A分支。这就是“限界”这两个字的来历。

分支限界法做的正是这件事:它维护一个待探索状态集合,每次从中挑一个“看起来最有希望”的状态展开,同时用限界函数把明显没希望的状态排除掉。把这个问题看成一棵搜索树,每个节点代表“当前在哪个顶点、已经走了多远”,边代表“走哪一条邻接边”,那么找最短路就是在搜索树里找一条总权重最小的根到目标节点的路径。

1.2 分支限界法在算法家族里的定位

分支限界法和回溯法、贪心标号法经常被放在一起比较。回溯法也遍历搜索树,但它主要通过约束函数剪掉“不可能满足约束”的分支,更适合解决八皇后、数独这类约束满足问题。分支限界法更像为优化问题量身定制:它不仅剪掉不可行的分支,更通过上下界判断哪些分支“不可能优于当前已知解”,从而大幅减少搜索量。

和教科书里常见的贪心标号法相比,分支限界法的思路更“通用”。经典标号法之所以高效,是因为它利用了最短路径的贪心选择性质:每次从未确定节点中选距离最小的,就可以直接确定它。这在非负权图上确实成立,但一旦问题变成“多起点”“带路径约束”“限制边数”,贪心性质就可能被破坏,改起来非常痛苦。

分支限界法没有这个包袱。它的正确性只依赖于两个条件:搜索树完整覆盖所有可能路径,限界函数给出的估计不会高于实际最优值。只要这两点满足,算法一定能找到最优解。所以在你需要扩展或者定制搜索规则时,分支限界法反而比经典标号法更稳。

2. 算法设计:状态定义、搜索树与限界函数的确定

2.1 状态表示:当前节点、累计代价与路径

我一开始设计状态时走了点弯路,只存了“当前节点”和“累计代价”,结果后面做路径还原时还得回头找父节点,非常麻烦。后来改成直接在一个状态里包含完整路径,虽然内存开销大了一点,但每一步搜索发生了什么一目了然。

一个状态结构包含三部分:

  • node:当前所在的顶点编号;
  • cost:从起点到当前顶点的累计路径长度;
  • path:从起点到当前顶点的完整节点序列。

初始状态是 (source, 0, [source])。搜索时从待探索集合里取出一个状态,然后遍历 node 的所有出边 (next, weight),生成子状态 (next, cost + weight, path + [next])。这样整个搜索树就通过状态转移自然展开了。

注意一个问题:直接在每个状态里存 path 列表,会导致大量的列表拷贝。在小图或者演示场景里完全没问题,但图的规模上了十几万节点以后,这个拷贝开销会成为明显的性能瓶颈。我在后文会给出更省内存的 prev 数组方案,这里先保留 path 是因为它直观。

2.2 限界函数:在非负权图里,当前距离就是下界

分支限界法的灵魂是限界函数。它要在搜索树的每个节点处,估算“从这个节点继续走下去,最终能拿到的最优解的下限”。如果这个下限已经大于已知的可行解,那么这条分支可以直接剪掉。

单源最短路径有一个天然的限界函数,就是当前累计距离本身。前提是:图的边权必须非负。只要所有边权大于等于0,从当前节点到目标节点的剩余路径长度一定不可能小于0,所以当前状态最终产生的任何完整路径总长度,都不可能低于当前 cost。换句话说,当前 cost 就是一个一致且可行的下界。

这个性质太重要了。它意味着我不需要设计任何复杂的启发式函数,直接把 cost 当作优先级排序依据就行。相比之下,很多其他优化问题要费尽心思设计松弛下界,比如旅行商问题里的最小生成树下界,最短路问题在这个维度上简直是一块福地。

不过也得记住:这个下界成立的前提是 w >= 0。一旦出现负权边,当前距离就不再是最终路径总长度的下界了。比如当前 cost 是10,后面跟着一条 -5 的边,最终路径总长度可能变成5,直接打破限界函数的单调性。所以分支限界法用于最短路时,默认图必须是非负权图。我测试时专门加了一个校验,遇到负权边直接抛异常,避免算法在错误前提下运行。

2.3 最小堆做分支选择:为什么是best-first而不是BFS

分支限界法里“分支”的顺序决定了搜索效率。常见策略有FIFO、LIFO和优先队列三种。FIFO更像广度优先,一层层展开;LIFO更像深度优先,一路走到底再回头。这两种都不太适合最短路问题,因为它们没有利用“哪条分支更有希望”的信息。

更自然的策略是best-first:每次都从待探索集合里选 cost 最小的状态来扩展。这样算法会优先探索那些已经走了较短距离的分支,尽快接近目标区域。实现方式就是用最小堆。C++里是 priority_queue,Python里是 heapq。每次入堆一个状态,堆顶就是当前所有待扩展状态里 cost 最小的那个。

为什么这样选分支能保证最优?可以这样理解:在非负权图里,堆顶状态的 cost 是所有待扩展状态中最小的。如果某个节点第一次被从堆顶弹出,那么不可能还有一条更短的路径在等着它——因为任何更短路径的当前前缀状态 cost 会更小,会先被弹出,从而更早把该节点入堆。这就是分支限界法能保证全局最优的核心逻辑。

我记得第一次写的时候,直接在普通队列上做层扩展,效果就是一个低配版BFS。换成最小堆之后,算法收敛速度快了几个数量级,这才算是真正用上了分支限界法的力量。

3. 完整可复现的实现:从邻接表到分支限界主流程

3.1 数据结构选型

写实现之前先定数据结构。图我用邻接表,Python里最直观的写法是list[list[tuple[int, int]]],每个元素(neighbor, weight)表示一条出边。对有向图来说,邻接表比邻接矩阵省空间,遍历出边也方便。无向图就每条边加两次。

优先级队列用heapq。它的元素是(cost, counter, node, path)。这里 counter 是一个自增序号,用来保证即使两个状态的 cost 和 node 完全相同,元组也能比较大小,不会因为 path 是列表而报错。

路径我最后保留了两种实现思路:path版方便观察搜索过程,prev版适合大规模图。下面这段代码为了可读性,先用path版,后面会说怎么改成prev版。

3.2 核心代码实现与逐步解释

下面是我在模拟项目里跑通过的版本。函数输入起点编号source和邻接表adj,返回两个数组:dist是从起点到各节点的最短距离,paths是对应的最短路径。

import heapq def branch_and_bound_shortest_paths(source, adj): n = len(adj) dist = [float("inf")] * n dist[source] = 0 counter = 0 heap = [(0, counter, source, [source])] best_paths = [None] * n best_paths[source] = [source] while heap: cost, _, node, path = heapq.heappop(heap) # 已经找到更短路径的状态直接丢弃 if cost > dist[node]: continue # 第一次弹出时,dist[node] 一定等于 cost,这里就是最短路径 best_paths[node] = path for nxt, weight in adj[node]: if weight < 0: raise ValueError("分支限界法处理单源最短路要求边权非负") new_cost = cost + weight if new_cost < dist[nxt]: dist[nxt] = new_cost counter += 1 heapq.heappush(heap, (new_cost, counter, nxt, path + [nxt])) return dist, best_paths

这一段代码看起来短,但有几个地方值得仔细想。

第一个是if cost > dist[node]: continue。因为同一个节点可能通过多条路径被入堆,较早入堆的那条可能后来发现不是最优。当它从堆里弹出来时,dist[node]已经被更新成更小值,那么这条状态就废了,直接跳过。

第二个是路径更新逻辑。只有new_cost < dist[nxt]时才入堆,所以进入堆的状态一定对应了一次有效的距离更新。初始状态 cost 等于 dist[source] 的0,所以不会被误杀。

第三个是终止判定。这个版本会一直跑到堆空,求的是从起点到所有节点的最短路径。如果只关心单个终点,可以在if node == target时直接 return,能省不少计算量。

3.3 路径还原的另一种写法:从列表拷贝到prev数组

如果图很大,每个状态都带path + [nxt]的拷贝会让内存迅速膨胀。改成prev数组不存储完整路径,只在发现更短路径时记录“这个节点是从哪个节点走过来的”。

import heapq def branch_and_bound_shortest_paths_prev(source, adj): n = len(adj) dist = [float("inf")] * n prev = [-1] * n dist[source] = 0 counter = 0 heap = [(0, counter, source)] while heap: cost, _, node = heapq.heappop(heap) if cost > dist[node]: continue for nxt, weight in adj[node]: if weight < 0: raise ValueError("分支限界法处理单源最短路要求边权非负") new_cost = cost + weight if new_cost < dist[nxt]: dist[nxt] = new_cost prev[nxt] = node counter += 1 heapq.heappush(heap, (new_cost, counter, nxt)) return dist, prev

回溯路径时,从目标点一路沿着 prev 往前走到 source,再反转列表就行:

def recover_path(prev, target): path = [] cur = target while cur != -1: path.append(cur) cur = prev[cur] return path[::-1]

这个方案的空间开销小很多,缺点是路径信息没有随搜索状态走,无法在搜索中途判断“是否经过某个节点”。如果你的需求恰好是带节点限制的路径搜索,那还是得用 path 版本。

3.4 复杂度分析:比经典标号法慢多少

从复杂度上看,分支限界法的核心操作和标号法类似,都是“从堆里取最小状态 + 扫描邻接边 + 更新距离入堆”。最坏情况下,一个节点的距离可能被更新多次,导致入堆次数增加,所以实际运行时间会比教科书里的经典贪心标号法常数略大。

这里我给一个粗略对比:

方法典型时间复杂度常数扩展性
经典标号法O((V+E) log V)小低,依赖贪心性质
分支限界法最坏 O(E log V) 以上中高,可改状态与限界函数

注意,标号法能够严格做到每个节点只从堆里弹出一次,是因为它在节点第一次确定后就不再放松其出边。而分支限界法如果不加额外标记,某些节点可能被重复弹出多次,虽然多数时候这些重复状态会被cost > dist[node]拦掉,但堆里已经产生了额外元素。所以纯性能上,简单最短路问题用标号法更划算。分支限界法的价值更多体现在灵活性和教学意义上。

4. 实测案例、边界情况与剪枝策略

4.1 一个六点图的手推过程

理论讲再多,不如跑一个手推的例子。考虑下面这个有向图,共有6个节点,编号0到5,起点是0,终点是5:

起点终点权重
014
022
121
135
238
2410
342
356
453

用分支限界法跑一遍。初始堆里只有(0, 0, [0]),弹出0,扩展出节点1的cost 4和节点2的cost 2,堆里现在是(2, [0,2])和(4, [0,1])。

下一个弹出的是cost 2的节点2,继续扩展出节点3的cost 10和节点4的cost 12。此时堆里有(4, [0,1])、(10, [0,2,3])、(12, [0,2,4]),于是下一轮弹出了cost 4的节点1。

从节点1扩展,走回节点2的cost是5,但dist[2]已经是2,所以这条分支被丢弃。走到节点3的cost是9,比之前的10更小,因此更新dist[3]并重新入堆。这时候堆顶是cost 9的节点3。

节点3弹出后,走到节点4的cost为11,比之前的12更小,更新;走到节点5的cost为15,先记下。堆里马上弹出cost 11的节点4,再走到节点5的cost变为14,又更新一次。最终堆顶是cost 14的节点5,它就是最短距离。

手推下来你会发现,分支限界法并不是“一股脑枚举所有路径”,而是每次选择当前累计距离最小的状态去扩展。上面的搜索过程中,节点3的路径从10修正到9,节点5从15修正到14,体现了分支限界法允许“反悔”的特性。这和贪心标号法有些相似,但思想来源完全不同。

4.2 负权边为什么会让分支限界法瞬间失效

很多人会把分支限界法和“贪心标号法”混淆,认为只要每次选最小状态就能处理所有图。我被这个误解坑过一次。某次改边界测试时,我故意把一条边的权重改成负数,想看看算法会不会报错。结果它没报错,只是安静地输出了错误答案。

原因不复杂。假设起点0到节点1有一条直接路径cost 5,同时起点0到节点2有一条路径cost 10,节点2到节点1有一条 -8 的边。那么 0->2->1 的总cost只有2,明显优于0->1的5。但按照排序,cost 5的节点1会先于cost 10的节点2被弹出,一旦弹出时不做保护,dist[1]就被错误确定成5。等到cost 10的节点2弹出,再走负权边到节点1时,发现可以更新,但前面已经太晚了。

更极端的情况是负权回路:走一圈总权重还是负数,cost可以无限降低,堆底永远最小,搜索根本停不下来。所以这个算法必须限定为非负权图。我的代码里直接对每条边做检查,遇到负权就抛异常,宁可让程序崩溃,也不能在错误前提下继续跑。

4.3 自环、平行边和不可达节点的处理

工程实现里除了负权边,还有几类边界情况值得注意。

自环是指从当前节点指向自己的边。在非负权图中,自环只会让 cost 变大,永远不可能通过new_cost < dist[nxt]的检查,所以会自动被丢弃,不影响正确性。

平行边是指两个节点之间有多条权重不同的边。处理逻辑也很简单:邻接表里每条边都会被扫描,权重小的那条自然会更早更新距离,权重大的因为无法通过< dist[nxt]判断,被自动忽略。

不可达节点是另一个问题。如果起点无法到达某个节点,dist会保持inf,paths保持None。代码不会报错,但使用者需要自己判断:如果dist[target]是inf,说明不存在路径。这个处理比返回 -1 更语义化,推荐保留。

5. 我在项目落地时踩过的坑

5.1 入堆就标记visited,这是最经典的错误

我第一版实现里,在把子状态入堆时就顺手标记了“已访问”。这看起来能防止重复入堆,其实是严重的正确性问题。原因在于,一个节点第一次被入堆时的路径未必是最优的。比如上面六点图里的节点3,最早被入堆是通过 0->2->3 的cost 10,但后来通过 0->1->3 的cost 9入堆。如果我按第一次入堆就标记 visited,那后续cost 9的分支就会被剪掉,永远得不到正确结果。

正确的做法是“弹出时确认”:从堆里弹出一个状态时,如果它的 cost 已经大于 dist[node],说明这个状态已经被更好的路径取代,直接丢;否则才把它当成最优结果处理。记住一句话:入堆只是候选,出堆才是定论。

5.2 路径拷贝与tuple比较的细节

Python 的 heapq 在比较元组时会从左到右逐个比较元素。我最初写的是(cost, node, path),一开始没问题,但后来遇到 cost 相等、node 也相等的情况,Python 就会继续比较 path 这个列表。两个列表可以比较,但这里比较的是路径内容,既不必要,还容易引发“混合类型比较不了”之类的错误。

后来我改成(cost, counter, node, path),用自增序号避免元组比较到 path。虽然 counter 看起来是多余信息,但它保证了所有元组都能安全排序,是一个很实用的小技巧。

还有一个性能点:path + [nxt]每次都会复制整个路径。如果你只是要求输出一条最短路径,用 prev 数组更合适。只有当你需要实时知道当前路径是否经过某特定节点时,path 版本的便利性才值得付出内存代价。

5.3 后续扩展:为什么我保留了这个搜索框架

项目做到后来,需求果然加了两条:一是禁走某些中间节点,二是限制路径最多只能有K条边。经典标号法在这两个需求面前非常尴尬,因为它的整个推导都建立在“自由松弛”上,加约束等于重写。

分支限界法就从容很多。禁走中间节点,只需要在扩展状态时检查目标节点是否在禁用集合;限制边数,只需要在状态里多加一个steps字段,入堆时如果steps + 1 > max_steps就跳过。状态结构、优先队列、剪枝逻辑完全不用动。

这也是我最终愿意把分支限界法放进项目核心的原因。很多算法在最坏情况下理论性能不是最优,但灵活性和可扩展性带来的工程收益,往往比理论常数更值钱。如果你只是要一个最短路函数,直接上标号法;如果你预感到后面会不断加约束,分支限界法这个搜索框架会让你省下大量重构时间。

最后再分享一个个人经验:不管用什么算法实现单源最短路,一定要准备一组可以手算的小图测试用例,比如上面那个六点图。算法跑得再漂亮,没有一个手推结果做基准,出了问题你根本分不清是数据错了、算法错了,还是代码写错了。

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

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

立即咨询