Lesson 0411 · 最短路 I:Dijkstra

④ 数据结构与算法 — 第 11 课

先记住这三句,剩下的都是解释它们
  1. 松弛:如果「绕道 u 再去 v」比现在的 dis[v] 更近,就改掉 dis[v]。
  2. 每一轮:在还没定下来的点里,挑 dis 最小的那个,宣布它的距离已经是最终答案(叫「盖章」)。
  3. 为什么敢盖章:边权都 非负,任何绕路都只会更远,所以此刻最小的那个不可能再被别的路线超越。
这三句里只有第 3 句需要证明,本课第 3 节会用一个反证把它钉死。剩下的内容全是「怎么写才快」。
上节课:BFS 求「最少几条边」,因为每条边一样贵,谁先到谁最近。
这节课:边有了路费。这时「步数少」不等于「便宜」,BFS 立刻失效 —— Dijkstra 就是为这件事准备的。

1. 为什么 BFS 不管用了

三点:1→2 路费 5,1→3 路费 1,3→2 路费 1。

1 --5--> 2
1 --1--> 3 --1--> 2

到 2 号:直达 5,绕一趟 1+1 = 2   ← 绕路更便宜

BFS 只数边:它一步就能踩到 2 号,于是报「2 号 = 1 步」,然后就再也不回头看 2 号了 —— 可真正便宜的路要绕两跳。错的原因是 BFS 有个隐含前提:多走一条边一定不便宜。边权非负这个前提还在,但「每条边一样贵」没了。

所以要换一个衡量标准:不算「走了几步」,算「花了多少钱」。

2. 松弛:本课唯一的新动作

整个 Dijkstra 只有两个动作,第一个就是松弛(relax)。名字很唬人,做的事一句话:

从 u 往 v 走一条边,如果 dis[u] + w(u,v) 比现在的 dis[v] 更小,就把 dis[v] 改成这个更小的值。
if (dis[u] + w < dis[v]) dis[v] = dis[u] + w;      // 就这一行

dis[x] 的含义始终是:「目前已知的」从起点到 x 的最短距离。注意「目前已知」四个字 —— 它一开始是 INF(∞,代表「还不知道怎么去」),靠松弛一次次变小。松弛不会让答案变差,只会让它更接近真相。

手推一遍:四个点

图(有向、带权):

1 --7--> 2 --1--> 4
1 --2--> 3 --100--> 4
         3 --3--> 2

起点 1。每一轮做两件事:① 挑未定点里 dis 最小的 → 盖章;② 拿它去松弛它的出边。

轮次挑谁(dis)松弛过程这一步之后
初始——1=0 2=∞ 3=∞ 4=∞
11(0)1→2:0+7=7 → 2=7
1→3:0+2=2 → 3=2
1=0 2=7 3=2 4=∞
23(2)3→2:2+3=5 < 7 → 2=5
3→4:2+100=102 → 4=102
1=0 2=5 3=2 4=102
32(5)2→4:5+1=6 < 102 → 4=61=0 2=5 3=2 4=6
44(6)没有出边1=0 2=5 3=2 4=6

答案:1=0、2=5、3=2、4=6。

请注意第 2 轮:3→4 那条边权 100,先把 4 写成 102;第 3 轮发现「绕 2 号过去只要 6」,又把它改小了。这就是松弛的意义 —— 允许先给一个粗糙的答案,再慢慢改好。

这里就是最容易卡住的地方:第 2 轮为什么要挑 3 号,而不是接着处理 2 号?
因为规则是「每轮挑当前 dis 最小的未定点」,此刻 3 号是 2、2 号是 7。挑最小的那个,是为了让它尽早定型(下一节解释为什么这样挑就敢定型)。

3. 为什么敢「盖章」

把「盖章」写成代码是一次 done[u] = true,之后再也不会修改 u 的距离。这个操作听起来很危险 —— 万一以后出现更便宜的路呢?

用一句话回答:以后出现的任何路,都要先经过一个还没盖章的点;而那些点的距离都不小于 u,边权又非负,所以绕过去只会更贵。

把它写成反证,只有三行:

  1. 假设 u 被盖章时 dis[u] 不是最终答案,真实最短距离 D < dis[u]。
  2. 真实路径上必然有一个还没盖章的点 x(起点没盖章时就是它),而且路是「起点 … x … u」,所以 dis[x] ≤ 到 x 的距离 ≤ D < dis[u]。
  3. 可「dis 最小」的未定点是 u,任何未定点 x 都该有 dis[x] ≥ dis[u] —— 矛盾。所以 D 不可能小于 dis[u]。

这个证明里唯一用到「边权非负」的地方是第 2 步的「到 x 的距离 ≤ D」:因为从 x 往后走到 u 的路费加起来 ≥ 0,所以到 x 一定不比起点的总距离 D 更贵。边权一旦可以是负数,这句话就崩了 —— 这正是 0412 要讲的内容(届时你会看到 Dijkstra 在负权图上悄悄给出错误答案)。

三个常见的困惑

困惑回答
为什么不能每轮挑「任意」未定点?挑大的会让证明第 3 步失效,可能盖错章。挑最小是唯一安全的选法,不是优化。
盖章之后它的 dis 会不会被改小?不会。代码里根本没机会改它 —— 别人的松弛只会写没盖章的点。这也是为什么它敢「永久」。
注意区分:数组中一个点的值可能被多条边反复更新(还没盖章时),这和「盖章后再改」是两件事。
那为什么堆里会出现同一个点好几次?因为它还没盖章,每次被松弛变小时都入了一次堆。旧的那条记录作废,出堆时用一个 if 跳过(第 7 节)。

4. 朴素版:先写对,再写快

#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;
int n, m, s;
vector<vector<pair<int,int>>> g;      // g[u] = {v, w},g[u][i].first = 边终点

void dijkstra_naive() {
    vector<int> dis(n + 1, INF);
    vector<bool> done(n + 1, false);          // 是否已盖章
    dis[s] = 0;

    for (int round = 1; round <= n; round++) {
        // ① 挑:未定点里 dis 最小的
        int u = -1;
        for (int i = 1; i <= n; i++)
            if (!done[i] && dis[i] != INF && (u == -1 || dis[i] < dis[u])) u = i;
        if (u == -1) break;                  // 剩下的点都到不了,提前结束
        done[u] = true;                      // ② 盖章

        // ③ 松弛它的所有出边
        for (auto [v, w] : g[u])
            if (dis[u] + w < dis[v]) dis[v] = dis[u] + w;
    }

    for (int i = 1; i <= n; i++)
        cout << (dis[i] == INF ? -1 : dis[i]) << (i == n ? '\n' : ' ');
}

拿 P4779 的样例逐轮跑一遍

这就是上面这份程序实际打印出来的过程(样例:4 6 1,边 1→2 (2)、2→3 (2)、2→4 (1)、1→3 (5)、3→4 (3)、1→4 (4)):

轮次挑谁(dis)松弛过程这一步之后
初始——1=0 2=∞ 3=∞ 4=∞
11(0)1→2:2 < ∞ → 2=2
1→3:5 < ∞ → 3=5
1→4:4 < ∞ → 4=4
1=0 2=2 3=5 4=4
22(2)2→3:2+2=4 < 5 → 3=4
2→4:2+1=3 < 4 → 4=3
1=0 2=2 3=4 4=3
34(3)没有出边,什么都不动1=0 2=2 3=4 4=3
43(4)3→4:4+3=7,不比 3 小 → 不动1=0 2=2 3=4 4=3

输出 0 2 4 3 —— 和样例答案一致。
顺带一个细节:第 3 轮挑的是 4 号(dis=3),比 3 号(dis=4)先盖章。这并不是「按编号顺序」,而是「按 dis 从小到大」。后面用堆优化,靠的就是这个顺序。

5. 朴素版慢在哪:一次实测

朴素版每一轮都要「扫描一遍所有点找最小」,n 轮就是 O(n²)。实测(随机图、边权 1–1000、本机 g++ 16.2.0 -O2,只计算法本身、不含读入):

n(点数)m(边数)朴素版堆优化
2 00010 0003.5 ms0.2 ms
10 00050 000207.7 ms1.3 ms
100 000(P4779 的上限)200 00030 178 ms(30.2 秒)9.1 ms

看清楚最后一行:n 只从 1 万涨到 10 万,朴素版从 0.2 秒涨到 30 秒(平方增长),而 P4779 的时限是 1000 ms。朴素版在 P4779 上必超时,必须换数据结构。

6. 堆优化:把「找最小」交给堆

瓶颈在于每轮都要扫一遍点找最小。找最小值这件事,堆天生就拿手:

#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;
int n, m, s;
vector<vector<pair<int,int>>> g;
long long ans = 0;          // 按题目要求加和(P4779 要这个)

void dijkstra_heap() {
    vector<int> dis(n + 1, INF);
    // 小根堆:存 {距离, 点}。priority_queue 默认是大根堆,用 greater 反过来
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
    dis[s] = 0;
    pq.push({0, s});
    while (!pq.empty()) {
        auto [du, u] = pq.top(); pq.pop();
        if (du > dis[u]) continue;            // 过期记录,跳过(懒删除)
        // 这里 u 就是当前未定点里 dis 最小的,直接盖章
        for (auto [v, w] : g[u]) {
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                pq.push({dis[v], v});         // 变小的点入堆
            }
        }
    }
    // 输出 n 个数(P4779 的数据保证 s 能到所有点)
    for (int i = 1; i <= n; i++) cout << dis[i] << (i == n ? '\n' : ' ');
}

效果:同一张 n = 105、m = 2×105 的图,朴素版 30.2 秒,堆优化 9.1 ms —— 差约 3300 倍。
复杂度从 O(n²) 变成 O(m log m)(每条边最多产生一次入堆,每次进出堆 log)。

7. 懒删除:if (du > dis[u]) continue;

一个点可能被松弛变小好几次,每变一次就入堆一次,于是堆里躺着同一号的好几个旧距离。比如 2 号先以 7 入堆,后来变成 5 又入堆一次 —— 等 5 那条出堆时,7 那条还躺在里面。

有两种处理办法:

  1. 勤快删除:真的去堆里把旧的那条删掉。麻烦(堆不提供按值删除)。
  2. 懒删除:不管它,等它出堆时检查一下 —— 如果堆里的值比现在的 dis[u] 大,说明它是过期货,continue 跳过。
auto [du, u] = pq.top(); pq.pop();
if (du > dis[u]) continue;      // 过期的旧记录,丢掉
这一行不能省,也不能写成 if (du != dis[u]) continue; 之外的花样。它的作用是:
① 跳过旧记录 → 保证每个点只被「盖章」一次;
② 没有它,同一个点会被反复处理,最坏变回 O(n·m)。

8. 语法拆解(这一段专门给「看不懂那些尖括号」的人)

写法含义 / 为什么要这么写
pair<int,int>一个二元组,first / second 两个成员。这里存 {距离, 点}。
注意 pair 比较大小是「先比 first,相等再比 second」—— 所以把距离放 first,堆才会按距离排序。
priority_queue<T, Container, Compare>三个参数:元素类型、底层容器(写 vector<T>)、比较器。第三个参数决定「谁在顶上」。
greater<pair<int,int>>默认是大根堆(最大的在顶上),加 greater 变成小根堆。greater 来自 <functional>,被 bits/stdc++.h 带进来了。
auto [du, u] = pq.top();结构化绑定(C++17),等价于 int du = pq.top().first; int u = pq.top().second;,但短得多。
for (auto [v, w] : g[u])范围 for + 结构化绑定:把 g[u] 里每个 pair 拆成 v 和 w。auto 前面的 & 省掉也能跑(只是多复制一次)。
const int INF = 0x3f3f3f3f;10 亿多一点,约等于 ∞,且两个相加不会溢出 int(0x7fffffff 相加就会炸)。
g[u].push_back({v, w});邻接表:g[u] 存所有从 u 出发的边。{v, w} 直接构造一个 pair 塞进去。

9. 完整模板(P4779 可直接用)

#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;
int n, m, s;
vector<vector<pair<int,int>>> g;

int main() {
    ios::sync_with_stdio(false);     // 关掉 C 与 C++ 的流同步
    cin.tie(nullptr);                // 切断 cin 与 cout 的绑定
    cin >> n >> m >> s;
    g.assign(n + 1, {});
    for (int i = 0; i < m; i++) {
        int u, v, w; cin >> u >> v >> w;
        g[u].push_back({v, w});
    }

    vector<int> dis(n + 1, INF);
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
    dis[s] = 0;
    pq.push({0, s});
    while (!pq.empty()) {
        auto [du, u] = pq.top(); pq.pop();
        if (du > dis[u]) continue;
        for (auto [v, w] : g[u])
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                pq.push({dis[v], v});
            }
    }
    for (int i = 1; i <= n; i++) cout << dis[i] << (i == n ? '\n' : ' ');
    return 0;
}

读 2×105 行数据要多快?实测

P4779 的输入有 m ≤ 2×105 行「u v w」。同一份文件,三种读法各跑三遍取最快(本机 g++ 16.2.0 -O2,三者的读入结果完全一致):

读入方式耗时结论
cin >> x351.9 ms默认开着流同步,慢
scanf118.1 ms比开同步的 cin 快约 3 倍
cin + ios::sync_with_stdio(false) + cin.tie(nullptr)33.9 ms最快,而且只多两行

P4779 的时限是 1000 ms。用 cin 却不关同步,等于白扔 300 多毫秒(三分之一的时间);关掉之后这 300 毫秒全拿回来。

10. 坑点清单

11. 小测

Q1. Dijkstra 每一轮为什么要挑「未定点里 dis 最小的」?
Q2. 「松弛」这个词具体指什么操作?
Q3. 堆优化版里 if (du > dis[u]) continue; 的作用是?
Q4. 朴素版 O(n²) 与堆优化 O(m log m),P4779(n ≤ 105、m ≤ 2×105)该用哪个?
Q5. 为什么 Dijkstra 在带负权边的图上会给出错误答案?

12. 动手练习

  1. P4779 【模板】单源最短路径(标准版) —— 用上面的模板就能过,n ≤ 105、m ≤ 2×105。注意要加那两行快读。
  2. P3371 【模板】单源最短路径(弱化版) —— 和 P4779 的区别在输出:到不了的点要输出 231-1,而且可能有重边。判 dis[i] == INF 时别用错的常数。
  3. 选做:P1629 邮递员送信 —— 要算「去 + 回」。想一想为什么把边反向再跑一次 Dijkstra 就能拿到「回」的距离。
卡住时的自查顺序
  1. 样例过了吗?先对着第 4 节那张表手推一遍样例,看是不是挑错了点。
  2. 输出格式和题目要求一致吗(几个数、空格、换行、不可达怎么写)。
  3. dis 的初始值和 dis[s] = 0 有没有写错。
  4. 有没有 if (du > dis[u]) continue;。
  5. 邻接表用的是 g[u] 还是把边存反了。
  6. 数据范围算过没有(int 够不够、堆里会不会太多)。
  7. 都不对:把那组出错的数据和你的代码一起贴过来,我帮你缩小范围。

本课小结

下一课预告
0412 最短路 II —— 把「边权非负」这个前提拆掉:Bellman-Ford(不盖章,全体反复松弛)、SPFA(它的队列版,也是负环检测的常客)、Floyd(五行算出任意两点距离)。配题 P3385。
这一课里「盖章」的三个困惑,下一课会用负权反例再打一遍 —— 那时候你会真正明白第 3 节那句「边权非负」有多要命。
这一课的代码都在本机用 g++ -O2 -std=c++17 编译运行过;第 4 节那张逐轮表就是程序实际打印的过程,第 5、9 节的耗时也是真实测量值。哪一句还不明白 —— 比如「松弛为什么不会让答案变差」「堆里为什么会有同一个点」「pair 为什么先比 first」—— 直接问我,我可以从更小的地方讲起。

← 上一课:并查集 · 课程对照表

书籍 《算法 (第4版)》第 4.4 节(最短路径)· 《算法导论》第 24 章(单源最短路径)

在线 OI-Wiki · 最短路 · 洛谷 P4779