题单 · 习题解析

洛谷 P1546 [USACO3.1] 最短网络 Agri-Net

★ 矩阵给的是**完全图** ⇒ 朴素 Prim 原样搬过来;★★★ 真正会挂人的一个字都不在算法里 —— 输入格式那句「某些行会紧接着另一些行」,`cin >>` 免疫、**按行读当场死**(顶格折行档:正解 124712 / 它 **0**);⚠⚠ 而这一档我自己踩了一跤:照抄「80 个字符」在 `n ≤ 6` 上**折不动**、精确的 0 ⇒ 小图要复现的是题面那个**比值**([P1020](/sol/p1020/) 那条);★ 两个「触发条件 ≡ 抓获数」都是能证的等价(「把 0 当没边」50 ≡ 50,题面**没说**距离 ≥ 1;「手滑写成 Dijkstra」189 ≡ 189,它算的是最短路径树);★★★ 兑现本章第 10 步:两版**选的边不同 39 组、权和不同 0 组** ⇒ 比数别比边集;⚠ 负结论:`n = 100` 太小,两种正解都是 5 ms(其中 4 ms 是读入)⇒ **选型不值钱**

原题:洛谷 P1546出自 第 34 章 最小生成树:Kruskal 与 Prim 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目背景

Farmer John 被选为他们镇的镇长!他其中一个竞选承诺就是在镇上建立起互联网,并连接到所有的农场。当然,他需要你的帮助。

题目描述

FJ 已经给他的农场安排了一条高速的网络线路,他想把这条线路共享给其他农场。为了用最小的消费,他想铺设最短的光纤去连接所有的农场。

你将得到一份各农场之间连接费用的列表,你必须找出能连接所有农场并所用光纤最短的方案。每两个农场间的距离不会超过 10⁵

输入格式

第一行农场的个数 N3 ≤ 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 步那张表里「点少、边稠」那一档,说的就是它 —— 朴素 PrimO(n²),一个堆都不用,而且图已经是矩阵了,一行都不用转存

p1546.cpp★ 这一版就能 AC(朴素 Prim,顶格本机 5 毫秒)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么这道题的正解比 [P3366](/sol/p3366/) 还短
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 个字符的限制,因此,某些行会紧接着另一些行。

p1546Line.cpp✗ 按行读矩阵(官方样例照过,顺手写的对拍 300 轮全绿)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 用 cin >> 读的话,这句话对你完全没有影响

cin >> x所有空白符一视同仁 —— 空格、制表符、换行,在它眼里都是「一个数结束了」。 所以正解那一版根本不知道输入折没折行。

而「按行读」的写法把换行当成了结构:getline 拿一行、假定里面正好 n 个数。 输入一折行,它第一行只读到几个数,剩下的格子留着未初始化的垃圾,后面全乱套

顶格 n = 100、距离 ≤ 10⁵ 时(80 列大约放得下 13 个数):

一组顶格折行数据
正解 / Kruskal 124712
★ 按行读 0

⇒ 这是这一页唯一一个「对拍在默认档上永远抓不到」的错法 —— 生成器自己吐出来的矩阵是规整的,得专门造一档折行的。

⚠⚠ 而这一档我自己踩了一跤:照抄「80 个字符」,那一档是精确的 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

p1546Zero.cpp✗ 把矩阵里的 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

题面只写了「每两个农场间的距离不会超过 10⁵」,一个字都没说距离 ≥ 1。 而对角线上那一圈 0 会让人很自然地想「0 就是没边」—— 这个念头对角线上是对的,别处就不是了。

★ 50 ≡ 50:触发条件和抓获数一个不差
档 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]

p1546Dij.cpp✗ 松弛那行多了「best[u] +」(样例打出 34,挡住了)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 189 ≡ 189:它算的是最短路径树,而两个量不同 ⟺ 输出不同

本章第 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)—— 并列越多,两棵树越容易分岔。

p1546NoIn.cpp✗ 挑点时忘了 !in[i](样例打出 0,当场挡住)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

已经入树的点 best 已经定死、往往就是全场最小 ⇒ 它每一轮都挑同一个点,树根本长不起来。 四个档全是 300 / 300 —— 又一个「每组都错」型,官方样例一测就死。

5★★★ 兑现本章第 10 步:两棵树可能不一样,那对拍该比什么

本章第 10 步埋了一句话:「两种算法给出的树可能长得不一样,但权值和必须相同。」 这一页把它量出来了 —— 拿朴素 PrimKruskal 两份正确写法逐组比:

p1546Kru.cpp★ Kruskal 版 —— 也能 AC(要先把 4950 条边摊开)
// ★ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 「选了哪些边」不同 39 组,「权值和」不同 0 组
300 组 档 0(距离 1~40) ★ 档 3(距离只取 1~3,大量并列
两版选的边集不同 6 39
两版权值和不同 0 0

⇒ 结论有两半,缺一半就会写出一个报满屏假阳性的对拍

  • 别比边集 —— 并列一多,两个都正确的写法就会分岔(39 / 300);
  • 权值和可以逐字节比 —— 这道题只要输出一个数,两版从来没有不同过。

★ 这正是 B3644 那条线上的一个点:那道题「输出任意一种即可」, 逐字节比会报 283 轮假阳性(94%);这道题输出的恰好是那个不变量,所以直接比就行。 ⇒ ★★ 「对拍该比什么」的答案,写在题面的输出格式里。

6⚠ 一条负结论:这道题上「选 Prim 还是 Kruskal」一分钱都不值

⚠ 顶格 n = 100 —— 端到端 5 毫秒,其中 4 毫秒是读入
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」这句话真正分胜负的地方在下一道P2872n = 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 各跑一遍,正解给 07,两个都对 —— ⇒ 违反它之后没有任何一版的行为变了(第 12 章那个判据)。

7★ 对拍这一页

参照物是枚举所有边的子集n ≤ 6 ⇒ 15 条边、2¹⁵ 个子集),和两种正解一行代码都不共享。

p1546Brute.cpp参照物:枚举所有边子集(300 轮不一致 0 轮)
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 为什么 怎么办
「按行读」在档 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度量程序和生成器

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

9一页纸

★ 关键的一步 矩阵给的是完全图 ⇒ 朴素 Prim,本章第 7 步原样搬过来
★★★ 真正的坑 输入格式那句「某些行会紧接着另一些行」—— cin >> 免疫,按行读当场死
⚠⚠ 自己踩的 折行档照抄「80 个字符」⇒ 小图折不动、精确的 0;要复现的是比值不是绝对数
★ 50 ≡ 50 「把 0 当没边」的触发条件 ≡ 抓获数(题面没说距离 ≥ 1)
★ 189 ≡ 189 「手滑写成 Dijkstra」同样一个不差 —— 它算的是最短路径树
★★★ 对拍比什么 两版选的边不同 39 组权和不同 0 组 ⇒ 比数,别比边集
⚠ 负结论 n = 100 太小,Prim / Kruskal 都是 5 毫秒(其中 4 毫秒是读入)⇒ 选型不值钱
★ 算术 int 余量 216 倍;矩阵 40 KBP3366 同一张是 95 MB);「3 ≤ N」是噪声