0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P2820,日期见页头。两边不一致时信原站。
题目背景
某个局域网内有 n 台计算机,由于搭建局域网时工作人员的疏忽,现在局域网内的连接形成了回路,我们知道如果局域网形成回路那么数据将不停的在回路内传输,造成网络卡的现象。因为连接计算机的网线本身不同,所以有一些连线不是很畅通,我们用 f(i,j) 表示 i, j 之间连接的畅通程度,f(i,j) 值越小表示 i, j 之间连接越通畅,f(i,j) 为 0 表示 i, j 之间无网线连接。
题目描述
现在需要解决回路问题,我们将除去一些连线,使得网络中没有回路,不改变原图节点的连通性,并且被除去网线的 Σf(i,j) 最大,请求出这个最大值。
输入格式
第一行两个正整数 n, k。
接下来的 k 行每行三个正整数 i, j, m 表示 i, j 两台计算机之间有网线联通,通畅程度为 m。
输出格式
一个正整数,Σf(i,j) 的最大值。
说明/提示
对于全部数据,保证 1 ≤ n ≤ 100,1 ≤ f(i,j) ≤ 1000。
时限 1 秒,内存 125 MB。
输入输出样例
输入
5 5 1 2 8 1 3 1 1 5 3 2 4 5 3 4 2
输出
8
五条网线总畅通程度 8 + 1 + 3 + 5 + 2 = 19。
留下最通畅的一片森林(1-3 的 1、3-4 的 2、1-5 的 3、2-4 的 5,共 11),
删掉的就是 19 − 11 = 8 —— 正好是那条 1-2 的 8。
1★ 关键的一步:把「删掉的最大」翻成「留下的最小」
题目要求删完之后满足两件事:没有回路 + 连通性一个字都不变。 这两句话合起来只有一个意思 —— 留下的恰好是一片生成森林。
于是:
删掉的 Σf 最大 ⟺ 留下的 Σf 最小 ⟺ 留下的是最小生成森林。
⇒ 答案 = 总权和 − 最小生成森林的权和。 本章第 6 步那份 Kruskal 一个字都不用改,只在最外面套一句减法。
// P2820 局域网 —— ★ 这一版就能 AC//// ★ 关键的一步是一句话的换算:「**删掉的最大**」= 总权和 − 「**留下的最小**」。// 题目要求删完之后「没有回路」+「不改变连通性」⇒ 留下的恰好是一片**生成森林**;// 要删的最大,就让留下的最小 ⇒ 留下的是**最小生成森林**。//// ⚠⚠ 两处必须留神:// ① 题面**没有保证图是连通的**(它说的是「不改变原图节点的连通性」)——// 所以这里求的是**森林**不是树,Kruskal 一路收到底、不能要求「收够 n − 1 条」。// ② 题面**一个字都没给 k 的上限**(只给了 `1 ≤ n ≤ 100` 和 `1 ≤ f ≤ 1000`)// ⇒ 别按 n(n−1)/2 开死数组,用 vector 一路 push 就完了。
#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, k; cin >> n >> k; vector<array<int, 3>> e(k); long long total = 0; for (auto& t : e) { cin >> t[1] >> t[2] >> t[0]; // 读进来是 i j m,存成 (m, i, j) total += t[0]; // ★ 总权和要把**每一条**边都算上 }
sort(e.begin(), e.end()); for (int i = 1; i <= n; i++) fa[i] = i;
long long keep = 0; for (auto& t : e) { // ⚠ 一路收到底,不数够不够 n − 1 条 int x = find(t[1]), y = find(t[2]); if (x == y) continue; fa[x] = y; keep += t[0]; }
cout << total - keep << '\n'; return 0;}点「运行 ▶」看结果
① 题面从头到尾没说过图是连通的。
它说的是「不改变原图节点的连通性」—— 这句话恰恰是在提醒你:
原来断开的两块,删完之后也还得是断开的两块。
⇒ 这里求的是森林不是树,Kruskal 得一路收到底,不能要求「收够 n − 1 条」。
② 题面一个字都没给 k 的上限。
数据范围那一行只写了 1 ≤ n ≤ 100 和 1 ≤ f(i,j) ≤ 1000 —— k 不在里面。
★ 这是「读数据范围时先看每一档的主语是谁」的一个新形态:
这一次是那一行里少了一个数。 ⇒ 别按 n(n−1)/2 = 4950 开死数组,vector 一路 push 就完了。
2★ 另一条路:从大到小,能删就删
比起「换算成最小生成树」,还有一条更直白的路:把网线按畅通程度从大到小排, 一条条问「拆了它两头还连得上吗」,连得上就拆。
// ★ P2820 的另一条路:从**大**到小扫,能删就删 —— 它也对//// 想法比换算更直白:把网线按畅通程度**从大到小**排,// 一条一条问「拆了它之后,两头还连得上吗?」连得上就拆。//// ★ 它给出的答案和正解**逐组相同**(见本页第 ② 步,四档共 1200 组一个不差),// 理由也是一句话:拆完之后留下的仍然是一片生成森林,// 而「每次拆最贵的可拆边」正是 Kruskal 的**逆过程**(反向 Kruskal)。// ⚠ 代价是它每问一次都要跑一遍连通性检查 —— O(k × (n + k)),// 而正解是一次排序加一遍并查集。这道题 n ≤ 100 无所谓,规模一大就不行了。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; cin >> n >> k; vector<array<int, 3>> e(k); long long total = 0; for (auto& t : e) { cin >> t[1] >> t[2] >> t[0]; total += t[0]; }
sort(e.begin(), e.end(), greater<array<int, 3>>()); // 从大到小 vector<char> dead(k, 0);
/* 只用剩下的边,看 a 和 b 还连不连得上 */ auto connected = [&](int a, int b) { vector<int> fa(n + 1); for (int i = 1; i <= n; i++) fa[i] = i; function<int(int)> f = [&](int x) { return fa[x] == x ? x : fa[x] = f(fa[x]); }; for (int i = 0; i < k; i++) if (!dead[i]) { int x = f(e[i][1]), y = f(e[i][2]); if (x != y) fa[x] = y; } return f(a) == f(b); };
long long cut = 0; for (int i = 0; i < k; i++) { dead[i] = 1; // 先假装拆掉 if (connected(e[i][1], e[i][2])) cut += e[i][0]; else dead[i] = 0; // 拆了就断了 —— 放回去 }
cout << cut << '\n'; return 0;}点「运行 ▶」看结果
| 反向 Kruskal 的答案 ≡ 正解 | ★ 1200 / 1200(四个档各 300 组) |
顶格 n = 100、完全图 4950 条边 · A 机 · WSL2 · 2026-08-31 · 独占 · 5 次取最小 |
|
| · 正解(一次排序 + 一遍并查集) | 4 毫秒 |
| · 反向 Kruskal(每条边重跑一遍连通性) | 111 毫秒 ⇒ ★ 28 倍 |
⇒ 这道题 n ≤ 100,28 倍也照样过。
★ 但它把「等价 ≠ 等值」说清楚了:两条路答案永远一样,代价差一个数量级 ——
和隔壁 P1195 那条(「先求完整 MST 再删」等价但一分钱不省)凑成一对:
「它也对」和「该写它」是两句话。
3★★★ 读反:输出「留下的」而不是「删掉的」
// ✗ P2820:把题目问的东西读反了 —— 输出「留下的最小」//// 题面问的是「被除去网线的 Σf(i,j) **最大值**」,而算出最小生成森林之后,// 手边最现成的那个数是**留下的权和**。★ 顺手打出它,是这道题最常见的一发 WA。// ⚠ 而它是个「每组都错」型的 bug ⇒ 官方样例当场挡住(打出 11,答案是 8)。
#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, k; cin >> n >> k; vector<array<int, 3>> e(k); long long total = 0; for (auto& t : e) { cin >> t[1] >> t[2] >> t[0]; total += t[0]; }
sort(e.begin(), e.end()); for (int i = 1; i <= n; i++) fa[i] = i;
long long keep = 0; for (auto& t : e) { int x = find(t[1]), y = find(t[2]); if (x == y) continue; fa[x] = y; keep += t[0]; }
cout << keep << '\n'; // ✗ 问的是删掉的,不是留下的 return 0;}点「运行 ▶」看结果
算完最小生成森林之后,手边最现成的那个数是留下的权和 —— 顺手打出它,是这道题最常见的一发 WA。
| 300 轮 | 档 0 | 档 1 不连通 | 档 2 有重边 | ★ 档 3 大量并列 |
|---|---|---|---|---|
| 「删掉的」恰好等于「留下的」 | 7 | 1 | 2 | ★ 38 |
| ⇒ 「打出留下的」被抓 | 293 | 299 | 298 | 262 |
★★ 四个档全部一个不差(300 − 触发数)。
⇒ 这是第 28 章 P3959 那条的正面版本: 「它算的是另一个量」推不出「它一定和正解不同」 —— 两个不同的量会在某些输入上取到同一个值,而那个「某些」只能数出来。 ★ 而档 3(畅通程度只取 1~3)把这个「某些」放大了 5 倍(7 → 38):值域一挤,撞车就多。
4⚠ 两个「生成器缺一档」的 0
// ✗ P2820:以为图一定连通,非要收够 n − 1 条//// 题面从头到尾**没说过图是连通的** —— 它说的是「不改变原图节点的连通性」,// 这句话恰恰是在提醒你:原来断开的两块,**删完之后也还得是断开的两块**。//// ★ 这一版一旦发现收不满 n − 1 条,就当成「有问题」按 0 处理;// ⚠ 而顺手写的生成器要是先造一棵树保证连通([P3366 那条](/sol/p3366/)),// 这个 bug 又是**精确的 0**。
#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, k; cin >> n >> k; vector<array<int, 3>> e(k); long long total = 0; for (auto& t : e) { cin >> t[1] >> t[2] >> t[0]; total += t[0]; }
sort(e.begin(), e.end()); for (int i = 1; i <= n; i++) fa[i] = i;
long long keep = 0; int cnt = 0; for (auto& t : e) { if (cnt == n - 1) break; int x = find(t[1]), y = find(t[2]); if (x == y) continue; fa[x] = y; keep += t[0]; cnt++; }
if (cnt != n - 1) { cout << 0 << '\n'; return 0; } // ✗ 不连通就当没得删 cout << total - keep << '\n'; return 0;}点「运行 ▶」看结果
| 档 1(不搭那棵树)· 300 轮 | |
|---|---|
| 第一层:原图真的不连通 | 176 |
| · 其中正解的答案本来就是 0(图本身就是森林,压根没边可删) | ⚠ 131 |
| · 其中正解的答案 ≠ 0 | 45 |
⇒ 「非要收够 n − 1 条」被抓 |
★ 45 |
⇒ 那 131 轮里两版一起打出 0,对拍记「通过」而验的是零 (「一致有两种:都算对了,和都没算」)。
★★ 而这一段最值钱的动作是:抓获率对不上时别急着加轮数,去把中间那几层数出来 (B3625 那条)—— 数完之后 45 ≡ 45,一个不差。
// ✗ P2820:用邻接矩阵存这些网线//// `n ≤ 100` ⇒ 一张 101 × 101 的矩阵又小又顺手,很多人第一反应就是它。// ★★ 而这道题**存不得**:它要的不只是「留下哪些」,还要**总权和** ——// 而矩阵一个格子只放得下一条边,**同一对计算机之间的第二条网线会被直接覆盖掉**。//// ⚠ 题面里既没有「保证没有重边」,`k` 的上限也一个字没提// ⇒ 「同一对点之间有好几条线」是题面**允许**的输入。// ★ 顺手写的生成器(在 n(n−1)/2 个点对里随机挑,不重复)**结构上**造不出重边// ⇒ 那一档是精确的 0。
#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 g[105][105];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; cin >> n >> k; for (int i = 0; i < k; i++) { int a, b, m; cin >> a >> b >> m; g[a][b] = g[b][a] = m; // ✗ 第二条线把第一条盖掉了 }
vector<array<int, 3>> e; long long total = 0; for (int i = 1; i <= n; i++) for (int j = i + 1; j <= n; j++) if (g[i][j]) { e.push_back({g[i][j], i, j}); total += g[i][j]; }
sort(e.begin(), e.end()); for (int i = 1; i <= n; i++) fa[i] = i;
long long keep = 0; for (auto& t : e) { int x = find(t[1]), y = find(t[2]); if (x == y) continue; fa[x] = y; keep += t[0]; } cout << total - keep << '\n'; return 0;}点「运行 ▶」看结果
n ≤ 100 ⇒ 一张 101 × 101 的矩阵才 40 KB,顺手得很。可它在这道题上是错的。
B3647 那次的「重边不取 min」,问题是留下的那条选错了; 这道题还要总权和,而矩阵一个格子只放得下一条边 ⇒ 同一对计算机之间的第二条网线连同它的权值一起消失了。
| 300 轮 | 档 0 | 档 1 | ★★ 档 2 有重边 | 档 3 |
|---|---|---|---|---|
| 输入里真的有重边 | 0 | 0 | 300 | 0 |
| ⇒ 「用邻接矩阵存」被抓 | ★ 0 | ★ 0 | ★ 300 | ★ 0 |
★ 又一个一个不差。⚠ 而那三个 0 是生成器缺一档: 顺手写的生成器在「互不相同的点对」里挑边,结构上造不出重边。
⚠⚠ 而这一档我自己踩了一跤:第一版生成器搭完树之后没记住用掉的点对, 树边和额外边会撞成重边 —— 于是档 0 那一列本该是精确的 0,实测却抓了 193 次。 ⇒ 生成器注释里写的「这一档没有重边」,也要跑一遍才算数。
5★★★ 兑现本章第 10 步:两片森林可能不一样,权和必须相同
拿 Kruskal 和一份 Prim 版最小生成森林(对每个连通块各长一次)逐组比:
| 300 组 | 档 0(畅通程度 1~30) | ★ 档 3(只取 1~3,大量并列) |
|---|---|---|
| 两版留下的边集不同 | 8 | ★ 74 |
| 两版权和不同 | ★ 0 | ★ 0 |
⇒ 和 P1546 那一页结论一致(那边是 6 / 39,这边是 8 / 74),而且并列一多就分岔:
- 别比边集;
- 答案那个数可以逐字节比 —— 这道题输出的恰好是那个不变量。
★ ⇒ 「对拍该比什么」的答案,写在题面的输出格式里(B3644 那条的正面版本)。
6⚠ 题面自己打了一次架 —— 而那句话是噪声
题目背景里写着:
f(i,j)为0表示i, j之间无网线连接。
而输入格式说的是「接下来的 k 行每行三个正整数」,数据范围说的是 1 ≤ f(i,j) ≤ 1000。
⇒ 三句话合起来:输入里根本不会出现 0 —— 图是按「k 条边」给的,不是按矩阵给的。
那句背景是在描述「概念上的 f」,不是在描述输入。
★ 按第 12 章那个判据称一称:造一档违反它的数据?造不出来 ——
1 ≤ f 已经把 0 挡死了。⇒ 它是噪声。
⚠ 而这跟隔壁 P1195 恰好相反:那道题的数据范围明写着 0 ≤ L,
同一个「0」,一边是噪声,一边值一整个错法。
n = 100 的完全图(无重边)有 |
4950 条边 |
| ⇒ 总权和上界 | 4950 × 1000 = 4 950 000(int 绰绰有余) |
而要撑破 int 需要 |
★ 2 147 483 条边 |
⇒ 只要 k 不超过两百万条,int 就够。可题面一个字都没保证 ——
⇒ 这是少见的「算不出确定答案」的情形:long long 是一次三十秒的保险,写上不亏。
★ 对照前面那些页:那些题的界都能从题面乘出来,这道题乘不出来。
7★ 对拍这一页
参照物是枚举「留下哪些网线」的所有子集:要求留下的那批 ① 没有回路 ② 连通性和原图一模一样, 在这样的子集里取权和最小的 —— 直接照题面那三句话走。
300 轮(n 随机 4~6) |
档 0 默认 | ★ 档 1 不连通 | ★★ 档 2 有重边 | ★ 档 3 大量并列 |
|---|---|---|---|---|
| Kruskal(正解) | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 反向 Kruskal | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 打出留下的那个数 | 293 | 299 | 298 | 262 |
非要收够 n − 1 条 |
★ 0 | ★ 45 | ★ 0 | ★ 0 |
| 用邻接矩阵存 | ★ 0 | ★ 0 | ★ 300 | ★ 0 |
| 错法 | 触发条件 | 触发几组 | 真被抓 | 比 |
|---|---|---|---|---|
| 用邻接矩阵存 | 输入里有重边(档 2) | 300 | 300 | ★ 1.0 |
| 打出留下的 | 「删掉的」≠「留下的」(档 0) | 293 | 293 | ★ 1.0 |
非要收够 n − 1 条 |
图不连通(档 1) | 176 | 45 | ⚠ 3.9 |
⇒ 前两个是能证的等价,第三个是两层的 —— 而第二层(正解答案 ≠ 0)数出来之后, 它同样是 45 ≡ 45,一个不差。 ★★ 所以「差 3.9 倍」不是这个 bug 的性质,是「我第一层写得太粗」的性质。
| 错法 | 抓获率(最狠的那一档) | 官方样例挡住了吗 |
|---|---|---|
| 打出留下的那个数 | 299 / 300 | ★ 挡住了(打出 11) |
| 用邻接矩阵存 | 300 / 300 | ⚠ 放过了 —— 样例五条边、没有重边 |
非要收够 n − 1 条 |
45 / 300 | ⚠ 放过了 —— 样例是连通的 |
⇒ 又一次:中间那个也是 每组都错型(在它的档上 300 / 300),样例照样放过 —— 因为那组数据在结构上问不出这个问题(P1746 那条)。
8度量程序和生成器
9一页纸
| ★★ 关键的一步 | 「删掉的最大」= 总权和 − 「留下的最小」,而留下的必是生成森林 |
| ⚠⚠ 题面轻描淡写的两处 | 没保证连通(求森林别求树)/ k 的上限一个字没给(别开死数组) |
| ★ 另一条路 | 反向 Kruskal「从大到小能删就删」也对(1200 / 1200),⚠ 但慢 28 倍 |
| ★★★ 293 ≡ 293 | 「打出留下的」蒙对的那 7 轮 ≡「删掉的恰好等于留下的」;并列档放大到 38 |
| ★★ 45 vs 176 | 不连通 176 组只抓 45 —— 131 组两版一起输出 0,把中间那层数出来才对上 |
| ★★★ 存不得矩阵 | 它丢的不只是「选错边」,是整条重边的权值(档 2 抓 300 / 300) |
| ⚠⚠ 自己踩的 | 生成器搭完树没记点对 ⇒ 档 0 混进重边,那一列本该是 0 却抓了 193 |
| ★ 同一个「0」 | 这道题的 f = 0 是噪声(1 ≤ f 挡死),隔壁 P1195 的 0 ≤ L 值一整个错法 |