阶段 6 · 图论 · 第 31 章提高组 S

拓扑排序

图变成有向的,就冒出一个新问题:先做哪个。★ 关键一步是把上一章那个 BFS 的入队条件换掉 ——「入度减到 0 才入队」,而判环是白送的。

需要先学:第 30 章 图上的 DFS 与 BFS、连通性例题:任务排序 + 判环建议用时:110 分钟
上一章章末那句话,这一章要当场兑现

第 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暴力:不用队列、不用入度数组,每一轮从头扫一遍

brute.cpp标准答案:每一轮重新问一遍「谁现在能做」
// 标准答案 —— 不用队列、不用入度数组,每一轮从头扫一遍
//
// 输入:第一行 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

想法朴素得不能再朴素,就是第 2 步那张表的直译:

一轮一轮地挑人。每一轮扫描所有还没被挑走的任务,看它的前驱是不是全都被挑走了 —— 是的话它现在就能做;在所有能做的里面挑编号最小的。 某一轮一个都挑不出来 → 剩下的人互相卡住 → 有环 → -1。

它和正解的思路差在哪 —— 这正是对拍要的
  • 暴力:「能不能做」每一轮都现算(把前驱重新问一遍);
  • 正解:给每个点记一个 in[v],增量维护(减一、减一、减到 0)。

一个现算、一个增量维护 —— 不是同一个想法写两遍,所以它们不太可能一起错 (第 9 章那条规矩)。

顺带把有环那张也跑一遍,-1 那一支从一开始就要在场:

brute.cpp(换成有环的那张图)加一条 3 → 5,就绕回去了

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 倍。

⚠ 这张表也差点做废(第 25 章那条的第三次)

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 秒
genBig.cpp固定种子,同一条命令永远造同一张图

5★ 关键一步:入度减到 0 才能入队

fast.cppKahn 算法:BFS 换了个入队条件
// 拓扑排序 —— 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 一句话:BFS 只换了「什么时候能入队」
第 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 就能做 —— 队列空了还有剩,就是有环
5 1 3 6 4 2
第 1 / 8 步
112232415060
点上面那个数字 = 它还欠着几个前驱(✓ = 已经做完了)。绿色 = 现在就能做、已经在容器里
小根堆(下一个取最小的)
5
6
已经排好的顺序
(还没有)
★ 还没出来的点数
6入队次数 2
队列空了它还不是 0 —— 那就是有环。判环不用另写一份代码,它就是这个数字。
先把每个点的入度数出来(入度 = 还欠着几个前驱)。一上来入度就是 0 的有 2 个:5、6 —— 它们谁也不欠,现在就能做。

点上面那个数字是它还欠着几个前驱,减到 0 就变绿、进容器。 右边那个大数字是 ★ 还没出来的点数 —— 队列空了它还不是 0,就说明有环。

按一下「换成有环那张」(在默认图上加一条 3 → 5),你会看到 5、1、3 三个点 入度永远降不到 0,队列早早就空了,计数器停在 3。那就是判环的全部现场。

下拉框里那四个错误版本,第 8 步逐个讲。

7★ 答案不唯一 —— 这一章真正的新东西

先看另一种完全不同的拓扑排序:DFS 的后序逆序。

dfsTopo.cpp第二种思路:后序遍历,倒过来
// 第二种拓扑排序: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它跑出 6 5 4 2 1 3 —— 和正解 5 1 3 6 4 2 完全不一样。 但两个都是对的。

★ 那到底有多少个「对的」?把它数出来

「不唯一」是个含糊的词。数一下:

count.cpp状压 DP 数拓扑序个数(./count all 列出全部)
// 这张图到底有多少个合法拓扑序?—— 用第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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 个合法顺序字典序最小 5 1 3 6 4 2
第 1 / 70 步
·
已经走出来的完整顺序
0
全部展开是 20 个 —— count.cpp 用状压 DP 数出来的也是这个数
蓝色那条路
5 1 3 6 4 2
每一步都挑分支里编号最小的那个 —— 走出来的就是「字典序最小」的答案。 小根堆干的就是这件事。
树上每一条从上到下的路径都是一个「正确」答案。所以题面必须再加一句 「输出字典序最小的那个」,对拍才有得比。
从「什么都还没做」开始。根节点下面那几个分支,就是一上来入度为 0 的任务 —— 「选哪个都行」,这正是答案不唯一的源头。

这棵树把「不唯一」摊开给你看:每一层是「这一步可以做谁」,每一条从上到下的路径都是一个正确答案, 一共 20 条。蓝色那条是「每一步都挑编号最小的分支」走出来的 —— 那就是字典序最小的答案, 也正是小根堆干的事。(第 3 章那句「递归 = 决策树」在这里第二次登场。)

★ 答案不唯一时,对拍怎么办 —— 两条出路

出路一:把答案钉唯一。 题面加一句「输出字典序最小的那个」, 代码里把队列换成小根堆。好处是能直接逐字节对拍; 代价是多了一层和拓扑排序本身无关的东西(而且慢了个 log n)。

出路二:不比答案,比性质。 写一个验证器,只问「你给的这个顺序合不合法」:

check.cpp(验证器)输入 = 原题输入 + 一行待检查的顺序
// ★ 验证器:给一个顺序,判断它是不是这张图的**合法**拓扑序
//
// 这是本章新添的一件工具,而且它解决的是一个前 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它只查两件事,缺一不可: ① 必须正好是 1..n 的一个排列;② 每一条依赖都得是「前面指向后面」。

上面这一组查的就是 dfsTopo.cpp 给的那个顺序 —— 它跟正解不一样,但合法。

⚠ 验证器有个必须记住的盲区:它只能证明「这个答案合法」, 不能证明「答案存在时你没漏报」 —— 一份永远输出 -1 的程序能通过所有合法性检查。 所以判环的结论还得单独对一遍。 (这和第 20 章那句「对拍只能证伪」是同一件事的另一面。)

本章两条出路都用上了:主对拍走出路一(brute vs fast,逐字节比), 而 dfsTopo.cpp 走出路二 —— check:viz 每轮都拿验证器验它一遍, 再单独核对它的判环结论和 Kahn 一致。

8四种把它写错的方式

wrongQueue.cpp✗ 用普通队列,而不是小根堆
// ✗ 错误版本二:用普通队列,而不是小根堆
//
// ★ 这一份特殊:**它给出的顺序是完全合法的拓扑序**,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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 5 6 1 4 3 2。★ 这一份特殊:它给的顺序完全合法,验证器查都查不出问题 —— 它只是不是题目要的「字典序最小」那个。

把它留着,是因为它一个人就把这一章两件事都说清了: 拓扑序不唯一,所以题面必须把答案钉唯一。

wrongPrint.cpp✗ 入队时就记答案,不是出队时
// ✗ 错误版本五:在**入队**的时候就把点记进答案,而不是出队的时候
//
// 第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 5 6 1 3 4 2。第 30 章刚讲过「vis 要在入队时打」,这里正好反过来 —— 记答案必须在出队时。两句话不打架,说的是同一件事:

  • 入队时打 vis:是为了「别让同一个点被塞两次」,越早越好;
  • 出队时记答案:因为顺序是出队决定的,不是入队决定的。

⚠ 用普通队列时这两者恰好一样(先进先出,入队序 = 出队序),所以这个 bug 会隐身; 一换成小根堆,堆把队列重排了一遍,进去的顺序和出来的顺序当场分家。 这也解释了它为什么在第 30 章那种纯 BFS 里从来不出问题 —— 那里根本没有堆。

wrongPushEarly.cpp✗ 入度减了,但没减到 0 就入队
// ✗ 错误版本三:入度减了,但没等它减到 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 5 1 3 3 6 2 4 2 —— 8 个数,还有重复。 if (--in[v] == 0) q.push(v) 写成了 --in[v]; q.push(v);, 等于把「所有前驱都满足」偷偷降级成了「满足一个就行」。

⚠ 有一类图完全抓不到它:每个点最多只有一个前驱(一棵树、一条链)—— 那时两句话是同一个意思。所以默认那张图里,3 号和 2 号各有两个前驱,那两条「跨一格」的边就是为它准备的。

wrongDir.cpp✗ 把依赖的方向读反了
// ✗ 错误版本四:把依赖的方向整个读反了
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 2 3 1 4 5 6 —— 算法一点毛病没有,错的是读题。 把它丢给验证器,6 条依赖全部被违反:

check.cpp(拿去验「方向读反」那个顺序)第二关专门逮这种

★ 请注意它过得了验证器的第一关(是 1..6 的排列,长度也对)。 这就是验证器为什么必须查两件事,而不是只查「是不是排列」。

wrongNoCycle.cpp✗ 忘了判环(只在有环的图上才现形)
// ✗ 错误版本一:忘了判环 —— 队列空了就直接把手上这些点打印出来
//
// 少的就是最后那一句 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

在有环那张图上跑出 6 4 2 —— 只有 3 个数。它少的就是那句 if (ans.size() < n) 输出 -1。只要图是 DAG,它就完全正确 —— 这也正是它最危险的地方,第 11 步会看到它的死活完全捏在生成器手里。

9★ 一块试金石:什么都不做

wrongIdentity.cpp✗ 判完环,直接输出 1 2 3 … n
// ✗ 「什么都不做」—— 判完环之后直接输出 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它不是一个真实的 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★ 生成器调了四次,每次只改一处

★ 前两档各掩盖一批 bug,后两档则是「有环太多,把别人挤没了」

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。

每一支都要有,而且都不能多到吃掉别人。 「某一支永远走不到」和「某一支占得太多」,是同一枚硬币的两面。

gen.cpp(带五个档位的生成器)四次改动都能重跑
genNice.cpp(编号就是拓扑序、而且永远无环)演示用:反面教材
⚠ 一笔老实账:温柔的数据不是对所有 bug 都温柔

拿 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自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)