题单 · 习题解析

洛谷 B3644 【模板】拓扑排序 / 家谱树

★★★ 题面写「输出任意一种即可」⇒ **先写验证器,别写逐字节对拍**:三种正确写法都合法,而逐字节比报 **283 / 300** 轮不一致 —— 而 283 恰好 ≡ 拓扑序不唯一的轮数;★ 「边存反了」被抓 249 ≡ 图里真的有边的轮数;⚠ 官方样例上三种写法打出同一个串,「多解」根本看不出来

原题:洛谷 B3644出自 第 31 章 拓扑排序 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

有个人的家族很大,辈分关系很混乱,请你帮整理一下这种关系。给出每个人的后代的信息。 输出一个序列,使得每个人的后辈都比那个人后列出

输入格式

第 1 行一个整数 N1 ≤ N ≤ 100),表示家族的人数。 接下来 N 行,第 i 行描述第 i 个人的后代编号 aᵢ,ⱼ,表示 aᵢ,ⱼi 的后代。 每行最后是 0 表示描述完毕。

输出格式

输出一个序列,使得每个人的后辈都比那个人后列出。 如果有多种不同的序列,输出任意一种即可。

输入输出样例

输入

5
0
4 5 1 0
1 0
5 3 0
3 0

输出

2 4 5 3 1

边是「长辈 → 后辈」:2→42→52→13→14→54→35→3。 只有 2 号没有长辈,所以它第一个;接着 4、5、3、1。

⚠⚠ 注意输出格式那句「输出任意一种即可」—— 这是本页的全部难点, 而且第 ① 步会发现:这道题「答案不唯一」这件事,在这组样例上根本看不出来。

1★ 正解:本章的 Kahn 原样搬过来,只换了读入

题面那句「第 i 行是第 i 个人的后代」+「后辈要列出」⇒ 边是 i → j(长辈指向后辈), 求任意一个拓扑序。

b3644.cpp★ Kahn(入度 + 队列),这一版就能 AC
// ★ B3644 正解:Kahn 算法(入度 + 队列)—— 本章 fast.cpp 原样搬过来,只换了读入。
//
// 题面:给每个人的**后代**列表(以 0 结尾),要求「每个人的后辈都比那个人后列出」
// ⇒ 一条「i 是 j 的长辈」的关系就是一条有向边 `i → j` ⇒ 求任意一个拓扑序。
//
// ⚠⚠ 这道题**答案不唯一**(题面明写「如果有多种不同的序列,输出任意一种即可」)——
// 于是对拍**不能逐字节比**。本页的办法是写一个**验证器**(b3644Check.cpp),
// 只问「你给的这个顺序合不合法」。解析页第 ③ 步说了这件事值多少。
//
// ⚠ 读入是**变长行**:`后代… 0`。和 [P1113](/sol/p1113/) 一样,别去管换行,
// 直接把整个输入当一条数字流读到 0 为止。
//
// 复杂度 O(n + m)。题面 n ≤ 100,随便写。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<int>> g(n + 1);
vector<int> indeg(n + 1, 0);
for (int i = 1; i <= n; i++) {
int v;
while (cin >> v && v != 0) { g[i].push_back(v); indeg[v]++; }
}
queue<int> q;
for (int i = 1; i <= n; i++) if (!indeg[i]) q.push(i); // ★ 所有入度为 0 的点,一个都不能少
string out;
while (!q.empty()) {
int u = q.front(); q.pop();
if (!out.empty()) out += ' ';
out += to_string(u);
for (int v : g[u]) if (--indeg[v] == 0) q.push(v);
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

⚠ 读入是变长行后代… 0)。和上一轮 P1113 一样:别去管换行cin >> 本来就跳过所有空白,把整个输入当一条数字流读到 0 为止。

2⚠⚠ 「输出任意一种即可」—— 于是对拍不能逐字节比,得先写一个裁判

b3644Check.cpp★★ 验证器:只问「你给的这个顺序合不合法」

它只判两件事,逐字照题面,不假设任何算法: ① 输出是 1..n 的一个排列(不重不漏);② 每一条边,长辈都排在后辈前面

⚠ 验证器的边界,得先说清楚

它只能证明「这个答案合法」,不能证明「答案存在时你没漏报」。 一份什么都不输出的程序过不了第 ①(长度不对), 但一份「输出了合法序、却漏掉判环」的程序,它看不出来 —— 这和第 20 章那句「对拍只能证伪」是同一件事的另一面。 (本章正文 check.cpp 里也写着这一条。)

3★★★ 三种正确写法:都合法,而逐字节比会报 283 轮「不一致」

同一个 Kahn,把队列换成栈;再写一份完全不同的 DFS 逆后序

b3644Stack.cpp⚠ 队列换成栈 —— 看着像 bug,一次都没错
// ⚠ B3644:把 Kahn 里的**队列换成栈** —— 看着像 bug,其实**一次都不会错**。
//
// 「先进先出」换成「后进先出」,输出的顺序当场就变了(样例上 `2 4 5 3 1` 变成别的)。
// 可拓扑排序**从来没要求过顺序**:Kahn 的正确性只依赖一件事 ——
// **一个点被输出时,它的所有前驱都已经输出过了**。而「从哪个容器里挑下一个」
// 对这件事毫无影响,挑谁都行。
//
// ⇒ ★★ 所以这一版**答案不同、但同样合法**。300 轮验证器全过(解析页第 ③ 步)。
// ⚠ 而如果拿它去和正解**逐字节比**,会报一大堆「不一致」—— 那全是假阳性。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<int>> g(n + 1);
vector<int> indeg(n + 1, 0);
for (int i = 1; i <= n; i++) {
int v;
while (cin >> v && v != 0) { g[i].push_back(v); indeg[v]++; }
}
vector<int> st;
for (int i = 1; i <= n; i++) if (!indeg[i]) st.push_back(i);
string out;
while (!st.empty()) {
int u = st.back(); st.pop_back(); // ★ 栈:后进先出
if (!out.empty()) out += ' ';
out += to_string(u);
for (int v : g[u]) if (--indeg[v] == 0) st.push_back(v);
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
b3644Dfs.cpp★ DFS 逆后序(另一条路,一行代码都不共享)
★★★ 283 ≡ 283 —— 假阳性的数量,恰好等于「有多解」的轮数
300 轮随机 DAG
队列版 / 栈版 / DFS 版 过验证器 300 / 300 / 300
逐字节比:栈版和队列版不同的轮数 283
⚠ 逐字节比:DFS 版和队列版不同的轮数 282
★ 而真正有多解(拓扑序不唯一)的轮数 283 —— 一个不差

⇒ ★★★ 拿逐字节对拍去验这道题,300 轮里会报 283 轮「不一致」,而真正的错误是 0 轮。 94% 的报警全是假的。

而那个「一个不差」是能证的:只要某一步同时有两个入度为 0 的点, 队列取最先进来的、栈取最后进来的 —— 必然分岔。 (DFS 版差的那 1 轮,是它恰好和队列版走出了同一个串。)

★★ 对照本书前两次量到的同一件事: 第 26 章 P1040 是「多解 80 轮,可两版 tie-break 恰好一致 ⇒ 0 轮不同」, 第 27 章 P3478 是「22 轮假阳性」—— 这一页是同一条线上最极端的一点:283 / 300。

★ 把「多解」关掉做个对照:一条链

生成器换成 1 → 2 → … → n 一条链:拓扑序唯一 300 / 300 轮, 三种写法输出相同 300 / 300 轮

「逐字节比」不是永远错的,它只在「答案唯一」的时候才对 —— 而这道题的题面明明白白写着「输出任意一种即可」。 ⇒ ★ 看到题面写「任意一种 / 输出任意解」,第一件事是去写验证器,不是去写对拍。

4⚠ 两个错法 —— 而它们各配一条精确的恒等式

b3644Rev.cpp✗ 边的方向存反了
// ✗ B3644 错法一:**边的方向存反了** —— 把「i 的后代是 j」读成了「j → i」。
//
// 题面那句话要一个字一个字读:「第 i 行描述第 i 个人的**后代**编号」,
// 而输出要求「每个人的**后辈**都比那个人**后**列出」⇒ 边是 `i → j`(长辈指向后辈)。
//
// ★ 说清楚它算了什么:它输出的是**反着的**拓扑序(后辈在前)。
// ⚠ 而这个错法在**每条边上**都错,所以官方样例一测就死。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<int>> g(n + 1);
vector<int> indeg(n + 1, 0);
for (int i = 1; i <= n; i++) {
int v;
while (cin >> v && v != 0) { g[v].push_back(i); indeg[i]++; } // ★ 反了
}
queue<int> q;
for (int i = 1; i <= n; i++) if (!indeg[i]) q.push(i);
string out;
while (!q.empty()) {
int u = q.front(); q.pop();
if (!out.empty()) out += ' ';
out += to_string(u);
for (int v : g[u]) if (--indeg[v] == 0) q.push(v);
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
b3644One.cpp✗ 只把 1 号点入队(样例上一个字都不输出)
// ✗ B3644 错法二:**一开始只把 1 号点入队**(忘了「所有入度为 0 的点」)。
//
// Kahn 的第一步是「把**所有**入度为 0 的点放进队列」。只放一个,
// 那些不在 1 号点可达范围里的点就永远出不来 ⇒ **输出的序列短了一截**。
//
// ⚠ 它和第 30 章 [B3625](/sol/b3625/) 第一版那个「只从 1 号点搜」是同一个形状的错 ——
// **默认「图是从 1 号点连通的」**。题面从来没这么保证过。
// ★ 而这一次验证器能一眼看穿它:长度不是 n。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<int>> g(n + 1);
vector<int> indeg(n + 1, 0);
for (int i = 1; i <= n; i++) {
int v;
while (cin >> v && v != 0) { g[i].push_back(v); indeg[v]++; }
}
queue<int> q;
if (indeg[1] == 0) q.push(1); // ★ 只放 1 号
string out;
while (!q.empty()) {
int u = q.front(); q.pop();
if (!out.empty()) out += ' ';
out += to_string(u);
for (int v : g[u]) if (--indeg[v] == 0) q.push(v);
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 轮 被验证器抓 而它 ≡
边的方向存反了 249 图里真的有边的轮数 = 249(一个不差)
只把 1 号点入队 287 ★ 其中 287 轮是「长度就不够」被抓的
★ 两条都能一句话证完
  • 存反了:只要有一条u → v,反向之后 v 就必然排在 u 前面 ⇒ 必被抓。 漏掉的 51 轮,正是生成器造出零条边的那 51 轮(那时任意排列都合法)。
  • 只放 1 号点:Kahn 的第一步是「把所有入度为 0 的点放进队列」。只放一个, 不在 1 号可达范围里的点就永远出不来 ⇒ 输出短了一截,验证器第 ① 条就拦住了。 ⚠ 它和第 30 章 B3625 第一版是同一个形状的错:默认「图是从 1 号点连通的」

5★ 一件容易忽略的事:官方样例把这一页的主题整个盖住了

三种正确写法在官方样例上打出的是同一个串 2 4 5 3 1

队列 DFS 逆后序
官方样例 2 4 5 3 1 2 4 5 3 1 2 4 5 3 1
300 轮随机 DAG 上和队列版不同 —— 283 282

⇒ ★★ 这组样例里只有 2 号点没有长辈,接下来每一步也都只有一个候选 —— 它恰好是一个「拓扑序唯一」的例子。 ⇒ 于是「答案不唯一、不能逐字节比」这件全页最要紧的事,只看样例是发现不了的。 ★ 又一次那条老规矩的现场:官方样例给了几组就跑几组,但别指望它替你把题面读完。

6度量程序和生成器

b3644Count.cpp度量程序(本页所有数字都出自它)
b3644Gen.cpp数据生成器

7一页纸

★ 关键的一步 「后代」= 边 i → j ⇒ 本章 Kahn 原样搬过来(n ≤ 100,随便跑)
★★★ 全页最要紧的一条 题面写「输出任意一种即可」⇒ 先写验证器,别写逐字节对拍
★★★ 它值多少 三种写法都合法(300/300/300),可逐字节比报 283 轮不一致 —— 94% 是假阳性
★ 而 283 ≡ 283 假阳性轮数恰好等于「拓扑序不唯一」的轮数(能证:两个候选时队列和栈必分岔)
★ 对照档 换成一条链 ⇒ 唯一 300/300、三版输出相同 300/300 ⇒ 逐字节比只在答案唯一时才对
两个错法 存反了被抓 249 ≡ 有边的轮数;只放 1 号点被抓 287(长度就不够)
⚠ 官方样例 三种正确写法打出同一个串 —— 这道题的「多解」在样例上根本看不出来