题单 · 习题解析

洛谷 P1339 [USACO09OCT] Heat Wave G

★ 一条无向边 = 两条有向边,代码只多一行 —— ★★ 而本章那三句反证**一次都没用到「边是有向的」**,只用到「边长非负」,所以结论一个都不用改;⚠ 「只存一遍边」被抓 189/300 且**恒 ≥ 正解**,可官方样例放过了它(那三条边恰好方向都对);★★ 重边:题面**一个字没提**、样例里**却有两组** —— 「题面提没提」和「样例挡不挡得住」是两件独立的事;★ 「t 出堆就 break」值 2.16 倍

原题:洛谷 P1339出自 第 32 章 最短路一:Dijkstra 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1339,日期见页头。两边不一致时信原站。

题目描述

有一个 n 个点 m 条边的无向图,请求出从 st 的最短路长度。

输入格式

第一行四个正整数 n, m, s, t

接下来 m 行,每行三个正整数 u, v, w,表示一条连接 u, v,长为 w 的边。

输出格式

输出一行一个整数,表示答案。

说明/提示

【数据范围】

对于 100% 的数据,1 ≤ n ≤ 25001 ≤ m ≤ 62001 ≤ 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.cpp★ 这一版就能 AC
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

整份代码和本章fast.cpp 只差一行

g[u].push_back({v, w});
g[v].push_back({u, w});     <- 就是这一行
★ 题单那句「想一想为什么无向图不影响这一章的任何结论」——答案是一句话

本章第 5 步那三句反证里,一次都没有用到「边是有向的」。 它用到的只有一处:「从 x 走到 u 的那一段长度 ≥ 0」—— 也就是边长非负。(本章的 naive.cpp 顶上那段注释专门盯着这个 ≥ 0。)

⇒ 所以「无向」根本不是 Dijkstra 的前提,它只是建图时怎么存的问题。 证明一个字不用改,代码多一行。

顺带一道三十秒的算术题n ≤ 2500w ≤ 1000 ⇒ 任何一条简单路径 ≤ 2 500 000, 而 0x3f3f3f3f = 1 061 109 567 —— 富余 424 倍int 绰绰有余。

★ 这和隔壁 P3371 正好凑成一对:同一个常量,那道题差 2.02 倍不够用。 ⇒ 「INF 该写多大 / 要不要 long long」永远是一道拿这道题的题面乘一遍的算术题。

2⚠ 第一个错法:漏了那一行,把无向图当成有向图

p1339Dir.cpp✗ 每条边只存了一遍(官方样例照样打 7)
// ✗ 错法①:把无向图当成有向图 —— 每条边只存了一遍
//
// 这是这道题唯一一处**真正的**新东西,也是最容易漏的一行。
// 漏了它,算的就是另一张图上的最短路:题面给的 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 轮
「只存一遍边」被抓 189
★ 其中答案比正解小的轮数 0
★ 「它往哪个方向错」不用跑就能判 —— 而这次实测印证了

这一版是在一张边更少的图上求最短路(每条边只剩一个方向), 而它给出的每一条路在原图上都真的走得通 ⇒ 它给的是一个合法方案 ⇒ ★ 答案恒 ≥ 正解第 26 章那条判据)。

实测 300 轮,一次都没有反过来。 ⚠ 而本书也在这件事上被打回来过两次(第 23 章第 27 章 P2015 的「只会多算」都是错的)—— 所以这一条仍然是量出来的,不是推出来的。

3⚠⚠ 而官方样例把它放过去了 —— 原因能一句话说清

样例的最短路是 5 → 6 → 1 → 4,用到三条边:5 6 36 1 11 4 3

这三条边在输入里的方向,恰好就是要走的方向5 6 写在前、6 1 写在前、1 4 写在前)。 所以「只存一遍」在这组样例上一分不扣,照样打 7。

★ 这是「它过了样例的第三种原因:这组样例在结构上问不出这个问题」的又一次 —— 不是概率低,是那三条边恰好都不用反着走

4★★ 第二个错法:邻接矩阵直接覆盖 —— 而这道题的题面一个字没提重边

p1339Mat.cpp✗ g[u][v] = w(官方样例照样打 7)
// ✗ 错法②:邻接矩阵存图,重边**直接覆盖**(没取 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 和隔壁 P3371 凑成一对:「题面提没提」和「样例挡不挡得住」是两件独立的事
题面提了重边吗 官方样例里有重边吗 样例挡住这个错法了吗
P3371 提了(2022 年专门补的 Update) 没有 没挡住
P1339(这道题) 一个字没提 有两组 仍然没挡住

这道题样例里的两组是 2 4 2 / 2 4 37 2 2 / 7 2 1 —— 可它们都不在那条最短路上,于是覆不覆盖都一样。

⇒ ★ 读题时「题面没提」不等于「不会有」,而「样例里有」也不等于「样例会替你测出来」。

300 轮 顺手随机 不造重边档 重边密集档
有重边 —— 0 300
而且「后来那条更长」 —— 0 300
真被抓 90 精确的 0 166

⚠ 重边密集那一档:三百轮全都有「后来那条更长」的重边,可真被抓只有 166 —— 差的那 134 轮里,被盖掉的那条短边不在最短路上。⇒ 又一次「满足触发条件 ≠ 一定被抓」。

5★★ 验算走第二条路:从终点反着跑一次

p1339Rev.cpp★ 从 t 出发跑一次,读 dist[s](300 轮逐个相同)
// ★ 另一条完全独立的路:从**终点** 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

无向图上「s 到 t」和「t 到 s」是同一条路,所以两边跑出来的数必须一模一样。 实测 300 轮,不一致 0 组

⚠⚠ 但这个「显然」是有主语的 —— 主语是「这张图的边是不是无向的」

本书在第 15 章那一对页面上把这件事量过:

  • P1379(八数码,每一步都可逆)⇒ 从终点反着跑,成立;
  • P1135(电梯,步长由出发那层的 K 决定)⇒ 当场就错 —— 那道题 10 152 条边里只有 2 516 条(24.8%) 反过来也走得通。

⇒ ★★ 「从终点反着跑一次」不是一个到处能用的技巧,它是无向图(或者显式建反图)的特权。 ★ 而下一道题 P1629 走的正是另一条路:那张图是单向的, 所以它必须真的把边反过来重建一张图

6★ 一个正确的优化:t 一定下来就收工 —— 它值 2.16 倍

p1339Early.cpp★ if (u == t) break; —— 同样正确,快一点

Dijkstra 是按距离从小到大把点一个个定死的 ⇒ t 从堆里出来的那一刻,答案就已经定死了。

尺子用「一共定死了几个点」(机器无关)。顶格 20 张随机图(n = 2500m = 6200):

20 张图合计定死的点数
不 break 50 000(= 20 × 2500,每张图全定死)
★ 加上那句 break 23 187

2.16 倍

⚠ 而「值 2.16 倍」不等于「该加它」

这道题 n ≤ 2500,不加也随便过(O(m log n) 只有几万次操作)。 ★ 加它的理由是「说得清」——「答案在 t 出堆那一刻就定死了」这句话本身就是本章的关键一步。

⚠ 而最坏情况它一分钱都不省t 恰好是离 s 最远的那个点)。 ⇒ 又一次「更优的写法也可能一分钱都不值」—— 报倍数必须说清量的是哪一档数据。

7★ 对拍这一页

参照物是 Floyd —— 它连「起点」这个概念都没有,和 Dijkstra 在思路上完全无关。

p1339Brute.cpp参照物:Floyd(300 轮不一致 0 轮)
300 轮(n 随机 4~9、保证连通)
正解 ≡ Floyd 不一致 0 轮
正解 ≡ 从终点反着跑 不一致 0 轮
只存一遍边(当成有向图) 189(★ 而且恒 ≥ 正解
邻接矩阵直接覆盖 90 →(不造重边档)★ 0 →(重边密集档)166
⚠ 生成器为什么要「保证连通」——因为题面没说走不到时输出什么

题面只说「求出从 s 到 t 的最短路长度」,没有定义 s 走不到 t 的情形 (USACO 原题的图是连通的)。

⇒ 题面没定义的情形,生成器就不该造 —— 否则对拍比的是两个都没定义的东西, 那种「一致」什么也不证明。 ★ 这是「一致有两种:都算对了,和都没算」的一个变体:这次是连题目都没算

8度量程序和生成器

p1339Count.cpp度量程序(本页所有数字都出自它)
p1339Gen.cpp(四个档位)数据生成器

9一页纸

★ 关键的一步 一条无向边 = 两条有向边,代码只多一行
★ 为什么本章的结论一个都不用改 那三句反证里一次都没用到「边是有向的」,只用到「边长非负」
⚠ 只存一遍边 被抓 189 / 300,而恒 ≥ 正解(给的是一个合法方案);⚠ 官方样例放过了它(那三条边恰好方向都对)
★★ 重边 题面一个字没提,官方样例里却有两组 —— 而它们不在最短路上,所以仍然没挡住;三档:0 / 90 / 166
★★ 从终点反着跑 300 轮逐个相同 —— ⚠ 而这条路的前提是边无向P1135 上它当场就错)
★ t 出堆就 break 2.16 倍(50 000 → 23 187 个点),但这道题不加也过 —— 加它的理由是说得清
⚠ 生成器 题面没定义走不到时输出什么 ⇒ 那种数据根本不该造