第 30 章结尾我写了这么一句:
拓扑排序其实还是 BFS,只是「什么时候能入队」的条件变了。 副产品:队列空了却还有点没出来 = 图里有环。
这一章要做的就是把这两句话落到实处。而且这一章还有一件前 30 章都没碰到过的麻烦:
⚠ 答案不唯一。 同一张图往往有几十上百个都对的顺序 —— 而对拍是逐字节比字符串的。
第 7 步会正面处理它,那一节的收获(验证器)以后每次遇到「答案不唯一」都要用上。
1一句话问题
有
n个任务和m条依赖,每条写成u v,意思是「u 必须排在 v 前面」。 (⚠ 可能有重边,也可能有自环。)
- 如果根本排不出来,输出
-1;- 否则输出一个合法的顺序 —— 有多个合法顺序时,输出字典序最小的那个。
题面本来只需要说「输出一个合法顺序」。加上「字典序最小」纯粹是为了把答案钉唯一 —— 否则你和标准答案各给一个都对的顺序,对拍会判你错。
第 7 步会看到另一条出路(写验证器),以及为什么这一章两条都用上了。 遇到答案不唯一的题,先想清楚怎么验,再动手写。
2手算一遍:6 个任务、6 条依赖
6 6
5 1 ┐
5 3 ├ 5 → 1 → 3(外加一条 5 → 3)
1 3 ┘
6 4 ┐
6 2 ├ 6 → 4 → 2(外加一条 6 → 2)
4 2 ┘
画出来是两条互不相干的链:5 → 1 → 3 和 6 → 4 → 2
(每条链上还多了一条「跨一格」的边,那是故意的,第 8 步会用到)。
一上来谁也不欠的只有 5 号和 6 号。既然要字典序最小,就先做 5:
| 这一步能做的 | 挑谁 | 已排好 |
|---|---|---|
| 5、6 | 5 | 5 |
| 1、6 | 1 | 5 1 |
| 3、6 | 3 | 5 1 3 |
| 6 | 6 | 5 1 3 6 |
| 4 | 4 | 5 1 3 6 4 |
| 2 | 2 | 5 1 3 6 4 2 |
5 1 3 6 4 2 —— 这组数后面每一步都会回来验。
⚠ 请特别注意它不是 1 2 3 4 5 6。这也是故意的 —— 第 11 步会看到,
如果数据里「编号顺序本身就是合法答案」,一整批错法会集体隐身。
3暴力:不用队列、不用入度数组,每一轮从头扫一遍
// 标准答案 —— 不用队列、不用入度数组,每一轮从头扫一遍//// 输入:第一行 n m(n 个任务、m 条依赖),接下来 m 行 `u v`,意思是「u 必须排在 v 前面」// ⚠ 可能有重边,也可能有自环(自环 = 「我必须排在我自己前面」= 无解)// 输出:排不出来(有环)就输出 -1;否则输出**字典序最小**的那个合法顺序//// 这份代码的想法朴素得不能再朴素:// 一轮一轮地挑人。每一轮扫描所有还没被挑走的任务,// 看它的**前驱是不是全都已经被挑走了** —— 是的话它现在就可以做;// 在所有「现在就可以做」的任务里,挑**编号最小**的那个。// 某一轮下来一个都挑不出来 → 剩下的这些人互相卡住 → 有环 → -1。//// 它慢在哪:每挑一个人都要把整张图重新扫一遍,O(n × (n + m))。// 但它对得刺眼 —— **「可以做」这件事它是每次现算的**,// 而 fast.cpp 的入度数组是**增量维护**的(减一、减一、减到 0)。// 一个现算、一个增量维护,两个思路完全不同,这正是对拍要的// (第 9 章那条规矩:同一个思路写两遍只能验出打字错误)。//// ★ 为什么答案要规定成「字典序最小」:拓扑序通常**不唯一**,// 而对拍是逐字节比字符串的。不把答案钉成唯一的,两份都对的程序也会被判成不一致。// 正文第 5 步专门讲了这件事,那里还有 count.cpp 数出这张图到底有多少个合法顺序。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> pre(n + 1); // pre[v] = v 的所有前驱(可能重复,不去重) for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; pre[v].push_back(u); }
vector<char> done(n + 1, 0); vector<int> ans; for (int step = 0; step < n; step++) { int pick = -1; for (int v = 1; v <= n && pick < 0; v++) { if (done[v]) continue; bool ready = true; // ★ 每一轮都重新问一遍「它的前驱都走完了吗」 for (int u : pre[v]) if (!done[u]) { ready = false; break; } if (ready) pick = v; // v 从小到大扫,第一个可用的就是编号最小的 } if (pick < 0) { cout << -1 << "\n"; return 0; } // 一个都挑不出来 = 有环 done[pick] = 1; ans.push_back(pick); }
for (int i = 0; i < n; i++) cout << ans[i] << " \n"[i == n - 1]; return 0;}点「运行 ▶」看结果
想法朴素得不能再朴素,就是第 2 步那张表的直译:
一轮一轮地挑人。每一轮扫描所有还没被挑走的任务,看它的前驱是不是全都被挑走了 —— 是的话它现在就能做;在所有能做的里面挑编号最小的。 某一轮一个都挑不出来 → 剩下的人互相卡住 → 有环 →
-1。
- 暴力:「能不能做」每一轮都现算(把前驱重新问一遍);
- 正解:给每个点记一个
in[v],增量维护(减一、减一、减到 0)。
一个现算、一个增量维护 —— 不是同一个想法写两遍,所以它们不太可能一起错 (第 9 章那条规矩)。
顺带把有环那张也跑一遍,-1 那一支从一开始就要在场:
4实测:暴力慢在哪
暴力每挑一个人,就要把所有人重新问一遍 —— O(n × (n + m)),n 一翻倍它就四倍地慢。
本机实测(./genBig <n>,边数取 2n,固定种子):
| 命令 | 任务数 n | 暴力(每轮从头扫) | Kahn + 小根堆 |
|---|---|---|---|
./genBig 10000 |
1 万 | 0.20 秒 | 0.01 秒 |
./genBig 20000 |
2 万 | 0.80 秒 | 0.02 秒 |
./genBig 40000 |
4 万 | 3.80 秒 | 0.03 秒 |
./genBig 80000 |
8 万 | 17.43 秒 | 0.07 秒 |
./genBig 160000 |
16 万 | 89.07 秒 | 0.15 秒 |
★ n 翻一倍,暴力慢四倍,正解只慢一倍。 16 万个任务时差了近 600 倍。
genBig 的边数我一开始想取小一点(n/5),好让数据小到网页那个「同题对比」小工具
也传得动(本地运行服务对输出有 64 KB 的上限,第 30 章刚为它吃过亏)。
实测发现不行:边一少,暴力里那句「找到第一个能做的就停」几乎立刻命中 ——
n = 20000 时它只要 0.10 秒,比 m = 2n 时快了八倍。
暴力又一次假装自己不慢,这次让它偷懒的是「提前 break」。
所以这张表老老实实用 m = 2n,而且只能在终端里跑(数据传不进网页):
g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o brute brute.cpp
g++ -O2 -std=c++17 -o fast fast.cpp
./genBig 40000 > big.txt
time ./brute < big.txt > /dev/null # 3.8 秒
time ./fast < big.txt > /dev/null # 0.03 秒5★ 关键一步:入度减到 0 才能入队
// 拓扑排序 —— Kahn 算法:★ 它其实就是第 30 章那个 BFS,只是「什么时候能入队」的条件变了//// 和 brute.cpp 解同一道题,答案必须一样,但快得多。//// ★ 这一章的关键一步,一句话://// BFS 里「能入队」的条件是「没来过」;// 拓扑排序里「能入队」的条件是「**所有前驱都已经出队了**」。//// 而「所有前驱都出队了」这件事,不需要每次去数 —— 只要给每个点记一个 in[v]// (还有几个前驱没出队),每当一个前驱出队,就把它的所有后继 in 减一,// **谁减到 0,谁就该入队了**。//// 注意这句话的形状和第 21 章那句一模一样:**「依赖谁,就先填谁」**。// 第 21 章是 DP 的填表顺序、第 26 章是区间、第 27 章是后序遍历、第 28 章是 S 从小到大,// 这一章它终于以最直白的样子出现了 —— **拓扑序就是「依赖顺序」这四个字本身。**//// ★ 副产品(而且是白送的)://// 队列空了,可输出的点却不够 n 个 ⇔ 图里有环。//// 为什么:剩下的那些点,每一个都还欠着至少一个前驱;// 顺着「谁欠谁」一直往回走,点是有限的,早晚会走回一个来过的点 —— 那就是一个环。// 反过来,有环的话环上的点谁也别想把入度减到 0。**判环不用另写一份代码。**//// ⚠ 这里用的是**小根堆**而不是普通队列,因为题目要「字典序最小」的那个答案。// 换成普通 queue 也能得到一个**合法**的拓扑序 —— 只是不一定是字典序最小的那个。// 拓扑序通常不唯一,这是本教材第一道答案不唯一的题,正文第 5 步专门讲。//// 复杂度:每条边只被处理一次,每个点进出堆各一次 → O((n + m) log n)。// (不要字典序最小的话用普通队列,就是 O(n + m)。)
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); // g[u] = u 指向的那些点(有向图只存一遍!) vector<int> in(n + 1, 0); // in[v] = 还有几个前驱没出队
for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; // ★ 入度加在**箭头指向**的那一头 }
// 一开始就没有任何前驱的点,直接入队 priority_queue<int, vector<int>, greater<int>> q; // 小根堆 = 每次取编号最小的 for (int v = 1; v <= n; v++) if (in[v] == 0) q.push(v);
vector<int> ans; while (!q.empty()) { int u = q.top(); q.pop(); ans.push_back(u); // ★ 出队时才输出,不是入队时 for (int v : g[u]) { if (--in[v] == 0) q.push(v); // ★ 减到 0 才入队;没减到 0 说明它还欠着别人 } }
// ★ 队列空了却还有人没出来 —— 他们互相卡死,图里有环 if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; }
for (int i = 0; i < n; i++) cout << ans[i] << " \n"[i == n - 1]; return 0;}点「运行 ▶」看结果
| 第 30 章的 BFS | 这一章的拓扑排序 | |
|---|---|---|
| 能入队的条件 | 没来过 | 所有前驱都已经出队了 |
| 容器 | 队列 | 队列(要字典序最小就换成小根堆) |
| 出队之后干什么 | 把邻居入队 | 把后继的入度减一,减到 0 的入队 |
而「所有前驱都出队了」这件事不需要每次去数:
给每个点记一个 in[v](还欠着几个前驱),一个前驱出队就给它的所有后继减一 ——
for (int v : g[u])
if (--in[v] == 0) q.push(v); // ★ 减到 0,才轮到它★ 注意这句话的形状:「依赖谁,就先填谁」。 第 21 章是 DP 的填表顺序、第 26 章是区间、第 27 章是后序遍历、第 28 章是 S 从小到大 —— 这是它第六次登场,而这一次它以最直白的样子出现:
拓扑序就是「依赖顺序」这四个字本身。 前面那五章其实都在做拓扑排序,只是那些图太规整,顺序一眼就能看出来,用不着真的排。
if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; }就这一句,判环就做完了。为什么它是对的,两句话:
- 剩下的那些点,每一个都还欠着至少一个前驱;
- 顺着「谁欠谁」一直往回走,点是有限的,早晚会踩回一个来过的点 —— 那就是一个环。
反过来也成立:有环的话,环上的点谁也别想把入度减到 0,他们注定卡在原地。
判环不用另写一份代码,它就是「出队够不够 n 个」这一个数字。
⚠ 而正因为它是白送的,它也是最容易被漏掉的 —— 白送到你注意不到自己没接住。
第 8 步那个 wrongNoCycle.cpp 就是这么来的。
6动画:入度一个个减下去
点上面那个数字是它还欠着几个前驱,减到 0 就变绿、进容器。 右边那个大数字是 ★ 还没出来的点数 —— 队列空了它还不是 0,就说明有环。
按一下「换成有环那张」(在默认图上加一条 3 → 5),你会看到 5、1、3 三个点
入度永远降不到 0,队列早早就空了,计数器停在 3。那就是判环的全部现场。
下拉框里那四个错误版本,第 8 步逐个讲。
7★ 答案不唯一 —— 这一章真正的新东西
先看另一种完全不同的拓扑排序:DFS 的后序逆序。
// 第二种拓扑排序:DFS 的**后序逆序**//// 这份代码存在的理由,不是「再给一种写法」,而是要制造一件事:// **它和 fast.cpp 给出的顺序通常不一样,但两个都对。**// 这就是正文第 5 步那节「答案不唯一」最好的证据 —— 不是我说的,是两份代码打出来的。//// 想法(★ 一句话):// 对 u 做 DFS,把它能到达的点**全部处理完之后**,再把 u 记下来。// 于是「u 之后才被记下来的点」,全都是 u 到不了的 —— 把这张记录表**倒过来**,// u 就排在所有它能到达的点前面。这正是拓扑序要的。//// ★ 「递归回来之后才做事」= 后序遍历 —— 第 27 章树形 DP 那一句「依赖谁就先填谁」,// 在这里第二次出现,而且这次是在有向图上。//// 判环用**三种颜色**(这是有向图判环的标准写法,和无向图的 vis 不是一回事):// 0 = 还没碰过// 1 = **正在递归中**(它在当前这条路径上)// 2 = 已经彻底处理完(连同它能到的所有点)// 走到一个颜色为 1 的点 → 说明当前这条路绕回了自己 → **有环**。// ⚠ 走到颜色为 2 的点是完全正常的(只是「以前从别处来过」),不是环。// 只用一个 vis 数组的话,这两种情况分不出来 —— 这是有向图判环的头号错误。//// 输出格式和 fast.cpp 一致(有环输出 -1),但顺序不保证字典序最小。// scripts/check-viz.mjs 对它做的是**硬验证**:拿 check.cpp 逐条边检查它是不是合法拓扑序,// 并且核对「有没有环」这个结论必须和 fast.cpp 一致。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<vector<int>> g;vector<int> color; // 0 没碰过 / 1 正在递归中 / 2 处理完了vector<int> post; // 处理完的先后顺序bool hasCycle = false;
void dfs(int u) { color[u] = 1; // 进入:标成「正在递归中」 for (int v : g[u]) { if (color[v] == 1) { hasCycle = true; return; } // ★ 撞上路径上的点 = 环 if (color[v] == 0) { dfs(v); if (hasCycle) return; } // color[v] == 2:以前从别处走完过它,正常,跳过 } color[u] = 2; // 离开:它和它能到的点都处理完了 post.push_back(u); // ★ 后序:递归回来之后才记}
int main() { if (!(cin >> n >> m)) return 0; g.assign(n + 1, {}); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); }
color.assign(n + 1, 0); for (int v = 1; v <= n && !hasCycle; v++) if (color[v] == 0) dfs(v);
if (hasCycle) { cout << -1 << "\n"; return 0; }
reverse(post.begin(), post.end()); // ★ 后序,倒过来 for (int i = 0; i < n; i++) cout << post[i] << " \n"[i == n - 1]; return 0;}点「运行 ▶」看结果
它跑出 6 5 4 2 1 3 —— 和正解 5 1 3 6 4 2 完全不一样。
但两个都是对的。
「不唯一」是个含糊的词。数一下:
// 这张图到底有多少个合法拓扑序?—— 用第 28 章那套状压 DP 数出来//// 存在的理由:正文第 5 步说「拓扑序通常不唯一」,// 但「不唯一」是个含糊的词。**到底有几个?** 这份代码把它变成一个数。//// ★ 状态就是第 28 章那句话:**一个集合就是一个整数。**// f[S] = 「把集合 S 里的任务排成一个合法的前缀」有多少种排法// 转移:枚举下一个要做的任务 v(v ∉ S,且 **v 的所有前驱都在 S 里**),// f[S | 1<<v] += f[S]// 初值 f[空集] = 1,答案 f[全集]。// 填表顺序还是「S 从小到大」—— `S | (1<<v)` 一定比 S 大,依赖自动就绪(第 28 章证过)。//// ⚠ 有环时答案自然就是 **0**:环上的点永远凑不齐前驱,全集根本到不了。// **判环在这里也是白送的**,和 fast.cpp 那句「出队不够 n 个」是同一件事的两个面孔。//// 复杂度 O(2ⁿ × n),所以只能玩小图(这里限制 n ≤ 20)——// 但用来「把不唯一说清楚」,小图正合适。//// 用法:./count 数一共有多少个,并打印字典序最小的那个// ./count all 再把**全部**合法顺序按字典序列出来(超过 40 个就只列前 40 个)//// 计数答案一律 long long(本教材的规矩:对拍查不出溢出,这只能靠脑子)。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> preMask; // preMask[v] = v 的前驱集合(位掩码,0 基)vector<vector<int>> all; // ./count all 时用vector<int> cur;
void listAll(int S) { if (S == (1 << n) - 1) { all.push_back(cur); return; } if ((int)all.size() >= 40) return; for (int v = 0; v < n; v++) { // v 从小到大 → 列出来的就是字典序 if (S >> v & 1) continue; if ((preMask[v] & S) != preMask[v]) continue; cur.push_back(v + 1); listAll(S | 1 << v); cur.pop_back(); if ((int)all.size() >= 40) return; }}
int main(int argc, char** argv) { bool showAll = (argc > 1 && string(argv[1]) == "all");
if (!(cin >> n >> m)) return 0; if (n > 20) { printf("n = %d 太大了,这份代码只玩 n <= 20 的小图\n", n); return 0; } preMask.assign(n, 0); vector<pair<int, int>> es(m); for (auto& [u, v] : es) { cin >> u >> v; preMask[v - 1] |= 1 << (u - 1); // 自环会让 v 把自己列为前驱 → 永远凑不齐 }
vector<long long> f(1 << n, 0); f[0] = 1; for (int S = 0; S < (1 << n); S++) { if (f[S] == 0) continue; for (int v = 0; v < n; v++) { if (S >> v & 1) continue; if ((preMask[v] & S) != preMask[v]) continue; // ★ 前驱还没齐,现在还不能做它 f[S | 1 << v] += f[S]; } }
long long total = f[(1 << n) - 1]; printf("%d 个任务、%d 条依赖:一共有 %lld 个合法的拓扑序\n", n, m, total); if (total == 0) { printf("★ 0 个 —— 图里有环,谁也排不出来。(判环在这里也是白送的。)\n"); return 0; }
// 字典序最小的那个:每一步贪心地取「现在能做的、编号最小的」 int S = 0; printf("字典序最小的那个是:"); for (int step = 0; step < n; step++) for (int v = 0; v < n; v++) { if (S >> v & 1) continue; if ((preMask[v] & S) != preMask[v]) continue; printf("%d%c", v + 1, step == n - 1 ? '\n' : ' '); S |= 1 << v; break; }
if (showAll) { listAll(0); printf("\n按字典序列出来(最多 40 个):\n"); for (size_t i = 0; i < all.size(); i++) { printf(" 第 %2d 个:", (int)i + 1); for (int x : all[i]) printf("%d ", x); printf("\n"); } if (total > (long long)all.size()) printf(" ……还有 %lld 个没列\n", total - (long long)all.size()); } return 0;}点「运行 ▶」看结果
20 个。 而且这个 20 是可以心算的:默认那张图是两条互不相干的链
(5→1→3 和 6→4→2),把它们交错排进 6 个位置,就是「从 6 个位置里挑 3 个给第一条链」——
C(6,3) = 20★ 数它用的是第 28 章那套状压 DP:
f[S] = 把集合 S 里的任务排成一个合法前缀,有多少种排法
转移:枚举下一个做谁(v ∉ S,且 v 的所有前驱都在 S 里),f[S | 1<<v] += f[S]填表顺序还是「S 从小到大」,理由和第 28 章一字不差(S | (1<<v) 一定比 S 大)。
顺带,判环在这里也是白送的:有环时环上的点永远凑不齐前驱,全集根本到不了,答案自然是 0。
这棵树把「不唯一」摊开给你看:每一层是「这一步可以做谁」,每一条从上到下的路径都是一个正确答案, 一共 20 条。蓝色那条是「每一步都挑编号最小的分支」走出来的 —— 那就是字典序最小的答案, 也正是小根堆干的事。(第 3 章那句「递归 = 决策树」在这里第二次登场。)
出路一:把答案钉唯一。 题面加一句「输出字典序最小的那个」,
代码里把队列换成小根堆。好处是能直接逐字节对拍;
代价是多了一层和拓扑排序本身无关的东西(而且慢了个 log n)。
出路二:不比答案,比性质。 写一个验证器,只问「你给的这个顺序合不合法」:
// ★ 验证器:给一个顺序,判断它是不是这张图的**合法**拓扑序//// 这是本章新添的一件工具,而且它解决的是一个前 30 章都没遇到过的麻烦://// **答案不唯一的时候,怎么对拍?**//// 前面所有章节的答案都是唯一的(最大价值、最短距离、连通块个数……),// 所以对拍可以粗暴地逐字节比字符串。拓扑序不行 —— 同一张图往往有几十上百个合法顺序,// 两份都正确的程序打出不同的字符串,是**完全正常**的。//// 两条出路,本章两条都用上了:// ① **把答案钉成唯一的**:规定输出「字典序最小」的那个(fast.cpp / brute.cpp 走这条)。// 好处是能直接对拍,坏处是多了一层「小根堆」,掩盖了拓扑排序本身。// ② **不比答案,比性质**:写一个验证器,只问「你给的这个顺序合不合法」。// dfsTopo.cpp 给出的是另一个顺序,就靠这份代码来证明它也是对的。//// ⚠ 记住 ② 的代价:**验证器只能证明「这个答案合法」,不能证明「答案存在时你没漏报」。**// 一份永远输出 -1 的程序,能通过所有「合法性」检查 —— 所以判环的结论还得单独对。// (这和第 20 章那句「对拍只能证伪」是同一件事的另一面。)//// 用法:./check < 输入 —— 输入 = 原题输入 + 最后一行待检查的顺序// 输入格式:n m / m 行边 / 一行 n 个数(或者一个 -1,表示「它认为有环」)//// 输出:一行结论 + 每条不合法的边。scripts/check-viz.mjs 就是拿它做硬验证的。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<pair<int, int>> es(m); for (auto& [u, v] : es) cin >> u >> v;
vector<int> ord; int x; while (cin >> x) ord.push_back(x);
if (ord.size() == 1 && ord[0] == -1) { printf("这份答案说「有环,排不出来」—— 验证器管不了这种断言,得另外核对\n"); return 0; }
// ① 必须正好是 1..n 的一个排列 if ((int)ord.size() != n) { printf("不合法:给了 %d 个数,应该是 %d 个\n", (int)ord.size(), n); return 0; } vector<int> pos(n + 1, -1); for (int i = 0; i < n; i++) { int v = ord[i]; if (v < 1 || v > n) { printf("不合法:出现了不存在的编号 %d\n", v); return 0; } if (pos[v] != -1) { printf("不合法:编号 %d 出现了两次\n", v); return 0; } pos[v] = i; }
// ② 每一条边都必须是「前面指向后面」 int bad = 0; for (auto [u, v] : es) { if (pos[u] >= pos[v]) { if (++bad <= 5) printf(" ✗ 违反了依赖 %d -> %d:%d 排在第 %d 位,%d 却排在第 %d 位\n", u, v, u, pos[u] + 1, v, pos[v] + 1); } }
if (bad == 0) printf("合法:是 1..%d 的排列,而且 %d 条依赖全都是「前面指向后面」\n", n, m); else printf("不合法:%d 条依赖被违反了\n", bad); return 0;}点「运行 ▶」看结果
它只查两件事,缺一不可:
① 必须正好是 1..n 的一个排列;② 每一条依赖都得是「前面指向后面」。
上面这一组查的就是 dfsTopo.cpp 给的那个顺序 —— 它跟正解不一样,但合法。
⚠ 验证器有个必须记住的盲区:它只能证明「这个答案合法」,
不能证明「答案存在时你没漏报」 —— 一份永远输出 -1 的程序能通过所有合法性检查。
所以判环的结论还得单独对一遍。
(这和第 20 章那句「对拍只能证伪」是同一件事的另一面。)
本章两条出路都用上了:主对拍走出路一(brute vs fast,逐字节比),
而 dfsTopo.cpp 走出路二 —— check:viz 每轮都拿验证器验它一遍,
再单独核对它的判环结论和 Kahn 一致。
8四种把它写错的方式
// ✗ 错误版本二:用普通队列,而不是小根堆//// ★ 这一份特殊:**它给出的顺序是完全合法的拓扑序**,check.cpp 逐条边查都能通过。// 它错的只有一件事 —— 题目要「字典序最小」的那个,而它给的是「先入队先出」的那个。//// 把它留下来,是因为它把这一章两件事同时点破了:// ① **拓扑序不唯一**:同一张图,两份都「对」的代码给出不同的答案;// ② 所以**题面必须把答案钉唯一**(或者改用验证器对拍)。// 对拍是逐字节比字符串的 —— 题面含糊一点,对拍就没法做。//// 默认那张图上:正解 `5 3 1 6 2 4`,它给 `5 6 3 1 2 4` —— 两个都合法。//// ⚠ 它是否会被抓住,同样取决于生成器:// 如果任何时刻「入度为 0 的点」都只有一个,那拓扑序本来就唯一,// 小根堆和普通队列**给出的东西一模一样**,这份代码永远是对的。// 一条链就是这种图。正文第 11 步有实测。//// 只改了一行:priority_queue → queue。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> in(n + 1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; }
queue<int> q; // ✗ 普通队列:先入队的先出,和编号大小无关 for (int v = 1; v <= n; v++) if (in[v] == 0) q.push(v);
vector<int> ans; while (!q.empty()) { int u = q.front(); q.pop(); ans.push_back(u); for (int v : g[u]) if (--in[v] == 0) q.push(v); }
if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; } for (int i = 0; i < n; i++) cout << ans[i] << " \n"[i == n - 1]; return 0;}点「运行 ▶」看结果
跑出 5 6 1 4 3 2。★ 这一份特殊:它给的顺序完全合法,验证器查都查不出问题 —— 它只是不是题目要的「字典序最小」那个。
把它留着,是因为它一个人就把这一章两件事都说清了: 拓扑序不唯一,所以题面必须把答案钉唯一。
// ✗ 错误版本五:在**入队**的时候就把点记进答案,而不是出队的时候//// 第 30 章刚讲过「vis 要在入队时打」,这一章正好反过来 ——// **记答案必须在出队时**。两句话看着像在打架,其实说的是同一件事://// 入队时打 vis:是为了「别让同一个点被塞两次」,越早越好。// 出队时记答案:是因为**顺序是由出队决定的**,而不是由入队决定的。//// ★ 用普通队列时这两者恰好一样(先进先出,入队序 = 出队序),所以这个 bug 会隐身;// 一旦换成**小根堆**,堆会把队列里的元素重新排一遍 ——// 「进去的顺序」和「出来的顺序」就分家了,这份代码立刻错。//// 这也解释了为什么它在第 30 章那种纯 BFS 里从来不出问题:那里根本没有堆。//// 只改了两行:ans.push_back 从出队那里挪到了入队那里。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> in(n + 1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; }
vector<int> ans; priority_queue<int, vector<int>, greater<int>> q; for (int v = 1; v <= n; v++) if (in[v] == 0) { q.push(v); ans.push_back(v); } // ✗ 入队就记
while (!q.empty()) { int u = q.top(); q.pop(); for (int v : g[u]) if (--in[v] == 0) { q.push(v); ans.push_back(v); } // ✗ 入队就记 }
if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; } for (int i = 0; i < n; i++) cout << ans[i] << " \n"[i == n - 1]; return 0;}点「运行 ▶」看结果
跑出 5 6 1 3 4 2。第 30 章刚讲过「vis 要在入队时打」,这里正好反过来 ——
记答案必须在出队时。两句话不打架,说的是同一件事:
- 入队时打
vis:是为了「别让同一个点被塞两次」,越早越好; - 出队时记答案:因为顺序是出队决定的,不是入队决定的。
⚠ 用普通队列时这两者恰好一样(先进先出,入队序 = 出队序),所以这个 bug 会隐身; 一换成小根堆,堆把队列重排了一遍,进去的顺序和出来的顺序当场分家。 这也解释了它为什么在第 30 章那种纯 BFS 里从来不出问题 —— 那里根本没有堆。
// ✗ 错误版本三:入度减了,但没等它减到 0 就把点塞进队列//// 写成了:// --in[v];// q.push(v); // ✗ 不管减完是不是 0// 正确的是:// if (--in[v] == 0) q.push(v);//// 这一处的意思差得很远:`in[v] == 0` 的含义是「**所有**前驱都出队了」。// 只要有一个前驱走完就把 v 放出去,等于把「必须全部满足」偷偷降级成了「满足一个就行」。//// 症状有两种,而且**它们看起来完全不像同一个 bug**:// · v 被提前输出(排在某个前驱前面)→ 顺序违法;// · v 被塞进队列好几次 → 输出里出现重复编号,长度还超过 n。// 第二种正是 check.cpp 第一关(「必须是 1..n 的排列」)要拦的东西。//// ⚠ 有一类图完全抓不到它:**每个点最多只有一个前驱**(比如一棵树、一条链)——// 那时「满足一个」和「满足全部」是同一句话。// 所以生成器必须造得出「一个点被两条以上的边指着」的图。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> in(n + 1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; }
priority_queue<int, vector<int>, greater<int>> q; for (int v = 1; v <= n; v++) if (in[v] == 0) q.push(v);
vector<int> ans; while (!q.empty()) { int u = q.top(); q.pop(); ans.push_back(u); for (int v : g[u]) { --in[v]; q.push(v); // ✗ 没判 0 就入队 } if ((int)ans.size() > n + 5) break; // 防止死循环把输出撑爆(重复入队会越滚越多) }
if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; } for (int i = 0; i < (int)ans.size(); i++) cout << ans[i] << " \n"[i == (int)ans.size() - 1]; return 0;}点「运行 ▶」看结果
跑出 5 1 3 3 6 2 4 2 —— 8 个数,还有重复。
if (--in[v] == 0) q.push(v) 写成了 --in[v]; q.push(v);,
等于把「所有前驱都满足」偷偷降级成了「满足一个就行」。
⚠ 有一类图完全抓不到它:每个点最多只有一个前驱(一棵树、一条链)—— 那时两句话是同一个意思。所以默认那张图里,3 号和 2 号各有两个前驱,那两条「跨一格」的边就是为它准备的。
// ✗ 错误版本四:把依赖的方向整个读反了//// g[v].push_back(u); // ✗ 应该是 g[u].push_back(v);// in[u]++; // ✗ 应该是 in[v]++;//// 也就是把题面那句「u 必须排在 v 前面」读成了「v 必须排在 u 前面」。// 于是它老老实实地算出了**整张图反过来**之后的拓扑序 ——// 算法一点毛病都没有,错的是**读题**。//// ⚠ 注意它和「只把 in 那一行写错」是两回事。只写错 in(`in[u]++` 而 g 不动)// 得到的是一份自相矛盾的代码:初始队列装的是「没有出边的点」,// 而它们又没有后继可减 —— 队列立刻空掉,几乎每组数据都输出 -1,// 一眼就能看出不对。**方向读反反而更危险,因为它看起来完全正常。**//// ★ 这个 bug 的隐身条件很有意思,值得记住:// 如果生成器造出来的图**反过来还是一张 DAG**(有向图基本都是),// 它照样能输出一个「像模像样」的顺序 —— 长度对、编号不重复,// **check.cpp 第一关根本拦不住它**,只有第二关「每条边前面指向后面」才逮得到。// 这正好说明验证器为什么要查两件事,而不是只查「是不是排列」。//// 顺带:如果生成器顺手只连「编号小 → 编号大」,那么反过来就是「大 → 小」,// 两个方向的字典序最小拓扑序分别是 1 2 3 … 和 n … 2 1,差别巨大,反而一抓一个准。// **这个 bug 是本章五个里唯一一个「温柔数据反而抓得到」的** —— 正文第 11 步那张表里能看到。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> in(n + 1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[v].push_back(u); // ✗ 方向读反了 in[u]++; // ✗ 于是入度也记到了另一头 }
priority_queue<int, vector<int>, greater<int>> q; for (int v = 1; v <= n; v++) if (in[v] == 0) q.push(v);
vector<int> ans; while (!q.empty()) { int u = q.top(); q.pop(); ans.push_back(u); for (int v : g[u]) if (--in[v] == 0) q.push(v); }
if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; } for (int i = 0; i < n; i++) cout << ans[i] << " \n"[i == n - 1]; return 0;}点「运行 ▶」看结果
跑出 2 3 1 4 5 6 —— 算法一点毛病没有,错的是读题。 把它丢给验证器,6 条依赖全部被违反:
★ 请注意它过得了验证器的第一关(是 1..6 的排列,长度也对)。
这就是验证器为什么必须查两件事,而不是只查「是不是排列」。
// ✗ 错误版本一:忘了判环 —— 队列空了就直接把手上这些点打印出来//// 少的就是最后那一句 `if (ans.size() < n) 输出 -1`。//// 它的可怕之处在于:**只要图是 DAG,它就完全正确。**// 只有当图里真的有环时,它才会打出一个**长度不够 n** 的序列 ——// 而且那个序列里的每一条依赖居然都是满足的,验证器只查「前面指向后面」的话还查不出来//(check.cpp 第一关查的就是「必须正好是 1..n 的排列」,正是为它准备的)。//// ★ 所以它的死活完全捏在生成器手里:// 生成器只要顺手「只连编号小 → 编号大」的边(这是造 DAG 最省事的办法,// 也是几乎每个人的第一反应),图就永远无环,这份代码 **0 / 300**。// 正文第 11 步有实测。//// 顺带:这也是「白送的判环」最容易被漏掉的原因 ——// 它白送到你根本注意不到自己没接住。
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> in(n + 1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; }
priority_queue<int, vector<int>, greater<int>> q; for (int v = 1; v <= n; v++) if (in[v] == 0) q.push(v);
vector<int> ans; while (!q.empty()) { int u = q.top(); q.pop(); ans.push_back(u); for (int v : g[u]) if (--in[v] == 0) q.push(v); }
// ✗ 这里少了一句:if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; } if (ans.empty()) { cout << "\n"; return 0; } for (size_t i = 0; i < ans.size(); i++) cout << ans[i] << " \n"[i == ans.size() - 1]; return 0;}点「运行 ▶」看结果
在有环那张图上跑出 6 4 2 —— 只有 3 个数。它少的就是那句
if (ans.size() < n) 输出 -1。只要图是 DAG,它就完全正确 ——
这也正是它最危险的地方,第 11 步会看到它的死活完全捏在生成器手里。
9★ 一块试金石:什么都不做
// ✗ 「什么都不做」—— 判完环之后直接输出 1 2 3 … n//// 它不是一个真实的 bug(没人会不小心写出这个),它是一块**试金石**://// ★ **如果你的生成器连「什么都不做」都抓不住,那它什么都证明不了。**//// 而这块试金石专门用来打假一个特定的「顺手」写法:// 生成器造 DAG 时只连「编号小 → 编号大」的边(这是最省事的造法,也几乎是每个人的第一反应),// 于是数据白送了一条题目里没有的性质 —— **编号本身就是一个合法拓扑序,而且正好是字典序最小的那个**。// 这批数据上,这份代码 **0 / 300**:它和真正的正解一个字都不差。//// 把生成器改成「先随机一个排列,再连 perm[i] → perm[j]」之后// (图的形状一点没变,只是给点换了名字),它立刻 **297 / 300**。// 正文第 11 步那张表就是这么来的。//// 这一处改动和第 27 章「打乱树的点编号」是同一招:// **别让编号自己带上一层题目没给的含义。**
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); vector<int> in(n + 1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; }
// 判环这一步是老老实实做的 —— 免得它连「有环」都答不上来,那就太假了 { queue<int> q; vector<int> d = in; for (int v = 1; v <= n; v++) if (d[v] == 0) q.push(v); int out = 0; while (!q.empty()) { int u = q.front(); q.pop(); out++; for (int v : g[u]) if (--d[v] == 0) q.push(v); } if (out < n) { cout << -1 << "\n"; return 0; } }
// ✗ 然后就……直接按编号输出 for (int i = 1; i <= n; i++) cout << i << " \n"[i == n]; return 0;}点「运行 ▶」看结果
它不是一个真实的 bug —— 没人会不小心写出这个。它是一块试金石:
在默认那张图上它输出 1 2 3 4 5 6,一眼就错。可是 ——
如果生成器造 DAG 时只连「编号小 → 编号大」的边(这是最省事的造法, 也几乎是每个人的第一反应),那么数据就白送了一条题目里没有的性质:
编号本身就是一个合法拓扑序,而且正好是字典序最小的那个。
于是这份「什么都不做」的代码 300 轮全对。 一批连「什么都不做」都打不假的数据,你还能指望它验出什么呢?
修法只有一处,而且不改图的形状,只改名字:
先随机一个排列 perm,再连 perm[i] → perm[j](i < j)。
它立刻从 0 / 300 变成 265 / 300。
这一招和第 27 章「打乱树的点编号」是同一个动作: 别让编号自己带上一层题目没给的含义。
10★ 对拍
// 拓扑排序 —— Kahn 算法:★ 它其实就是第 30 章那个 BFS,只是「什么时候能入队」的条件变了//// 和 brute.cpp 解同一道题,答案必须一样,但快得多。//// ★ 这一章的关键一步,一句话://// BFS 里「能入队」的条件是「没来过」;// 拓扑排序里「能入队」的条件是「**所有前驱都已经出队了**」。//// 而「所有前驱都出队了」这件事,不需要每次去数 —— 只要给每个点记一个 in[v]// (还有几个前驱没出队),每当一个前驱出队,就把它的所有后继 in 减一,// **谁减到 0,谁就该入队了**。//// 注意这句话的形状和第 21 章那句一模一样:**「依赖谁,就先填谁」**。// 第 21 章是 DP 的填表顺序、第 26 章是区间、第 27 章是后序遍历、第 28 章是 S 从小到大,// 这一章它终于以最直白的样子出现了 —— **拓扑序就是「依赖顺序」这四个字本身。**//// ★ 副产品(而且是白送的)://// 队列空了,可输出的点却不够 n 个 ⇔ 图里有环。//// 为什么:剩下的那些点,每一个都还欠着至少一个前驱;// 顺着「谁欠谁」一直往回走,点是有限的,早晚会走回一个来过的点 —— 那就是一个环。// 反过来,有环的话环上的点谁也别想把入度减到 0。**判环不用另写一份代码。**//// ⚠ 这里用的是**小根堆**而不是普通队列,因为题目要「字典序最小」的那个答案。// 换成普通 queue 也能得到一个**合法**的拓扑序 —— 只是不一定是字典序最小的那个。// 拓扑序通常不唯一,这是本教材第一道答案不唯一的题,正文第 5 步专门讲。//// 复杂度:每条边只被处理一次,每个点进出堆各一次 → O((n + m) log n)。// (不要字典序最小的话用普通队列,就是 O(n + m)。)
#include <bits/stdc++.h>using namespace std;
int main() { int n, m; if (!(cin >> n >> m)) return 0; vector<vector<int>> g(n + 1); // g[u] = u 指向的那些点(有向图只存一遍!) vector<int> in(n + 1, 0); // in[v] = 还有几个前驱没出队
for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; g[u].push_back(v); in[v]++; // ★ 入度加在**箭头指向**的那一头 }
// 一开始就没有任何前驱的点,直接入队 priority_queue<int, vector<int>, greater<int>> q; // 小根堆 = 每次取编号最小的 for (int v = 1; v <= n; v++) if (in[v] == 0) q.push(v);
vector<int> ans; while (!q.empty()) { int u = q.top(); q.pop(); ans.push_back(u); // ★ 出队时才输出,不是入队时 for (int v : g[u]) { if (--in[v] == 0) q.push(v); // ★ 减到 0 才入队;没减到 0 说明它还欠着别人 } }
// ★ 队列空了却还有人没出来 —— 他们互相卡死,图里有环 if ((int)ans.size() < n) { cout << -1 << "\n"; return 0; }
for (int i = 0; i < n; i++) cout << ans[i] << " \n"[i == n - 1]; return 0;}300 轮实测,六个版本:
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
| 方向读反 | 236 / 300 | 第 1 轮 |
| 没减到 0 就入队 | 235 / 300 | 第 1 轮 |
| 什么都不做(试金石) | 211 / 300 | 第 2 轮 |
| 用普通队列 | 156 / 300 | 第 2 轮 |
| 入队时就记答案 | 156 / 300 | 第 2 轮 |
| 忘了判环 | 64 / 300 | 第 4 轮 |
(最后一行的 64 正好等于「这 300 轮里有 64 轮是有环的」—— 它只在有环时才可能出错, 所以 64 / 64,一轮不漏。)
11★ 生成器调了四次,每次只改一处
gen.cpp 带了五个档位(./gen 种子 档位)。种子固定 1..300:
| 档位 | 改了什么 | 有环轮数 | 什么都不做 | 忘了判环 | 普通队列 | 提前入队 | 方向反 | 入队就记 |
|---|---|---|---|---|---|---|---|---|
| 0(最初) | 编号不打乱 + 只连小→大 | 0 | 0 | 0 | 193 | 258 | 300 | 193 |
| 1 | 打乱编号 | 0 | 265 | 0 | 196 | 257 | 300 | 196 |
| 2 | 允许成环:每条边 1/3 反向、自环随便造 | 231 | 60 | 231 | 46 | 161 | 69 | 46 |
| 3 | 改成每三组挑一组允许成环 | 207 | 76 | 207 | 64 | 208 | 93 | 64 |
| 4(在用) | 自环也只在那一组里留 | 64 | 211 | 64 | 156 | 235 | 236 | 156 |
档位 0 → 1:只改了「给点换个名字」这一处 —— 图的形状一条边都没动 —— 「什么都不做」从 0 跳到 265。这是第 27 章那个坑的第五张脸。
档位 2 → 3 → 4:这是一个前面几章没遇到过的新毛病。
★ 前面四章踩的都是「某一支永远走不到」(-1 那一支、多连通块那一支)。
档位 2 一上来矫枉过正:300 轮里 231 轮都有环,而有环时所有程序一律输出 -1 ——
于是三个跟顺序有关的 bug 反而没机会现形(46 / 161 / 69)。
档位 3 把「允许成环」从「每条边」降到「每三组挑一组」,只涨了一点点(207 轮还是太多);
一查才发现大头是自环 —— n 只有几个的时候,随手就撞出一条「我必须排在我自己前面」。
档位 4 把自环也关进那一组,有环降到 64 / 300,三个顺序 bug 立刻涨到 156 / 235 / 236。
每一支都要有,而且都不能多到吃掉别人。 「某一支永远走不到」和「某一支占得太多」,是同一枚硬币的两面。
拿 genNice.cpp(编号就是拓扑序 + 永远无环)跑 300 轮:
| 什么都不做 | 忘了判环 | 普通队列 | 提前入队 | 方向反 | |
|---|---|---|---|---|---|
| genNice | 0 | 0 | 210 | 242 | 296 |
| 最终档 | 211 | 64 | 156 | 235 | 236 |
看最后一列:「方向读反」在温柔数据上反而抓得更准(296 vs 236)。
道理不难想 —— 数据里只有「小 → 大」的边,读反之后答案从 1 2 3 … 变成 … 3 2 1,
差得不能再明显;而在打乱编号的数据里,反过来的那个顺序反倒有可能碰巧也合法。
所以「换了生成器,抓获率整体变好」这种话是靠不住的, 必须一个 bug 一个 bug 地看。第 27 章那笔「档位 3 反而少抓三十几轮」的账, 第 30 章那笔「打散连通性反而让另外两个掉了」的账,都是同一回事: 要的是把 0 变成非 0,不是让平均分好看。
12这一章可以带走的四样东西
【1】拓扑排序就是换了入队条件的 BFS。
BFS: 没来过 → 入队
拓扑排序: 所有前驱都出队了 → 入队(记 in[v],减到 0 就是)而「依赖谁就先填谁」这句话,从第 21 章一路走到这里,第六次登场 —— 拓扑序就是「依赖顺序」这四个字本身。
【2】判环是白送的:队列空了,出队却不够 n 个。 剩下的人每个都还欠着前驱,顺着「谁欠谁」往回走一定会踩回来 —— 那就是环。 ⚠ 正因为白送,它也最容易被漏掉。
【3】答案不唯一时,先想清楚怎么验,再动手写。 两条出路: 把答案钉唯一(小根堆 + 「字典序最小」),或者写验证器(查排列 + 查每条边前指后)。 ⚠ 验证器的盲区:它证明不了「答案存在时你没漏报」,判环的结论得单独对。
【4】生成器的两头都要看。 既不能让某一支永远走不到(编号就是拓扑序 / 永远无环 → 两个 bug 全是 0 / 300), 也不能让某一支占得太多(231 轮都有环 → 顺序类的 bug 全被挤没)。 调的时候一次只改一处、每次都实测,而且每一档都留成参数,让读者能整张表重跑。
第 32 章:最短路一 —— Dijkstra。
第 30 章的 BFS 已经能求最短路了,但那是每条边都一样长的情况。 边一带上权,「一圈一圈往外扩」就不成立了 —— 走三条短边可能比走一条长边还近。
★ 关键一步是一个贪心:每次取「当前最近的、还没定下来的点」,它的距离当场就定死了。 为什么这个贪心是对的(接阶段 4 那套交换论证),以及为什么有负权边就不行 —— 下一章会把这两件事讲透,而且照例拿一份完全不同思路的代码(Floyd)来对拍。
顺带你会发现:把这一章的小根堆原样搬过去,就是「堆优化 Dijkstra」。 容器换了,套路一点没变。
13自测
- 洛谷 B3644 【模板】拓扑排序解析 → —— 本章模板题。写完对着 fast.cpp 逐行检查一遍
- 洛谷 P1113 杂务解析 → —— ★ 拓扑序 + DP:每个任务的最早完成时间。第 21 章那句「依赖谁就先填谁」在这里字面成立
- 洛谷 P1347 排序解析 → —— ★ 边一条一条加进来,每加一条就判一次「已确定 / 有矛盾 / 还不确定」。逼你想清楚「拓扑序唯一」是什么意思
- 洛谷 P4017 最大食物链计数解析 → —— 拓扑序上做计数 DP。答案要取模,正好复习第 42 章要讲的那些坑
- 洛谷 P1983 [NOIP2013 普及组] 车站分级解析 → —— 进阶:难点全在建图上,边要靠「虚点」来省。建完图之后就是模板
- 洛谷 P2712 摄像头解析 → —— 判环 + 拓扑删点:入度 = 被几个摄像头监视着,剥不掉的就是环里的。⚠ 题面写着「0 ≤ x, y ≤ 500」——「位置编号本来就小,开个 501 的数组就够,不用离散化」;真正没被题面排除的是「自环」(摄像头监视自己所在的位置)