算法与数据结构 / 图论 · graph 算法合集 / 最短路径:拆解 Dijkstra 待审核 2 / 7
shortest-path · 最短路径

最短路径:拆解 Dijkstra

从一个点出发,到图里每个 vertex 的 shortest path 如何计算?先理解 relaxation 这个唯一的内核动作——不断用「经某点中转是否更近」把 distance estimate 向真实值收紧;再单步运行 Dijkstra 的 greedy:每次 extract-min 当前未 settle 的 vertex、将它 settle、relax 它的所有出边。随后看几种 case:有 cycle 不影响正确性,而一条 negative-weight edge 就能让「取出即 settle」的 greedy 误锁一个次优值——这正是 Dijkstra 的适用边界。最后为它加上「方向感」:把排序键从 gg 换成 f = g + h 即得到 A*。每节都能选源点、单步运行,观察 dist[] / prev[] 表与 priority queue 的实时变化。底层的 priority queue 与「乐观界」分别由 binary heap分支限界 两个系列展开,可作为延伸阅读。

五节:relaxation 与 greedy → 动手跑 Dijkstra → 各种 case → A* 搜索 → 真实应用。

1 · 核心动作:relaxation 与 greedy 为何成立

整个 shortest path 算法,内核只有一个动作——relaxation。理解它之后,Dijkstra、Bellman-Ford 都只是「按什么顺序、relax 多少次」的不同包装。

给每个 vertex 维护一个 dist[v]——「目前已知的、从源到 v 的 shortest path distance」。它始终是真实 shortest path distance 的一个 estimate 上界:一开始除源点为 0 外全是 ∞,随着我们发现更短的走法而单调下降,最终收敛到真实值。

relax 一条 edge uvu\to v(weight w)就是问一句:「先到 u 再走这条 edge,会不会比现在已知的到 v 更近?」

即:若 dist[u] + w < dist[v],就更新 dist[v] = dist[u] + w,并记 prev[v] = u(记住这一步是从 u 过来的,便于回溯整条路径)。

下面这张小图从 A 出发。点「relax 下一条 edge」会按一个固定顺序逐条尝试 relax(顺序见下方说明),观察 dist[] 如何从 ∞ 逐步收紧。留意 D 会被收紧两次(先 6 再 4)——这正体现了「estimate 单调下降、逐步逼近真实值」。

本节 relax 顺序 = 按 Dijkstra「settle 顺序」ACBDA \to C \to B \to D 依次处理每个 vertex 的出边(一个 vertex settle 后才 relax 它的出边)。按这个恰当的顺序,每条 edge 各 relax 一遍就够了

图 1-1 · relaxation 的逐条执行。按固定顺序尝试每条边,可观察 dist 如何从无穷逐步收紧,其中 D 被收紧两次。

1.1 · Dijkstra 的正确性前提:非负边权

relaxation 本身不挑顺序;但若按某个恰当的顺序来,每个 vertex 只需被 settle 一次。Dijkstra 的顺序是:每次在所有未 settle 的 vertex 里,extract-min dist 当前最小的那个,断言它的 dist 已经是真实 shortest path 值,从此 settle。

归纳论证(直觉版):设此刻 extract-min 得到的未 settle vertex 是 u,它的 dist[u] 在所有未 settle vertex 里最小。任何「还没走到 u」的其他路径,都得先经过另一个未 settle 的 vertex x 才能拐到 u;而到 x 的距离 ≥ dist[u](因为 u 是当前最小),再加上后续非负的 edge weight,只会更大,不可能比 dist[u] 还小。所以没有更短的路了——u 的 dist 已确定,可 settle。

「非负」是前提条件。上面那句「后续 edge weight 只会让路更长」,一旦出现 negative-weight edge 就不再成立:绕远路、再走一条 negative edge,反而可能更短。于是「当前最小就一定是最终最短」的断言失效——这正是各种 case 一节 negative weight 反例的根源。

2 · 动手跑 Dijkstra:extract-min · settle · relax 出边

Dijkstra 就是「按 dist 从小到大的顺序 settle 每个 vertex」。一条主循环,反复做到没有可 settle 的 vertex 为止:

其一,从 priority queue(所有未 settle 且可达的 vertex)里 extract-min dist 最小的那个 u。

其二,把 u 标记为 settled——断言 dist[u] 就是真实 shortest path 值,从此不再改。

其三,relax u 的所有出边:对每条 u→v,若 dist[u]+w < dist[v] 就更新 v(并记 prev[v]=u)。

其四,回到第一步。

下面用一个含 cycle 图(A→B→C→D→A 是一个有向 cycle)。选源点,点「下一步」逐次 settle 一个 vertex。注意:settled vertex(绿)被 cycle 上的回边指到时,relaxation 会被直接跳过——这就是有 cycle 也不出错的原因(§3 详谈)。可选一个目标点,结束时高亮整条 shortest path。

图 2-1 · Dijkstra 的单步执行。可选源点,逐次 settle 一个顶点,绿色为已确定、黄色为待定。

**结束条件:**priority queue 空了(没有「未 settle 且可达」的 vertex)。此时每个可达 vertex 的 dist 都已 settled, prev[] 连起来就是一棵 shortest path tree——从任意 vertex 沿 prev 回溯到源,就是它的 shortest path。仍是 ∞ 的 vertex 表示从源不可达。

3 · 有环与负权两种情形

把 Dijkstra 的适用边界讲清楚:什么会让它出错,什么不会。核心是同一句话——「取出即 settle,从此不再改」这个 greedy,在非负 weight 下是定理,遇上 negative weight 就不再成立。

3.1 · 有 cycle 图:仍然正确

很多人担心「图里有 cycle, Dijkstra 会不会沿着 cycle 绕圈圈、死循环或重复更新?」——不会。关键就在「settled vertex 不再被更新」:一个 vertex 一旦被 extract-min 并 settle,后面任何沿 cycle 回头指向它的 edge,在 relax 时都会被直接跳过。每个 vertex 只会被 settle 一次,主循环最多跑 V 次。

§2 的那张含 cycle 图(A→B→C→D→A 形成 cycle)。点单步跑完,留意 verdict 里 D→A 这条回边在 relax 时如何被「A 已 settled,跳过」挡掉——cycle 没有造成任何麻烦。

图 3-1 · 含环图上的 Dijkstra:回边在 relax 时不会让已 settle 的顶点复活。

**结论:**有 cycle(只要 weight 非负)对 Dijkstra 毫无影响。cycle 上的回边要么 relax 不动(更远),要么指向一个 settled vertex 而被跳过。正确性、复杂度都不变。

3.2 · negative-weight edge:greedy 失效的反例

现在换一张带 negative-weight edge 的小图:SU=2S\to U=2SV=5S\to V=5VU=4V\to U=-4。直接观察:到 U 有两条路——直接 SUS\to U2,或者经 SVUS\to V\to U5 + (-4) = 1绕路反而更近,可 Dijkstra 会给出 2。

点「单步」运行 Dijkstra,观察它如何在第一步之后就把 U 以 dist=2 settle;等轮到 V extract-min、想用 VU=4V\to U=-4 把 U 收紧到 1 时,U 已经 settled——更新被跳过。右边是 Bellman-Ford 算出的真实 shortest path,作为对照。

图 3-2 · 负权边下 Dijkstra 的反例:U 在第一步即被以较大的 dist 确定,之后再无机会被负权边收紧。

失效的根因:Dijkstra extract-min U 时,它的 dist=2 是当时未 settle vertex 里最小的,于是断言「不可能更短」并 settle。这个断言依赖「后续的 edge weight 都非负、只会让路更长」。可 negative-weight edge VU=4V\to U=-4 恰恰让「先到更远的 V (5)、再走一条 negative edge」总代价更小 (1)。前提被打破,断言失效,U 就被过早锁定在次优值 2。

那 negative weight 图怎么算?更换算法:Bellman-Ford(把所有 edge 反复 relax V−1 轮,不挑顺序、不「settle」,因此 negative edge 也能逐步传播开),。SPFA 是它的队列优化,最坏仍是 O(VE)O(VE);Johnson 解的是全源最短路(重赋权后每点跑一次 Dijkstra),与本页的单源语境不同。代价是更慢:Bellman-Ford 为 O(VE)O(VE),而 Dijkstra 在二叉堆下是 O((V+E)logV)O((V+E)\log V)、朴素数组扫描(本页实现)是 O(V2)O(V^2),稠密图上后者反而更优。另外:若图里有可达的 negative cycle,shortest path 无下界(沿 cycle 绕圈无限变小),此时根本无解——Bellman-Ford 能检测到这种情况。

3.3 · Bellman-Ford 是怎么算的

上面只用了 Bellman-Ford 的结果,此处把它的运行过程也展开。它的思路比 Dijkstra 更朴素:不挑顺序、不 settle 任何 vertex,只是把每一条 edge 都 relax 一遍,如此重复 V−1 轮。为什么 V−1 轮就够?任何 shortest path 最多 V−1 条 edge;每跑一轮,至少能让最短路上「再往前一跳」正确地确定下来,所以 V−1 轮后所有 dist 必然收敛。最后再多跑一轮做检测:若还有 edge 能把某点 relax 得更小,说明存在可达 negative cycle(沿它绕圈 dist 无限变小,shortest path 无下界)。

为什么恰好是 V−1 轮?

**shortest path 最多 V−1 条 edge。**没有 negative cycle 时,总存在一条 simple 的 shortest path(不重复经过 vertex)——重复经过就夹了一个 cycle,而非负 cycle 删掉只会让路不变长。零权环的存在使得非简单的最短路也可能同样短,所以此处要的是「存在」而非「一定是」。简单路径最多经过 V 个 vertex,所以最多 V−1 条 edge。这就是 V−1 的来源。

**每跑一轮,正确值至少沿最短路往前推进一跳。**一轮 = 把所有 edge 各 relax 一遍。可归纳证明:第 k 轮结束后,凡是「最短路 ≤ k 跳」的 vertex,dist 必已正确。归纳步:到 v 的 (k+1) 跳最短路 suvs\to \dots \to u\to v,其前缀 sus\to \dots \to u 是到 u 的 k 跳最短路(最短路的前缀也是最短路),由假设 u 在第 k 轮后已对;第 k+1 轮 relax 到 uvu\to v 时就把 v 定对。

一轮内的 edge 顺序无所谓。上面只用到「u 在上一轮末已正确」,与本轮内谁先 relax 无关。顺序走运能一轮推进好几跳(提前收敛),最差也保证推进一跳。所以 V−1 轮是不依赖顺序的最坏情况保证。

**V−1 是「紧」的,省不掉。**把图摆成一条链 sabs\to a\to b\to \dots(V 个点排成一线),若每轮恰好按 edge 逆序 relax,每轮只推进一跳,到链尾就得跑满 V−1 轮。

**第 V 轮只为检测 negative cycle。**V−1 轮后必收敛;再多跑一轮若还能 relax 得更小,说明存在「需 ≥V 跳才更短」的走法——≥V 跳必重复 vertex 即绕了负 cycle。对到本节例子:3 个 vertex → V−1 = 2,而本例的扫描顺序恰好是 SUS\to USVS\to VVUV\to U,第 1 轮结束时 dist 已是最终值(实测 {S:0, U:1, V:5}),第 2 轮扫下来没有任何更新。V1V-1 是与顺序无关的上界:最坏顺序下每轮只推进一跳,才需要跑满。

用的还是 §3 那张 negative weight 图(源 = S)。点「下一步」逐条 edge 观察 relax——重点关注 VU=4V\to U=-4:它会在某一轮把 dist[U] 从 2 收紧到 1。因为 Bellman-Ford 不 settle,U 的值之后仍可继续被改小,这正是它能处理 negative weight、而 Dijkstra 不能的根本区别。

图 3-3 · Bellman-Ford 在同一张负权图上的逐边 relax,可观察负权边如何把估计值传播开。

对比: Dijkstra 靠「按 dist 从小到大 extract-min、取出即 settle」省下重复计算,代价是假设 edge weight 非负;Bellman-Ford 放弃这个假设,改用「把每条 edge 反复 relax V−1 轮」换取对 negative weight 的正确性,代价是 O(V·E) 更慢。

4 · 给 Dijkstra 装上「方向感」:A* 搜索

Dijkstra 没有「目标」概念——它从源点向四周均匀扩展,把每个点都按 dist 逐一 settle,哪怕目标就在正右方,它也会先把左边、上下方一大片都扩展完。A* 只改一处:extract-min 时排序的键,从 gg(已走代价)换成 f = g + h——加上一个对「还差多远到目标」的乐观估计 hh。于是搜索被朝目标方向牵引,扩展的格子骤减。

同一份代码,只差一个 hh

  • g(n) = 从源到 n 已经走的真实代价(和 Dijkstra 的 dist 一样)。
  • h(n) = 从 n 到目标还要走多远估计(此处用曼哈顿距离,网格四向移动下永不高估)。
  • f(n) = g(n) + h(n) =「经过 n 的整条路」总代价的乐观估计——优先队列就按它排。

h0h \equiv 0 代入,f = g,A* 退化成 Dijkstra。下面那个下拉就是在这两者之间切。

格子里:中间大字 = ff,左上 = gg,右上 = hh蓝框 = 源,红框 = 目标。点空格子可加/拆墙,实时重算。

图 4-1 · A* 在网格上的搜索:格子中间是 f、左上是 g、右上是 h,蓝框为源、红框为目标,可点空格加墙或拆墙并实时重算。

4.1 · 为什么 A* 仍然给出最短路?(前提:hh admissible)

A* 不是「贪心直奔目标」那种可能得到次优解的启发式搜索——它保证找到的就是最短路,只要 hh 满足下面的条件:

**admissible(可采纳):h(n)h(n) 永不高估到目标的真实剩余代价。**曼哈顿距离在四向网格上恰好是「没有墙时的最短步数」,有墙只会让真实代价更大——所以它永不高估,合格。若像本页实现这样带 closed 集且不重开已扩展的节点,还需要更强的 consistent(一致 / 单调):h(u)c(u,v)+h(v)h(u) \le c(u,v) + h(v)h(goal)=0h(\text{goal}) = 0,否则某个点可能带着次优的 gg 进入 closed 后再无机会修正。单位代价的四向网格上,曼哈顿距离两条都满足。

直觉:因为 hh 不高估,f = g + h 也就不会高估「经过 n 的整条路」。当目标第一次从 open 里 extract-min 出队时,它的 ff(此刻 = gg,因为 h(goal)=0)是队里最小的;任何还在 open 里的点,其乐观估计 ff 都 ≥ 它,绕过去只会更长——所以这条就是最短的。

h 越准越省,但高估就会出错。h0h \equiv 0(Dijkstra)永不高估、最稳健,但没有方向感、扩展最多;hh = 真实剩余代价时 A* 沿直线直达目标、不浪费扩展(但此值通常算不出)。若把 hh 放大、刻意高估,搜索更快但可能得到次优路径——这就是 weighted A* 的取舍:以一定最优性换取速度。

这与「优先队列」相关联:A 和 Dijkstra 底层那个「每次取 f 最小」的容器,就是一个 binary heap。换个角度看,A 也是分支限界(best-first branch & bound)的一个特例——f = g + h 就是「经过 n 的整条路代价」的那个乐观界(bound)**。

5 · 应用实例:导航为什么会「重新规划路线」

地图导航的内核就是最短路:边权 = 路段通行时间,Dijkstra 找从起点到终点最快的那条路。真正实用的一步是动态重路由——前方一堵车(某段耗时飙升),最短路立刻换一条绕行路线。选起点 / 终点,再点任意路段切换「拥堵」(耗时 ×3),看蓝色路线和 ETA 实时改变。

每条路段标着通行分钟数;蓝色是当前最快路线,底部显示 ETA(总分钟)。点一条路段把它标红 = 拥堵(耗时 ×3)。若它在当前路线上,Dijkstra 往往会绕开它另选一条——这正是高德 / Google Maps 实时避堵的简化版。

图 5-1 · 最短路在导航、社交与任务图等场景中的映射。

5.1 · 同一方法的其他场景

「在带权图上求代价最低的一条路」这件事,远不止地图导航:

游戏 / 机器人寻路 →

NPC、AGV、扫地机的路径几乎都是 A*(网格 + 启发式)——Dijkstra 装上「朝目标的方向感」,少扩展大量格子。

网络路由协议

OSPF / IS-IS 这类链路状态协议在每台路由器上跑 Dijkstra,按链路开销算最短转发路径;RIP 用 Bellman-Ford。

负权环 = 套利机会

把汇率取负对数 logr-\log r 当边权,Bellman-Ford 找到的负权环就是一圈换下来「钱变多」的外汇套利路径——取正对数会让盈利的一圈变成正环,符号正好反了。

依赖与关系链

社交「最短认识链」(BFS / Dijkstra)、路网与地图导航的最短 / 最快路线、技能树与任务图的最低成本通关路线。(构建系统的编译顺序求的是拓扑序而非最短路,见拓扑排序。)

重路由的原理很直接:每次路况改变,就重跑一遍 Dijkstra。可回到 §2 看它如何 extract-min / settle / relax,或去 A* 看如何加速。

**底层关联:A / Dijkstra 每次「取 f 最小」依赖 binary heap;而把 f = g + h 看成「整条路代价的乐观界」,A 即是分支限界(branch & bound)的一个特例。

6 · 🔗 相关链接

**它真实跑在哪里。*地图导航:高德 / Google Maps 的路径规划,地基是 Dijkstra(工程上叠加 Contraction Hierarchies / A 把全国路网查询压到毫秒级)。

网络路由协议:OSPF、IS-IS 这类链路状态协议在每台路由器上跑 Dijkstra 算最短转发路径;RIP 用 Bellman-Ford。

游戏与机器人:NPC 寻路、机械臂 / AGV 路径几乎都是 A*(网格 + 启发式)。

其他:社交网络的最短关系链、构建系统的依赖 / 编译顺序、外汇套利检测(Bellman-Ford 找负权环)。