← 目录 / 算法文档 · 模块十五 图论基础 / 15.3 Dijkstra 最短路

15.3 Dijkstra 最短路

只关心"从一个点出发"的最短路,不需要 Floyd 那种任意两点都算一遍的高昂代价——贪心地每次确定当前最近的点,像水波一样一圈圈地把距离确定下来。

本页目录
① 为什么需要 Dijkstra:单源最短路不需要 O(n³)

15.2 节的 Floyd 能算出任意两点之间的最短距离,但代价是 O(n³),点数一多就跑不动了。如果只关心"从某一个固定的起点出发,到图中所有点的最短距离"(单源最短路),完全没必要把"任意两点"都算一遍——Dijkstra 算法正是针对这个更常见的场景设计的,用贪心的思路把复杂度降到 O((V+E) log V),能应付大得多的图。

14.4 节的 A* 其实已经用到了 Dijkstra 的核心机制,只是多加了一个启发函数 h(n);如果把 A* 的 h(n) 恒定设为 0,就直接退化成了本节的 Dijkstra——这一点在 14.4 节 ⑥ 已经提过。

② 核心思想:贪心 + 优先队列

Dijkstra 的过程可以概括成一句话:每次从"还没确定最短距离"的点里,挑出当前已知距离最小的那个,把它的距离"钉死"为最终答案,再用它去尝试缩短它邻居的距离。这是一个贪心策略——之所以"距离最小的点,它的距离就已经是最终答案",是因为如果还存在更短的路径,那条路径必然要经过某个"距离更大"的中间点,而这与"当前它是距离最小的点"矛盾。

优先队列(小顶堆)维护"当前已知距离",每次弹出距离最小的点,正好实现这个贪心顺序。

步骤做什么
初始化起点距离设为 0,其余所有点距离设为 INF;把 (0, 起点) 放入优先队列
取出每次从优先队列弹出"距离最小"的点 u
松弛枚举 u 的所有邻居 v,如果"经过 u 到 v"比 v 当前记录的距离更短,就更新 v 的距离,并把新距离连同 v 一起push进队列
结束优先队列为空时,所有能到达的点都已经算出最短距离
③ 图解:从 A 出发,一步步确定每个点的最短距离

还是 15.1、15.2 节的示例图(A-B=4A-C=1B-C=2B-D=5C-D=8),从 A 出发跑一遍 Dijkstra,全程追踪优先队列的弹出顺序:

优先队列弹出顺序(dist(A)=0 为初始状态)
弹出松弛了谁dist[A]dist[B]dist[C]dist[D]
(0, A)B:0+4=4;C:0+1=1041
(1, C)B:1+2=3<4;D:1+8=9<∞0319
(3, B)D:3+5=8<90318
(4, B)过时记录,dist[B] 已经是 3,4>3,直接跳过
(8, D)无更优更新0318
(9, D)过时记录,dist[D] 已经是 8,9>8,直接跳过
最终 dist[A]=0, dist[B]=3, dist[C]=1, dist[D]=8——和 15.2 节 Floyd 算出的结果完全一致(Floyd 那一节最终矩阵的第一行正是 0, 3, 1, 8),说明两种算法确实是在求解同一个问题,只是思路和适用场景不同。表格里两行"过时记录"是因为 BD 都被更新过不止一次:(4,B) 是第一次从 A 松弛时push进队列的,后来被经过 C 中转的更短路径 (3,B) 取代,但旧的 (4,B) 依然留在队列里,弹出时需要识别并跳过。
④ 完整代码
C++ · Dijkstra 最短路
1const int INF = 0x3f3f3f3f;
2vector<pair<int,int>> adj[MAXN]; // adj[u]:(邻居, 边权),见 15.1 节邻接表
3int dist_[MAXN];
4
5void Dijkstra(int s, int n)
6{
7 fill(dist_ + 1, dist_ + 1 + n, INF);
8 dist_[s] = 0;
9 priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq; // ★ greater<>:小顶堆,默认是大顶堆
10 pq.push({0, s});
11
12 while (!pq.empty())
13 {
14 auto [d, u] = pq.top(); pq.pop(); // ★ 每次弹出当前距离最小的点
15 if (d > dist_[u]) continue; // 过时的队列项,跳过(和 14.4 节 A* 同样的技巧)
16 for (auto [v, w] : adj[u])
17 {
18 if (dist_[u] + w < dist_[v]) // 松弛:经过 u 到 v 是否比目前记录的更短
19 {
20 dist_[v] = dist_[u] + w;
21 pq.push({dist_[v], v});
22 }
23 }
24 }
25}
💡
和 A* 几乎是同一份代码:对照 14.4 节的模板会发现,Dijkstra 就是把 A* 里"入队时额外加上 H(v)"这一步去掉——第 21 行 push 进队列的就是纯粹的 dist_[v],没有加任何启发式估计。第 15 行"跳过过时记录"的技巧也是两者共用的,这正呼应了 ① 提到的"Dijkstra 是 A* 在 h=0 时的特例"。
⑤ 复杂度分析

每条边最多会导致一次 push(松弛成功时),所以队列里总共最多有 O(E) 个元素;每次 poppush 操作在优先队列上的代价是 O(log E)(约等于 O(log V))。总时间复杂度是 O((V+E) log V)——比 Floyd 的 O(V³) 在稀疏图上快得多,能应对点数上万甚至更多的图。

对比项Floyd(15.2 节)Dijkstra(本节)
解决的问题任意两点之间的最短距离从一个固定起点到所有点的最短距离
时间复杂度O(V³)O((V+E) log V)
能否处理负权边可以(只要没有负权环)不能(见 ⑥ 陷阱)
适合场景点数较少,需要全源最短路点数较多,只需要单源最短路
⑥ 适用条件与常见陷阱
Dijkstra 不能处理负权边:贪心策略成立的前提是"距离最小的点,它的最终距离已经不会再变小了"——但如果存在负权边,一个后弹出的、当前距离较大的点,仍然有可能通过一条包含负权边的路径,把已经"钉死"的点的距离再压得更低,贪心的前提被破坏。如果图中存在负权边(但没有负权环),需要改用 Bellman-FordSPFA 算法(本书未展开,可自行查阅)。
忘记 greater<>,优先队列默认是大顶堆:C++ 的 priority_queue 不加额外模板参数时,默认每次弹出最大的元素——Dijkstra 需要每次拿到距离最小的点,必须显式传入 greater<> 改成小顶堆,或者把距离取相反数塞进默认的大顶堆(两种写法效果一样,前者更直观)。
漏掉"跳过过时记录"的判断,程序不会出错但会变慢:和 14.4 节 A* 的陷阱完全一样——同一个点可能因为被多次松弛而在队列里留下好几条记录,其中大部分已经过时。如果漏掉第 15 行的判断,程序依然能算出正确答案(因为对同一个点重复松弛不会破坏结果),但会白白多做很多次没有意义的松弛操作,实际运行时间明显变慢。
🏆
接下来:15.2、15.3 节解决的都是"点到点"的最短路问题。15.4、15.5 节要换一个角度——不再关心"最短距离",而是关心"用最少的边把所有点连通"(最小生成树,Kruskal 和 Prim 两种做法)以及"给一张有向无环图里的任务排出一个不违反依赖关系的执行顺序"(拓扑排序)。