④ 数据结构与算法 — 第 11 课
dis[v] 更近,就改掉 dis[v]。dis 最小的那个,宣布它的距离已经是最终答案(叫「盖章」)。三点: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 有个隐含前提:多走一条边一定不便宜。边权非负这个前提还在,但「每条边一样贵」没了。
所以要换一个衡量标准:不算「走了几步」,算「花了多少钱」。
整个 Dijkstra 只有两个动作,第一个就是松弛(relax)。名字很唬人,做的事一句话:
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=∞ |
| 1 | 1(0) | 1→2:0+7=7 → 2=7 1→3:0+2=2 → 3=2 | 1=0 2=7 3=2 4=∞ |
| 2 | 3(2) | 3→2:2+3=5 < 7 → 2=5 3→4:2+100=102 → 4=102 | 1=0 2=5 3=2 4=102 |
| 3 | 2(5) | 2→4:5+1=6 < 102 → 4=6 | 1=0 2=5 3=2 4=6 |
| 4 | 4(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」,又把它改小了。这就是松弛的意义 —— 允许先给一个粗糙的答案,再慢慢改好。
dis 最小的未定点」,此刻 3 号是 2、2 号是 7。挑最小的那个,是为了让它尽早定型(下一节解释为什么这样挑就敢定型)。
把「盖章」写成代码是一次 done[u] = true,之后再也不会修改 u 的距离。这个操作听起来很危险 —— 万一以后出现更便宜的路呢?
用一句话回答:以后出现的任何路,都要先经过一个还没盖章的点;而那些点的距离都不小于 u,边权又非负,所以绕过去只会更贵。
把它写成反证,只有三行:
dis[u] 不是最终答案,真实最短距离 D < dis[u]。dis[x] ≤ 到 x 的距离 ≤ D < dis[u]。dis[x] ≥ dis[u] —— 矛盾。所以 D 不可能小于 dis[u]。这个证明里唯一用到「边权非负」的地方是第 2 步的「到 x 的距离 ≤ D」:因为从 x 往后走到 u 的路费加起来 ≥ 0,所以到 x 一定不比起点的总距离 D 更贵。边权一旦可以是负数,这句话就崩了 —— 这正是 0412 要讲的内容(届时你会看到 Dijkstra 在负权图上悄悄给出错误答案)。
| 困惑 | 回答 |
|---|---|
| 为什么不能每轮挑「任意」未定点? | 挑大的会让证明第 3 步失效,可能盖错章。挑最小是唯一安全的选法,不是优化。 |
盖章之后它的 dis 会不会被改小? | 不会。代码里根本没机会改它 —— 别人的松弛只会写没盖章的点。这也是为什么它敢「永久」。 注意区分:数组中一个点的值可能被多条边反复更新(还没盖章时),这和「盖章后再改」是两件事。 |
| 那为什么堆里会出现同一个点好几次? | 因为它还没盖章,每次被松弛变小时都入了一次堆。旧的那条记录作废,出堆时用一个 if 跳过(第 7 节)。 |
#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' : ' ');
}
这就是上面这份程序实际打印出来的过程(样例: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=∞ |
| 1 | 1(0) | 1→2:2 < ∞ → 2=2 1→3:5 < ∞ → 3=5 1→4:4 < ∞ → 4=4 | 1=0 2=2 3=5 4=4 |
| 2 | 2(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 |
| 3 | 4(3) | 没有出边,什么都不动 | 1=0 2=2 3=4 4=3 |
| 4 | 3(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 从小到大」。后面用堆优化,靠的就是这个顺序。
朴素版每一轮都要「扫描一遍所有点找最小」,n 轮就是 O(n²)。实测(随机图、边权 1–1000、本机 g++ 16.2.0 -O2,只计算法本身、不含读入):
| n(点数) | m(边数) | 朴素版 | 堆优化 |
|---|---|---|---|
| 2 000 | 10 000 | 3.5 ms | 0.2 ms |
| 10 000 | 50 000 | 207.7 ms | 1.3 ms |
| 100 000(P4779 的上限) | 200 000 | 30 178 ms(30.2 秒) | 9.1 ms |
看清楚最后一行:n 只从 1 万涨到 10 万,朴素版从 0.2 秒涨到 30 秒(平方增长),而 P4779 的时限是 1000 ms。朴素版在 P4779 上必超时,必须换数据结构。
瓶颈在于每轮都要扫一遍点找最小。找最小值这件事,堆天生就拿手:
#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)。
if (du > dis[u]) continue;一个点可能被松弛变小好几次,每变一次就入堆一次,于是堆里躺着同一号的好几个旧距离。比如 2 号先以 7 入堆,后来变成 5 又入堆一次 —— 等 5 那条出堆时,7 那条还躺在里面。
有两种处理办法:
dis[u] 大,说明它是过期货,continue 跳过。auto [du, u] = pq.top(); pq.pop();
if (du > dis[u]) continue; // 过期的旧记录,丢掉
if (du != dis[u]) continue; 之外的花样。它的作用是:O(n·m)。
| 写法 | 含义 / 为什么要这么写 |
|---|---|
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 塞进去。 |
#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;
}
P4779 的输入有 m ≤ 2×105 行「u v w」。同一份文件,三种读法各跑三遍取最快(本机 g++ 16.2.0 -O2,三者的读入结果完全一致):
| 读入方式 | 耗时 | 结论 |
|---|---|---|
cin >> x | 351.9 ms | 默认开着流同步,慢 |
scanf | 118.1 ms | 比开同步的 cin 快约 3 倍 |
cin + ios::sync_with_stdio(false) + cin.tie(nullptr) | 33.9 ms | 最快,而且只多两行 |
P4779 的时限是 1000 ms。用 cin 却不关同步,等于白扔 300 多毫秒(三分之一的时间);关掉之后这 300 毫秒全拿回来。
<:写成 if (dis[u] + w <= dis[v]) 会在边权为 0 的图上反复入堆,慢到超时。done[];堆优化版靠 if (du > dis[u]) continue; 就够,两个都写也不会错(多一层保险)。dis[i] == INF,否则会打印 1061109567 这种数字(P3371 要求输出 231-1)。dis 要设成 INF,不是 0;只有 dis[s] = 0。push_back,两条边都存,跑的时候取小的那条。long long。if (du > dis[u]) continue; 的作用是?n ≤ 105、m ≤ 2×105。注意要加那两行快读。dis[i] == INF 时别用错的常数。dis 的初始值和 dis[s] = 0 有没有写错。if (du > dis[u]) continue;。g[u] 还是把边存反了。int 够不够、堆里会不会太多)。if (dis[u] + w < dis[v]) 就更新)+ 盖章(每轮挑 dis 最小的未定点)。O(n²):n = 105、m = 2×105 时 30.2 秒,过不了 P4779(时限 1000 ms)。O(m log m):同一张图 9.1 ms;靠 priority_queue 小根堆 + 懒删除 if (du > dis[u]) continue;。dis[s]=0、不关流同步白白多花 300 ms、拿它去跑负权图。g++ -O2 -std=c++17 编译运行过;第 4 节那张逐轮表就是程序实际打印的过程,第 5、9 节的耗时也是真实测量值。哪一句还不明白 —— 比如「松弛为什么不会让答案变差」「堆里为什么会有同一个点」「pair 为什么先比 first」—— 直接问我,我可以从更小的地方讲起。