0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1546,日期见页头。两边不一致时信原站。
题目背景
Farmer John 被选为他们镇的镇长!他其中一个竞选承诺就是在镇上建立起互联网,并连接到所有的农场。当然,他需要你的帮助。
题目描述
FJ 已经给他的农场安排了一条高速的网络线路,他想把这条线路共享给其他农场。为了用最小的消费,他想铺设最短的光纤去连接所有的农场。
你将得到一份各农场之间连接费用的列表,你必须找出能连接所有农场并所用光纤最短的方案。每两个农场间的距离不会超过 10⁵。
输入格式
第一行农场的个数 N(3 ≤ N ≤ 100)。
接下来是一个 N × N 的矩阵,表示每个农场之间的距离。理论上,他们是 N 行,每行由 N 个用空格分隔的数组成,实际上,由于每行 80 个字符的限制,因此,某些行会紧接着另一些行。当然,对角线将会是 0,因为不会有线路从第 i 个农场到它本身。
输出格式
只有一个输出,其中包含连接到每个农场的光纤的最小长度。
说明/提示
题目翻译来自 NOCOW。USACO Training Section 3.1
时限 1 秒,内存 125 MB。
输入输出样例
输入
4 0 4 9 21 4 0 8 17 9 8 0 16 21 17 16 0
输出
28
四个农场,矩阵对称、对角线为 0。
最小生成树选中 1-2(4)、2-3(8)、3-4(16),总长 28。
1第一版:题目直接把图铺在你面前了
给的是一张 N × N 的距离矩阵 ⇒ 任意两个农场之间都有一条边,这是一张完全图。
本章第 12 步那张表里「点少、边稠」那一档,说的就是它 ——
朴素 Prim:O(n²),一个堆都不用,而且图已经是矩阵了,一行都不用转存。
// P1546 [USACO3.1] 最短网络 —— ★ 这一版就能 AC//// 题目直接给一张 n × n 的距离矩阵 ⇒ 这是一张**完全图**(稠密到极点)。// [本章第 7 步](/ch/34-mst/)那份朴素 Prim 正好对上:O(n²),一个堆都不用。//// ★★ 而这道题真正会挂人的地方**不在算法里**,在输入格式那句话上:// 「由于每行 80 个字符的限制,因此,某些行会紧接着另一些行。」// —— 用 `cin >>` 读的话,这句话对你**完全没有影响**(它把所有空白符一视同仁);// 而按行读(getline 再切)当场就死。见 p1546Line.cpp。
#include <bits/stdc++.h>using namespace std;
int g[105][105];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; cin >> n; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) cin >> g[i][j]; // ★ 换不换行,它一个字都不在乎
vector<char> in(n + 1, 0); vector<int> best(n + 1, INT_MAX); best[1] = 0;
int sum = 0; for (int it = 0; it < n; it++) { int u = -1; for (int i = 1; i <= n; i++) // 挑「离这棵树最近」的那个点 if (!in[i] && (u < 0 || best[i] < best[u])) u = i; in[u] = 1; sum += best[u]; for (int i = 1; i <= n; i++) // ★ 比的是 g[u][i],不是 best[u] + g[u][i] if (!in[i]) best[i] = min(best[i], g[u][i]); } cout << sum << '\n'; return 0;}点「运行 ▶」看结果
| P3366 | 这道题 | |
|---|---|---|
| 图怎么给的 | m 行「u v w」 |
★ 一整张矩阵 |
n / m |
5000 / 2 × 10⁵(稀疏) | 100 / 4950(稠密到满) |
| 邻接矩阵要多少内存 | ⚠ 95 MB(题面给 128) | ★ 40 804 字节(40 KB) |
| 顺手该选 | Kruskal | ★ 朴素 Prim |
⇒ ★★ 「稠密图用朴素 Prim」这句话的主语从来是 n —— 同一张矩阵,
n = 100 是 40 KB,n = 5000 就是 95 MB。
2★★★ 而这道题真正会挂人的地方,一个字都不在算法里
题面输入格式那一段里塞了这么一句:
理论上,他们是
N行,每行由N个用空格分隔的数组成,实际上,由于每行 80 个字符的限制,因此,某些行会紧接着另一些行。
// ✗ P1546:按行读矩阵 —— 题面那句「某些行会紧接着另一些行」就是冲它来的//// ★★ 它在**规整**的矩阵上一个字不错(官方样例照过、顺手写的对拍 300 轮全绿),// 只要输入真的按题面说的那样折了行,它当场读崩。// ⇒ 这是这一页唯一一个「**对拍在默认档上永远抓不到**」的错法 ——// 因为生成器自己造的矩阵是规整的,得专门造一档折行的(p1546Gen.cpp 档位 1)。
#include <bits/stdc++.h>using namespace std;
int g[105][105];
int main() { int n; cin >> n; cin.ignore(numeric_limits<streamsize>::max(), '\n');
for (int i = 1; i <= n; i++) { string line; getline(cin, line); // ✗ 假定「一行正好 n 个数」 istringstream ss(line); for (int j = 1; j <= n; j++) ss >> g[i][j]; // 读不满就留着上一轮的垃圾 }
vector<char> in(n + 1, 0); vector<int> best(n + 1, INT_MAX); best[1] = 0; int sum = 0; for (int it = 0; it < n; it++) { int u = -1; for (int i = 1; i <= n; i++) if (!in[i] && (u < 0 || best[i] < best[u])) u = i; in[u] = 1; sum += best[u]; for (int i = 1; i <= n; i++) if (!in[i]) best[i] = min(best[i], g[u][i]); } cout << sum << '\n'; return 0;}点「运行 ▶」看结果
cin >> x 把所有空白符一视同仁 —— 空格、制表符、换行,在它眼里都是「一个数结束了」。
所以正解那一版根本不知道输入折没折行。
而「按行读」的写法把换行当成了结构:getline 拿一行、假定里面正好 n 个数。
输入一折行,它第一行只读到几个数,剩下的格子留着未初始化的垃圾,后面全乱套。
顶格 n = 100、距离 ≤ 10⁵ 时(80 列大约放得下 13 个数):
| 一组顶格折行数据 | |
|---|---|
| 正解 / Kruskal | 124712 |
| ★ 按行读 | 0 |
⇒ 这是这一页唯一一个「对拍在默认档上永远抓不到」的错法 —— 生成器自己吐出来的矩阵是规整的,得专门造一档折行的。
第一版折行档是照题面写的:每行最多 80 个字符就换行。跑完 300 轮, 「按行读」被抓 0 次 —— 差点就写成「这个坑对拍抓不到」。
真因很蠢:对拍用的是 n = 3~6 的小矩阵、距离只到 40 ⇒ 一行连 20 个字符都不到,80 列根本折不动。
⇒ ★★ 小图上要复现的是题面那个比值,不是那个绝对数:
n = 100、距离 ≤ 10⁵ 时 80 列大约放 13 个数,比值 0.13;
小图上照着压到「每行 1 个数」,同一个 bug 当场 300 / 300。
★ 这正是 P1020 那条:生成器该照抄题面的「比值」,而不是「绝对规模」
—— n 小了才能拿枚举子集当参照物,而比值保住了那个决定成败的性质。
3⚠ 题面没说的那句:距离可以是 0
// ✗ P1546:把矩阵里的 0 当成「这两点之间没有路」//// 这是一个**从题面推不出来、只能从题面「没说什么」看出来**的坑:// 题面只写了「每两个农场间的距离不会超过 10⁵」,**一个字都没说距离 ≥ 1**。//// ★ 而顺手写的生成器一律从 1 开始撒距离 ⇒ 这一档是**精确的 0**(见 p1546Gen.cpp 档位 2)。// 和[第 33 章 P1266](/sol/p1266/) 那个「`V = 0` 的边」是同一个形状。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;int g[105][105];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; cin >> n; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) { int x; cin >> x; g[i][j] = x ? x : INF; // ✗ 「0 就是没边」——对角线是这样,别处不是 }
vector<char> in(n + 1, 0); vector<int> best(n + 1, INF); best[1] = 0; int sum = 0; for (int it = 0; it < n; it++) { int u = -1; for (int i = 1; i <= n; i++) if (!in[i] && (u < 0 || best[i] < best[u])) u = i; in[u] = 1; sum += best[u]; for (int i = 1; i <= n; i++) if (!in[i]) best[i] = min(best[i], g[u][i]); } cout << sum << '\n'; return 0;}点「运行 ▶」看结果
题面只写了「每两个农场间的距离不会超过 10⁵」,一个字都没说距离 ≥ 1。
而对角线上那一圈 0 会让人很自然地想「0 就是没边」—— 这个念头对角线上是对的,别处就不是了。
| 档 0(距离 1~40,顺手写的样子) | ★ 档 2(距离 0~40,题面允许) | |
|---|---|---|
| 非对角线上真的出现 0 | 0 组 | 50 / 300 |
| 「把 0 当没边」被抓 | ★ 精确的 0 | ★ 50 / 300 |
★ 一个不差,而且能证:非对角线没有 0 时,那句 x ? x : INF 一次都不会改变什么。
⇒ 又一次「生成器最自然的默认值,往往正是某个 bug 的藏身处」——
顺手写 1 + rng() % 40 的人,结构上造不出这一档。
★ 和第 33 章 P1266 那个「V = 0 的边」是同一个形状。
4⚠ 另外两个:Prim 手滑写成 Dijkstra,和忘了 !in[i]
// ✗ P1546:Prim 手滑写成了 Dijkstra//// [本章第 7 步](/ch/34-mst/)说过这两份代码「只差一个字」——// 差的就是松弛那一行:Prim 比的是 `g[u][i]`(到**树**的距离),// Dijkstra 比的是 `best[u] + g[u][i]`(到**起点**的距离)。//// ⚠ 照本章 wrongDij.cpp 的规矩,这一份累加的是「真正用到的那条边的权」,// 而不是 best[u] —— 否则它连「一棵树的权值和」都不是了,错得太明显反而没有教学价值。// ★ 它的意思是:**它给出的是一棵真的树(最短路径树),只是选错了那棵。**
#include <bits/stdc++.h>using namespace std;
int g[105][105];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; cin >> n; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) cin >> g[i][j];
vector<char> in(n + 1, 0); vector<int> best(n + 1, INT_MAX), from(n + 1, 0); // from[v] = 把 v 够进来的那条边的权 best[1] = 0;
int sum = 0; for (int it = 0; it < n; it++) { int u = -1; for (int i = 1; i <= n; i++) if (!in[i] && (u < 0 || best[i] < best[u])) u = i; in[u] = 1; sum += from[u]; for (int i = 1; i <= n; i++) if (!in[i] && best[u] + g[u][i] < best[i]) { // ✗ 多了「best[u] +」这一截 best[i] = best[u] + g[u][i]; from[i] = g[u][i]; } } cout << sum << '\n'; return 0;}点「运行 ▶」看结果
本章第 7 步说这两份代码「只差一个字」:
✓ Prim: if ( g[u][i] < best[i]) ← 到**树**的距离
✗ Dijkstra: if (best[u] + g[u][i] < best[i]) ← 到**起点**的距离它给出的仍然是一棵真的树(从 1 号出发的最短路径树),只是通常不是最小的那棵。
| 档 0 · 300 轮 | |
|---|---|
| 最短路径树的权和 ≠ 最小生成树的权和 | 189 |
| 「手滑写成 Dijkstra」被抓 | ★ 189 |
★ 又一个「一个不差」,而且同样是能证的等价(两棵树的权和不同 ⟺ 打出的那个数不同)。 ⚠ 而距离大量并列的档 3 上它反而更容易抓(218 / 300)—— 并列越多,两棵树越容易分岔。
// ✗ P1546:挑点的时候忘了跳过「已经在树里」的//// 少的就是那半句 `!in[i]`。它每一轮都会挑中 `best` 最小的那个点 ——// 而已经入树的点 best 已经被定死、往往就是全场最小的 ⇒ **它会反复挑同一个点**,// 于是树根本长不起来,`sum` 少加一大截。// ★ 这是一个「每组都错」型的 bug ⇒ 官方样例当场挡住。
#include <bits/stdc++.h>using namespace std;
int g[105][105];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; cin >> n; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) cin >> g[i][j];
vector<char> in(n + 1, 0); vector<int> best(n + 1, INT_MAX); best[1] = 0; int sum = 0; for (int it = 0; it < n; it++) { int u = -1; for (int i = 1; i <= n; i++) if (u < 0 || best[i] < best[u]) u = i; // ✗ 没有 !in[i] in[u] = 1; sum += best[u]; for (int i = 1; i <= n; i++) if (!in[i]) best[i] = min(best[i], g[u][i]); } cout << sum << '\n'; return 0;}点「运行 ▶」看结果
已经入树的点 best 已经定死、往往就是全场最小 ⇒ 它每一轮都挑同一个点,树根本长不起来。
四个档全是 300 / 300 —— 又一个「每组都错」型,官方样例一测就死。
5★★★ 兑现本章第 10 步:两棵树可能不一样,那对拍该比什么
本章第 10 步埋了一句话:「两种算法给出的树可能长得不一样,但权值和必须相同。」 这一页把它量出来了 —— 拿朴素 Prim 和 Kruskal 两份正确写法逐组比:
// ★ P1546 的另一条路:Kruskal —— 它也能 AC//// 矩阵给的是完全图 ⇒ 要先把 n(n−1)/2 = 4950 条边**摊开**,再排序。// ⚠ 这一步在 n = 100 时无所谓(4950 条),但它是这道题和 [P3366](/sol/p3366/) 的分水岭:// 那边 n = 5000、m = 2 × 10⁵(稀疏),这边 n = 100、m = 4950(稠密到满)。// ⇒ **「用 Kruskal 还是 Prim」的主语是「点多还是边多」**,见本页第 ⑤ 步那张表。
#include <bits/stdc++.h>using namespace std;
int fa[105];int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; cin >> n; vector<array<int, 3>> e; for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) { int w; cin >> w; if (i < j) e.push_back({w, i, j}); // 只取上三角,矩阵是对称的 }
sort(e.begin(), e.end()); for (int i = 1; i <= n; i++) fa[i] = i;
int sum = 0, cnt = 0; for (auto& t : e) { int x = find(t[1]), y = find(t[2]); if (x == y) continue; fa[x] = y; sum += t[0]; if (++cnt == n - 1) break; } cout << sum << '\n'; return 0;}点「运行 ▶」看结果
| 300 组 | 档 0(距离 1~40) | ★ 档 3(距离只取 1~3,大量并列) |
|---|---|---|
| 两版选的边集不同 | 6 | ★ 39 |
| 两版权值和不同 | ★ 0 | ★ 0 |
⇒ 结论有两半,缺一半就会写出一个报满屏假阳性的对拍:
- 别比边集 —— 并列一多,两个都正确的写法就会分岔(39 / 300);
- 权值和可以逐字节比 —— 这道题只要输出一个数,两版从来没有不同过。
★ 这正是 B3644 那条线上的一个点:那道题「输出任意一种即可」, 逐字节比会报 283 轮假阳性(94%);这道题输出的恰好是那个不变量,所以直接比就行。 ⇒ ★★ 「对拍该比什么」的答案,写在题面的输出格式里。
6⚠ 一条负结论:这道题上「选 Prim 还是 Kruskal」一分钱都不值
A 机 · WSL2 · 8 核 / 7 G · 2026-08-31 · 独占 · 顶格 n = 100(输入 58 522 字节)· 5 次取最小 |
|
|---|---|
只读入(cin + 关同步) |
4 毫秒 |
| 朴素 Prim | 5 毫秒 |
| Kruskal | 5 毫秒 |
工作量本身也小得可怜:朴素 Prim 内层 20 000 次、Kruskal 要摊开的边只有 4950 条。
⇒ 这道题的规模不到,选型这件事说了等于没说。
★ 而「稠密图该用朴素 Prim」这句话真正分胜负的地方在下一道:
P2872 的 n = 1000 ⇒ 完全图 499 500 条边,那才是两条路真的分开的地方。
答案上界 (n − 1) × 10⁵ |
99 × 10⁵ = 9 900 000 |
int 上限 |
2 147 483 647 ⇒ 余量 ★ 216 倍 |
邻接矩阵 101 × 101 × 4 |
40 804 字节 |
★ 顺带称一下题面那句「3 ≤ N」:它是噪声。
把 N 放到 1 和 2 各跑一遍,正解给 0 和 7,两个都对 ——
⇒ 违反它之后没有任何一版的行为变了(第 12 章那个判据)。
7★ 对拍这一页
参照物是枚举所有边的子集(n ≤ 6 ⇒ 15 条边、2¹⁵ 个子集),和两种正解一行代码都不共享。
300 轮(n 随机 3~6) |
档 0 规整 | ★★ 档 1 折行 | ★ 档 2 距离可为 0 | ★ 档 3 大量并列 |
|---|---|---|---|---|
| 朴素 Prim(正解) | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| Kruskal | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 按行读矩阵 | ★ 0 | ★ 300 | ★ 0 | ★ 0 |
| 手滑写成 Dijkstra | 189 | 189 | 168 | ★ 218 |
| 把 0 当成没边 | ★ 0 | ★ 0 | ★ 50 | ★ 0 |
忘了 !in[i] |
300 | 300 | 300 | 300 |
| 那个 0 | 为什么 | 怎么办 |
|---|---|---|
| 「按行读」在档 0 / 2 / 3 | 生成器吐的是规整矩阵 | 造折行档 ⇒ 300 |
| 「把 0 当没边」在档 0 / 1 / 3 | 生成器从 1 开始撒距离 | 把下界放到 0 ⇒ 50 |
| 正解 / Kruskal 全 0 | ★ 它们是对的 | —— |
★ 而前两个 0 的自检就在同一张表里:同一段对拍代码在别的档上抓到了 300 和 50,代码是活的。
| 错法 | 抓获率(最狠的那一档) | 官方样例挡住了吗 |
|---|---|---|
忘了 !in[i] |
300 / 300 | ★ 挡住了(打出 0) |
| 手滑写成 Dijkstra | 218 / 300 | ★ 挡住了(打出 34) |
| 按行读矩阵 | 300 / 300 | ⚠ 放过了 —— 样例只有 4 个数一行,折不动 |
| 把 0 当成没边 | 50 / 300 | ⚠ 放过了 —— 样例里非对角线没有 0 |
⇒ ★★ 这一轮把「样例是一测就死的过滤器」那条规律的主语看得更清楚了: 后两个错法都是「每组都错」型(在它们各自的档上是 300 / 300),样例照样放过 —— 因为官方样例在结构上问不出那个问题(P1746 那条)。 ⇒ 那条规律说的是「答案对不对」,它管不了「这组数据长什么样」。
8度量程序和生成器
9一页纸
| ★ 关键的一步 | 矩阵给的是完全图 ⇒ 朴素 Prim,本章第 7 步原样搬过来 |
| ★★★ 真正的坑 | 输入格式那句「某些行会紧接着另一些行」—— cin >> 免疫,按行读当场死 |
| ⚠⚠ 自己踩的 | 折行档照抄「80 个字符」⇒ 小图折不动、精确的 0;要复现的是比值不是绝对数 |
| ★ 50 ≡ 50 | 「把 0 当没边」的触发条件 ≡ 抓获数(题面没说距离 ≥ 1) |
| ★ 189 ≡ 189 | 「手滑写成 Dijkstra」同样一个不差 —— 它算的是最短路径树 |
| ★★★ 对拍比什么 | 两版选的边不同 39 组、权和不同 0 组 ⇒ 比数,别比边集 |
| ⚠ 负结论 | n = 100 太小,Prim / Kruskal 都是 5 毫秒(其中 4 毫秒是读入)⇒ 选型不值钱 |
| ★ 算术 | int 余量 216 倍;矩阵 40 KB(P3366 同一张是 95 MB);「3 ≤ N」是噪声 |