0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1339,日期见页头。两边不一致时信原站。
题目描述
有一个 n 个点 m 条边的无向图,请求出从 s 到 t 的最短路长度。
输入格式
第一行四个正整数 n, m, s, t。
接下来 m 行,每行三个正整数 u, v, w,表示一条连接 u, v,长为 w 的边。
输出格式
输出一行一个整数,表示答案。
说明/提示
【数据范围】
对于 100% 的数据,1 ≤ n ≤ 2500,1 ≤ m ≤ 6200,1 ≤ w ≤ 1000。
【样例说明】
5 → 6 → 1 → 4 为最短路,长度为 3 + 1 + 3 = 7。
时限 1 秒,内存 128 MB。
输入输出样例
输入
7 11 5 4 2 4 2 1 4 3 7 2 2 3 4 3 5 7 5 7 3 3 6 1 1 6 3 4 2 4 3 5 6 3 7 2 1
输出
7
7 个点、11 条边、起点 5 号、终点 4 号 —— 和本章第 2 步手算的那张图是同一个规格。
⚠ 而这 11 行里藏着两组重边:2 4 2 / 2 4 3,以及 7 2 2 / 7 2 1。
1★ 关键的一步:一条无向边 = 两条方向相反的有向边
// P1339 [USACO09OCT] Heat Wave G —— ★ 这一版就能 AC//// 无向图上的单源最短路,只问 s 到 t 这一个数。//// ★ 关键的一步只有一句话:**一条无向边 = 两条方向相反的有向边**,存两遍。// 本章讲的、证的、写的全部是有向图上的 Dijkstra,而这道题**一个结论都不用改** ——// 因为「无向」不是 Dijkstra 的前提,它只是建图时怎么存的问题。// (题单那句「写完想一想为什么无向图不影响这一章的任何结论」,答案就是这一句。)//// ⚠ 而这句话反过来说才是这一页的重点:// **本章那三句证明里,一次都没有用到「边是有向的」——**// 用到的只有「边长非负」(naive.cpp 顶上那段反证里那个 `≥ 0`)。// ⇒ 所以换成无向图,证明一个字不用动。//// 一道三十秒的算术题:n ≤ 2500、w ≤ 1000 ⇒ 任何一条简单路径 ≤ 2500 × 1000 = 2.5 × 10⁶// ⇒ **int 绰绰有余**,连 0x3f3f3f3f 都富余 424 倍。// (和隔壁 [P3371] 正好凑一对:那道题同一个常量差 2.02 倍不够用。)//// 复杂度 O(m log n):顶格 m = 6200,随便跑。朴素 O(n²) = 6.25 × 10⁶ 也照样过。
#include <bits/stdc++.h>using namespace std;typedef pair<int, int> PII;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s, t; if (!(cin >> n >> m >> s >> t)) return 0; vector<vector<PII>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); g[v].push_back({u, w}); // ★ 就是这一行 —— 无向边存两遍 }
vector<int> dist(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; dist[s] = 0; q.push({0, s}); while (!q.empty()) { auto [d, u] = q.top(); q.pop(); if (d > dist[u]) continue; for (auto [v, w] : g[u]) if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); } } cout << dist[t] << '\n'; return 0;}点「运行 ▶」看结果
整份代码和本章的 fast.cpp 只差一行:
g[u].push_back({v, w});
g[v].push_back({u, w}); <- 就是这一行
本章第 5 步那三句反证里,一次都没有用到「边是有向的」。
它用到的只有一处:「从 x 走到 u 的那一段长度 ≥ 0」——
也就是边长非负。(本章的 naive.cpp 顶上那段注释专门盯着这个 ≥ 0。)
⇒ 所以「无向」根本不是 Dijkstra 的前提,它只是建图时怎么存的问题。 证明一个字不用改,代码多一行。
顺带一道三十秒的算术题:n ≤ 2500、w ≤ 1000 ⇒ 任何一条简单路径 ≤ 2 500 000,
而 0x3f3f3f3f = 1 061 109 567 —— 富余 424 倍,int 绰绰有余。
★ 这和隔壁 P3371 正好凑成一对:同一个常量,那道题差 2.02 倍不够用。 ⇒ 「INF 该写多大 / 要不要 long long」永远是一道拿这道题的题面乘一遍的算术题。
2⚠ 第一个错法:漏了那一行,把无向图当成有向图
// ✗ 错法①:把无向图当成有向图 —— 每条边只存了一遍//// 这是这道题唯一一处**真正的**新东西,也是最容易漏的一行。// 漏了它,算的就是另一张图上的最短路:题面给的 `u v w` 被当成「只能从 u 走到 v」。//// ⚠ 它不会崩、不会慢,只会安静地给出一个**偏大**的答案(少了一半的边,路只会更长)// —— 或者干脆走不到(打出 0x3f3f3f3f 那个 INF)。// ★ 「答案恒 ≥ 正解」这句话是能一眼判出来的([第 26 章那条](/sol/p1220/):// 给出的是一个合法方案 ⇒ 恒 ≥ 最优),度量程序里真数了一遍,300 轮一次没反过来。
#include <bits/stdc++.h>using namespace std;typedef pair<int, int> PII;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s, t; if (!(cin >> n >> m >> s >> t)) return 0; vector<vector<PII>> g(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> dist(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; dist[s] = 0; q.push({0, s}); while (!q.empty()) { auto [d, u] = q.top(); q.pop(); if (d > dist[u]) continue; for (auto [v, w] : g[u]) if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); } } cout << dist[t] << '\n'; return 0;}点「运行 ▶」看结果
| 300 轮 | |
|---|---|
| 「只存一遍边」被抓 | 189 |
| ★ 其中答案比正解小的轮数 | ★ 0 |
这一版是在一张边更少的图上求最短路(每条边只剩一个方向), 而它给出的每一条路在原图上都真的走得通 ⇒ 它给的是一个合法方案 ⇒ ★ 答案恒 ≥ 正解(第 26 章那条判据)。
实测 300 轮,一次都没有反过来。 ⚠ 而本书也在这件事上被打回来过两次(第 23 章、第 27 章 P2015 的「只会多算」都是错的)—— 所以这一条仍然是量出来的,不是推出来的。
3⚠⚠ 而官方样例把它放过去了 —— 原因能一句话说清
样例的最短路是 5 → 6 → 1 → 4,用到三条边:5 6 3、6 1 1、1 4 3。
⇒ 这三条边在输入里的方向,恰好就是要走的方向(5 6 写在前、6 1 写在前、1 4 写在前)。
所以「只存一遍」在这组样例上一分不扣,照样打 7。
★ 这是「它过了样例的第三种原因:这组样例在结构上问不出这个问题」的又一次 —— 不是概率低,是那三条边恰好都不用反着走。
4★★ 第二个错法:邻接矩阵直接覆盖 —— 而这道题的题面一个字没提重边
// ✗ 错法②:邻接矩阵存图,重边**直接覆盖**(没取 min)//// ★ 这道题的题面**一个字都没提重边** —— 而它的**官方样例里就有两组**:// 2 4 2 / 2 4 3 (4 号和 2 号之间两条,一条 2 一条 3)// 7 2 2 / 7 2 1 (7 号和 2 号之间两条,一条 2 一条 1)//// ⇒ 这和隔壁 [P3371](/sol/p3371/) 正好凑成一对:// 那道题**专门补了一句 Update** 提醒有重边,可它的官方样例里一条重边都没有(样例挡不住);// 这道题**题面一个字没提**,官方样例却当场就有两组。// ⇒ ★★ **「题面提没提」和「样例挡不挡得住」是两件独立的事**,两个方向的例子现在都有了。//// ⚠ 而重边只在「后来那条更长」时才真的害人(第一组是 2 → 3,正好中招)。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;const int MAXN = 2505;static int g[MAXN][MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s, t; if (!(cin >> n >> m >> s >> t)) return 0; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) g[i][j] = INF; for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u][v] = w; // ⚠ 直接覆盖,后来的那条赢了 g[v][u] = w; } vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[s] = 0; for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j; if (u == -1 || dist[u] == INF) break; vis[u] = 1; for (int v = 1; v <= n; v++) if (g[u][v] != INF && dist[u] + g[u][v] < dist[v]) dist[v] = dist[u] + g[u][v]; } cout << dist[t] << '\n'; return 0;}点「运行 ▶」看结果
| 题面提了重边吗 | 官方样例里有重边吗 | 样例挡住这个错法了吗 | |
|---|---|---|---|
| P3371 | ★ 提了(2022 年专门补的 Update) | ★ 没有 | 没挡住 |
| P1339(这道题) | ★ 一个字没提 | ★ 有两组 | ★ 仍然没挡住 |
这道题样例里的两组是 2 4 2 / 2 4 3 和 7 2 2 / 7 2 1 ——
可它们都不在那条最短路上,于是覆不覆盖都一样。
⇒ ★ 读题时「题面没提」不等于「不会有」,而「样例里有」也不等于「样例会替你测出来」。
| 300 轮 | 顺手随机 | 不造重边档 | 重边密集档 |
|---|---|---|---|
| 有重边 | —— | 0 | 300 |
| 而且「后来那条更长」 | —— | 0 | 300 |
| 真被抓 | 90 | ★ 精确的 0 | 166 |
⚠ 重边密集那一档:三百轮全都有「后来那条更长」的重边,可真被抓只有 166 —— 差的那 134 轮里,被盖掉的那条短边不在最短路上。⇒ 又一次「满足触发条件 ≠ 一定被抓」。
5★★ 验算走第二条路:从终点反着跑一次
// ★ 另一条完全独立的路:从**终点** t 反着跑一次,看 dist[s]//// 它不是错法,是**验算的第二条路**:无向图上「s 到 t」和「t 到 s」是同一条路,// 所以两边跑出来的数必须一模一样(度量程序里 300 轮逐个比过,0 组不同)。//// ⚠⚠ 而这个「显然」是有主语的 —— 主语是**这张图的边是不是无向的**:// [第 15 章 P1379](/sol/p1379/)(八数码,每一步都可逆)上它成立;// [P1135](/sol/p1135/)(电梯,步长由出发那层决定)上它**当场就错**// —— 那道题 10152 条边里只有 2516 条反过来也走得通。// ⇒ 「从终点反着跑一次」不是一个到处能用的技巧,它是**无向图**(或者反图)的特权。
#include <bits/stdc++.h>using namespace std;typedef pair<int, int> PII;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s, t; if (!(cin >> n >> m >> s >> t)) return 0; vector<vector<PII>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); g[v].push_back({u, w}); } vector<int> dist(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; dist[t] = 0; // ★ 从终点出发 q.push({0, t}); while (!q.empty()) { auto [d, u] = q.top(); q.pop(); if (d > dist[u]) continue; for (auto [v, w] : g[u]) if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); } } cout << dist[s] << '\n'; // ★ 读起点那一格 return 0;}点「运行 ▶」看结果
无向图上「s 到 t」和「t 到 s」是同一条路,所以两边跑出来的数必须一模一样。 实测 300 轮,不一致 0 组。
本书在第 15 章那一对页面上把这件事量过:
- P1379(八数码,每一步都可逆)⇒ 从终点反着跑,成立;
- P1135(电梯,步长由出发那层的 K 决定)⇒ 当场就错 —— 那道题 10 152 条边里只有 2 516 条(24.8%) 反过来也走得通。
⇒ ★★ 「从终点反着跑一次」不是一个到处能用的技巧,它是无向图(或者显式建反图)的特权。 ★ 而下一道题 P1629 走的正是另一条路:那张图是单向的, 所以它必须真的把边反过来重建一张图。
6★ 一个正确的优化:t 一定下来就收工 —— 它值 2.16 倍
Dijkstra 是按距离从小到大把点一个个定死的 ⇒ t 从堆里出来的那一刻,答案就已经定死了。
尺子用「一共定死了几个点」(机器无关)。顶格 20 张随机图(n = 2500、m = 6200):
| 20 张图合计定死的点数 | |
|---|---|
| 不 break | 50 000(= 20 × 2500,每张图全定死) |
| ★ 加上那句 break | 23 187 |
⇒ 2.16 倍。
这道题 n ≤ 2500,不加也随便过(O(m log n) 只有几万次操作)。
★ 加它的理由是「说得清」——「答案在 t 出堆那一刻就定死了」这句话本身就是本章的关键一步。
⚠ 而最坏情况它一分钱都不省(t 恰好是离 s 最远的那个点)。
⇒ 又一次「更优的写法也可能一分钱都不值」——
报倍数必须说清量的是哪一档数据。
7★ 对拍这一页
参照物是 Floyd —— 它连「起点」这个概念都没有,和 Dijkstra 在思路上完全无关。
300 轮(n 随机 4~9、保证连通) |
|
|---|---|
| 正解 ≡ Floyd | ★ 不一致 0 轮 |
| 正解 ≡ 从终点反着跑 | ★ 不一致 0 轮 |
| 只存一遍边(当成有向图) | 189(★ 而且恒 ≥ 正解) |
| 邻接矩阵直接覆盖 | 90 →(不造重边档)★ 0 →(重边密集档)166 |
题面只说「求出从 s 到 t 的最短路长度」,没有定义 s 走不到 t 的情形 (USACO 原题的图是连通的)。
⇒ 题面没定义的情形,生成器就不该造 —— 否则对拍比的是两个都没定义的东西, 那种「一致」什么也不证明。 ★ 这是「一致有两种:都算对了,和都没算」的一个变体:这次是连题目都没算。
8度量程序和生成器
9一页纸
| ★ 关键的一步 | 一条无向边 = 两条有向边,代码只多一行 |
| ★ 为什么本章的结论一个都不用改 | 那三句反证里一次都没用到「边是有向的」,只用到「边长非负」 |
| ⚠ 只存一遍边 | 被抓 189 / 300,而恒 ≥ 正解(给的是一个合法方案);⚠ 官方样例放过了它(那三条边恰好方向都对) |
| ★★ 重边 | 题面一个字没提,官方样例里却有两组 —— 而它们不在最短路上,所以仍然没挡住;三档:0 / 90 / 166 |
| ★★ 从终点反着跑 | 300 轮逐个相同 —— ⚠ 而这条路的前提是边无向(P1135 上它当场就错) |
| ★ t 出堆就 break | 值 2.16 倍(50 000 → 23 187 个点),但这道题不加也过 —— 加它的理由是说得清 |
| ⚠ 生成器 | 题面没定义走不到时输出什么 ⇒ 那种数据根本不该造 |