题单 · 习题解析

洛谷 P5318 【深基18.例3】查找文献

★★ 和 B3643 同一个 bug(忘了 sort),可官方样例这次放过了它;★★★ n = 10⁵ ⇒ 矩阵 9.31 GB 开不出来 —— 存法是被 n 逼出来的;★★★ 递归 DFS 每层 341 字节 ⇒ 2.4 万层就爆栈,把拼输出挪出去就是 48 字节 / 17.4 万层(×7)

⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

小 K 喜欢翻看洛谷博客获取知识。每篇文章可能会有若干个(也有可能没有)参考文献的链接指向别的博客文章。 小 K 求知欲旺盛,如果他看了某篇文章,那么他一定会去看这篇文章的参考文献 (如果他之前已经看过这篇参考文献的话就不用再看它了)。

假设洛谷博客里面一共有 n (1 ≤ n ≤ 10⁵) 篇文章(编号为 1n) 以及 m (1 ≤ m ≤ 10⁶) 条参考文献引用关系。目前小 K 已经打开了编号为 1 的一篇文章, 请帮助小 K 设计一种方法,使小 K 可以不重复、不遗漏的看完所有他能看到的文章。

这边是已经整理好的参考文献关系图,其中,文献 X → Y 表示文章 X 有参考文献 Y不保证编号为 1 的文章没有被其他文章引用。

P5318 的参考文献关系图(样例那张图)

请对这个图分别进行 DFS 和 BFS,并输出遍历结果。 如果有很多篇文章可以参阅,请先看编号较小的那篇(因此你可能需要先排序)。

输入格式

m + 1 行,第 1 行为 2 个数,nm,分别表示一共有 n (1 ≤ n ≤ 10⁵) 篇文章 (编号为 1n)以及 m (1 ≤ m ≤ 10⁶) 条参考文献引用关系。

接下来 m 行,每行有两个整数 X, Y 表示文章 X 有参考文献 Y

输出格式

共 2 行。第一行为 DFS 遍历结果,第二行为 BFS 遍历结果。

输入输出样例

输入

8 9
1 2
1 3
1 4
2 5
2 6
3 7
4 7
4 8
7 8

输出

1 2 5 6 3 7 8 4 
1 2 3 4 5 6 7 8 

⚠ 注意这组样例的边恰好是按出发点升序、且每个点的出边也升序给的 (1→2,3,42→5,64→7,8……)。

这句话本身就是本页第 ① 步的全部内容:「忘了排序」这个错法,被这组样例完整地放过去了。

1⚠ 第一版:vector 存图,忘了 sort —— 而官方样例一点反应都没有

p5318Order.cpp✗ 忘了 sort —— 样例照样对
// ✗ P5318 第一版:vector 存图,**忘了排序** —— 而官方样例一点反应都没有。
//
// ⚠⚠ 这一版和上一道题 [B3643](/sol/b3643/) 的第一版是同一个 bug,可两组官方样例的态度正好相反:
// B3643 那组样例当场把它打回来了(第三行邻接表就错);
// 这道题的样例里,每个点的出边**恰好就是升序给的**(1→2,3,4;2→5,6;4→7,8……)
// ⇒ 它原样打出正确答案。
//
// ⇒ ★★ **「样例挡不挡得住」不是这个 bug 的属性,只能一组一组试。**
// (对拍 300 轮它被抓 97 次 —— 是「偶尔才错」型,这正是样例放过它的原因。)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v); // ★ 有向图:只存一遍
}
/* ★ 少了这一行:for (int i = 1; i <= n; i++) sort(g[i].begin(), g[i].end()); */
string out;
vector<char> vis(n + 1, 0);
vector<int> st;
st.push_back(1);
while (!st.empty()) {
int u = st.back(); st.pop_back();
if (vis[u]) continue; // ② 弹出时才判
vis[u] = 1;
out += to_string(u); out += ' ';
for (int i = (int)g[u].size() - 1; i >= 0; i--) // ① 逆序压栈
if (!vis[g[u][i]]) st.push_back(g[u][i]);
}
out += '\n';
fill(vis.begin(), vis.end(), 0); // ★★ 第二次遍历前必须清空
queue<int> q;
q.push(1);
vis[1] = 1; // ★ 入队就标记
while (!q.empty()) {
int u = q.front(); q.pop();
out += to_string(u); out += ' ';
for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push(v); }
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 同一个 bug,两组官方样例态度正好相反

上一道 B3643 的第一版是同一个错vector 里邻居是输入序,不是升序)。 可那道题的官方样例当场把它打了回来(第三行邻接表打出 4 2 5 1 4), 这道题的样例一个字都没变

B3643 P5318
「忘了 sort」被官方样例挡住
300 轮对拍被抓 185(n 拧到 100 就是 300) 97

⇒ ★★ 「样例挡不挡得住」不是这个 bug 的属性 —— 只能一组一组试。 (本书连着好几轮量到「官方样例是个『一测就死』的过滤器」: 它挡住的是「每组都错」的,放过的是「偶尔才错」的。这两页正好把两头都拿到了。)

2第二版:换成前向星 —— 同一句「顺序没管」,这次样例挡住了

p5318Star.cpp✗ 前向星:邻居倒序(样例挡得住)
// ✗ P5318 第二版:换成链式前向星,**邻居倒序出来**。
//
// 和第一版是同一句「顺序没管」,但方向相反:前向星把新边挂在链头 ⇒ 加边顺序的倒序。
// ⚠ 这一版**官方样例挡得住**(DFS 第一步就从 1 走到 4 了),而第一版挡不住 ——
// 同一组样例,对两个方向的「顺序错」态度不同。
//
// ★ 顺带:它也是「顺手写前向星」的默认后果 —— 前向星本身没有错,
// 错的是**没有为「题目要求升序」这件事付钱**(见 [B3643](/sol/b3643/) 第 ⑤ 步:
// 把倒序掰成升序,钱和直接 sort 一样多)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<int> head(n + 1, -1), to_(m), nxt(m);
int cnt = 0;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
to_[cnt] = v; nxt[cnt] = head[u]; head[u] = cnt++;
}
string out;
vector<char> vis(n + 1, 0);
vector<int> st;
st.push_back(1);
while (!st.empty()) {
int u = st.back(); st.pop_back();
if (vis[u]) continue;
vis[u] = 1;
out += to_string(u); out += ' ';
vector<int> nb; // 逆序压栈 —— 和正解同一套写法
for (int e = head[u]; e != -1; e = nxt[e]) nb.push_back(to_[e]);
for (int i = (int)nb.size() - 1; i >= 0; i--)
if (!vis[nb[i]]) st.push_back(nb[i]);
}
out += '\n';
fill(vis.begin(), vis.end(), 0);
queue<int> q;
q.push(1); vis[1] = 1;
while (!q.empty()) {
int u = q.front(); q.pop();
out += to_string(u); out += ' ';
for (int e = head[u]; e != -1; e = nxt[e])
if (!vis[to_[e]]) { vis[to_[e]] = 1; q.push(to_[e]); }
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

打出来是 1 4 8 7 3 2 6 5 / 1 4 3 2 8 7 6 5 —— DFS 第一步就从 1 走到了 4。

3★★ 正解:sort + 迭代 DFS —— 而这道题连「用矩阵」这个选项都没有

p5318.cpp★★ 这一版能 AC
// ★★ P5318 正解:vector 存图 + 每个点的出边 sort 一遍,**DFS 写成迭代的**,BFS 照常。
//
// 题面那半句「**如果有很多篇文章可以参阅,请先看编号较小的那篇**」就是全部难点 ——
// 它要求**邻居按编号升序访问**,而三种存法给出的顺序各不相同(见 [B3643](/sol/b3643/) 第 ④ 步)。
//
// ⚠⚠ 而上一道题那条「白送的路」在这里**根本不存在**:
// B3643 里 n ≤ 1000,邻接矩阵的一行天生就是升序,第二问白送;
// 这道题 n ≤ 10⁵ ⇒ 矩阵要 10¹⁰ 格 = 9.31 GB,**连开都开不出来**。
// ⇒ **存法是被 n 逼出来的,不是「哪个更漂亮」。**
//
// ⚠ DFS 为什么不写递归:顶格 n = 10⁵ 排成一条链时,
// 「递归 + 边走边拼输出」那一版(p5318Rec.cpp)在本机 **2.4 万层**就爆栈了。
// 解析页第 ⑦ 步量了这件事。
//
// ⚠ 迭代 DFS 要和递归版**输出一模一样**,有两处必须小心:
// ① 邻居要**逆序**压栈(栈是后进先出,逆序压进去才会正序弹出来);
// ② 标记要放在**弹出时**判一次(同一个点可能被压进去好几次)。
//
// 复杂度:建图 O(m)、排序 O(m log m)、两次遍历各 O(n + m)。顶格 n = 10⁵、m = 10⁶。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v); // ★ 有向图:只存一遍
}
for (int i = 1; i <= n; i++) sort(g[i].begin(), g[i].end());
string out;
vector<char> vis(n + 1, 0);
vector<int> st;
st.push_back(1);
while (!st.empty()) {
int u = st.back(); st.pop_back();
if (vis[u]) continue; // ② 弹出时才判
vis[u] = 1;
out += to_string(u); out += ' ';
for (int i = (int)g[u].size() - 1; i >= 0; i--) // ① 逆序压栈
if (!vis[g[u][i]]) st.push_back(g[u][i]);
}
out += '\n';
fill(vis.begin(), vis.end(), 0); // ★★ 第二次遍历前必须清空
queue<int> q;
q.push(1);
vis[1] = 1; // ★ 入队就标记
while (!q.empty()) {
int u = q.front(); q.pop();
out += to_string(u); out += ' ';
for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push(v); }
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 上一道题那条「白送的路」,在这道题上根本不存在

B3643 里,「邻居按编号升序」是白送的 —— 因为 n ≤ 1000, 邻接矩阵的第 i 行天生就是升序,而那道题本来就要输出矩阵。

这道题 n ≤ 10⁵

顶格 n = 10⁵m = 10⁶ 内存
邻接矩阵(就算一格只用 1 字节 10¹⁰ 格 = ★ 9.31 GB
vector 邻接表 6.10 MB
链式前向星 8.01 MB
(题面给的) 128 MB

⇒ ★★ 存法是被 n 逼出来的,不是「哪个更漂亮」。 同一个「按编号升序访问邻居」的要求,n = 1000 时可以靠矩阵白送, n = 10⁵只能老老实实排序。这就是第 29 章那句话的两面。

4第三个错法:两次遍历共用一个 vis,中间忘了清空

p5318NoClear.cpp✗ 第二行只剩一个 1
// ✗ P5318 第三版:DFS 和 BFS **共用一个 vis 数组,中间忘了清空**。
//
// 这是「一道题里要遍历两次」时最常见的错。它的表现极好认:
// **第二行只剩一个 `1 `** —— 所有点在 DFS 那一轮已经被标记过了。
//
// ★ 它是「每一组都错」型(只要图里不止一个可达点就错),所以官方样例一测就死,
// 对拍 300 轮也抓到 283 次。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v); // ★ 有向图:只存一遍
}
for (int i = 1; i <= n; i++) sort(g[i].begin(), g[i].end());
string out;
vector<char> vis(n + 1, 0);
vector<int> st;
st.push_back(1);
while (!st.empty()) {
int u = st.back(); st.pop_back();
if (vis[u]) continue; // ② 弹出时才判
vis[u] = 1;
out += to_string(u); out += ' ';
for (int i = (int)g[u].size() - 1; i >= 0; i--) // ① 逆序压栈
if (!vis[g[u][i]]) st.push_back(g[u][i]);
}
out += '\n';
/* ★ 少了这一行:fill(vis.begin(), vis.end(), 0); */
queue<int> q;
q.push(1);
while (!q.empty()) {
int u = q.front(); q.pop();
out += to_string(u); out += ' ';
for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push(v); }
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它是「每一组都错」型(只要不止一个可达点就错)⇒ 官方样例一测就死,对拍 300 轮抓到 283 次。

5⚠ 第四个「错法」对拍永远抓不到 —— 而它在这道题上一分钱都不值

BFS 的「守卫」写在哪:入队就标记,还是出队才标记第 14 章 P1443 量过后者的代价是入队次数。这道题上呢:

p5318Late.cpp⚠ 出队才标记 —— 输出逐字节相同
★★ 这是「对拍抓不到」的第三种性质:可以证明的恒等

本书前面量过两种「对拍 0 次」:概率低(换生成器就能抓)和结构性的 0(题面挡死 / 生成器缺一档)。 这里是第三种:两版的输出必然相同,跟数据一点关系都没有。

两行就能证:入队才标记的 BFS 里,v 第一次进队,是在它的某个前驱 u 被处理的那一刻; 出队才标记的 BFS 里,v 第一次进队还是在同一刻(只是后面可能再进几次)。 队列是先进先出 ⇒ 两边「第一次出队」的顺序一模一样 ⇒ 输出逐字节相同。

⇒ 所以只能数次数。而数出来的结果是个否定结论:

入队才标记 出队才标记 倍数
随机小图(n = 10 / 100 / 1000,各 50 轮合计) 229 / 998 / 12240 262 / 1079 / 13547 1.08 ~ 1.14
★ 顶格 n = 10⁵m = 10⁶ 99 993 500 315 5.00

⇒ 顶格也只是五倍,而队列里放的是 int ⇒ 峰值 2 MB,题面给 128 MB。这道题上它挂不了人。 ★ 对照 P1443 那次(24 × 24 的棋盘,每格能被塞进队列一万八千次): 「出队才标记要不要紧」的主语是「同一个点能被重复入队多少次」, 而这里它被 m ≤ 10⁶ 卡得死死的。

6★★★ 真正会挂人的是递归 —— 而「多少层会爆栈」不是一个能背的数

最自然的写法是递归 DFS,边走边把编号拼进输出串

p5318Rec.cpp⚠ 递归 + 边走边拼串:顶格段错误
// ⚠ P5318 第一版之后最自然的写法:vector + sort + **递归 DFS**,边走边把编号拼进输出串。
//
// 算法完全正确(小数据上和正解逐字节相同),可它在**顶格数据上直接段错误** ——
// 本机(`ulimit -s` = 8192 KB)实测:这个 dfs 每层要 **341 字节**,
// 于是 **24 589 ~ 24 976 层**就把栈用光了,而题面 n 可以到 10⁵。
//
// ★★★ 解析页第 ⑦ 步把这件事量透了:**「多少层会爆栈」不是一个能背的数**,
// 它是「栈上限 ÷ 每层字节数」,而每层字节数由**你在那个函数里写了什么**决定 ——
// 只要把「拼输出」挪出递归(p5318Lean.cpp),门槛就从 2.4 万涨到 17.4 万,**差 7 倍**。
//
// 题面那半句「**如果有很多篇文章可以参阅,请先看编号较小的那篇**」就是全部难点 ——
// 它要求**邻居按编号升序访问**,而三种存法给出的顺序各不相同(见 /sol/b3643/ 第 ④ 步)。
//
// ⚠⚠ 而上一道题那条「白送的路」在这里**根本不存在**:
// B3643 里 n ≤ 1000,邻接矩阵的一行天生升序,第二问白送;
// 这道题 n ≤ 10⁵ ⇒ 矩阵要 10¹⁰ 格,**连开都开不出来**。
// ⇒ 存法的选择是被 n 逼出来的,不是「哪个更漂亮」。
//
// 复杂度:建图 O(m)、排序 O(m log m)、两次遍历各 O(n + m)。顶格 n = 10⁵、m = 10⁶。
#include <bits/stdc++.h>
using namespace std;
int n, m;
vector<vector<int>> g;
vector<char> vis;
string out;
void dfs(int u) {
vis[u] = 1;
out += to_string(u);
out += ' ';
for (int v : g[u]) if (!vis[v]) dfs(v);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
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); // ★ 有向图:只存一遍
}
for (int i = 1; i <= n; i++) sort(g[i].begin(), g[i].end());
vis.assign(n + 1, 0);
dfs(1);
out += '\n';
vis.assign(n + 1, 0); // ★★ 第二次遍历前必须清空
queue<int> q;
q.push(1);
vis[1] = 1; // ★ 入队就标记
while (!q.empty()) {
int u = q.front(); q.pop();
out += to_string(u);
out += ' ';
for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push(v); }
}
out += '\n';
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它在样例上完全正确。可把顶格那条链(1 → 2 → … → 10⁵)喂进去,它一个字都不输出(段错误)。

★★★ 只把「拼输出」挪出递归函数,能扛的深度就涨了 7 倍

本机(A 机 · WSL2 · i5-13500H · ulimit -s = 8192 KB · -O2 -std=c++17 · 2026-08-30) 二分出来的门槛:

写法 爆栈门槛(层) ⇒ 每层约 顶格 n = 10⁵
递归 + 递归函数里 out += to_string(u) 24 589 ~ 24 976 341 字节 段错误
递归,但只往数组里塞一个 int(下面那版) 174 000 ~ 174 500 48 字节 活着,⚠ 余量只有 1.74 倍
迭代(正解) —— ——

⇒ ★★★ 「多少层会爆栈」= 栈上限 ÷ 每层字节数,而每层字节数由你在那个函数里写了什么决定。 第 27 章那一轮量到的门槛是 14.2 万 ~ 15.6 万层(那个 dfs 的函数体是精简的), 和这一页的 2.4 万差 6 倍 —— 那不是两台机器的差别,是两个函数体的差别。

⚠ 顺带把那条规矩说得更狠一点:就算写成精简递归,顶格余量也只有 1.74 倍「顶格题一律写迭代,别赌」—— 赌赢了也只赢一点点。

p5318Lean.cpp★ 递归,但把拼输出挪出去(门槛 ×7)

7⚠ 和算法无关但会挂人的那一条:11 MB 的输入、1 秒的时限

m ≤ 10⁶ ⇒ 要读 2 × 10⁶ 个整数。顶格那组数据实测 11 778 709 字节(11.23 MB),而时限只有 1 秒

同机、同一份建图 + 排序 + 两次遍历,只换读法(A 机 · WSL2 · 2026-08-30,独占):

读法 读入 + 建图 端到端 1 秒时限
默认 cin(不关同步) 326 ms 357 ms 够,余量 2.8 倍
cin + sync_with_stdio(false) 89 ms 115 ms 够,余量 8.7 倍
scanf 120 ms 147 ms
getchar 快读 40 ms 73 ms
fread 快读 43 ms 75 ms
★ 三条能直接用的结论
  1. 四种读法全都够 —— 又一次第 19 章 P1803 那条: 倍数跨题几乎不变,变的是绝对时间,而分数线画在绝对时间上。 ⚠ 但默认 cin 的余量只剩 2.8 倍,比 P1803 那道题(3 秒时限)紧得多 —— ⇒ 「要不要写快读」永远是「数的个数 × 每个数的耗时 ÷ 时限」这道算术题,不是习惯问题。
  2. 关了同步的 cin 又一次比 scanf(89 vs 120 ms)—— 这和第 6 章 P2367第 12 章 P1226 是同一个方向,第三次了
  3. ⚠ 这道题的输出只有两行、共 2n 个数 —— 顶格约 1.2 MB, ★ 但别一个数一个 cout << 地打:本页所有版本都是先拼成一个 stringfwriteB3643 第 ⑧ 步量过,这一步值 30 倍)。

8★ 三个错法各被抓多少(参照物就是正解自己的另一种写法)

300 轮(n 随机 3~10,边序打乱)
忘了 sort 97
前向星倒序 100
vis 忘清 283
⚠ 出队才标记 0(可以证明的恒等,见第 ⑤ 步)

★ 自检:把边(u, v) 排好再喂进去(这一档里邻接表天然升序), 「忘了 sort」当场变成精确的 0,而「前向星倒序」仍被抓 155 —— ⇒ 那个 0 是「这一档结构上抓不到」,不是这段代码没在跑。

9度量程序和生成器

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

10一页纸

★★ 关键的一步 题面那半句「先看编号较小的那篇」⇒ 每个点的出边要 sort
★★ 和上一道的对照 同一个 bug:B3643 的样例挡住了,这道题的样例放过了(97/300 才错)
★★★ 为什么不能用矩阵 n = 10⁵ ⇒ 矩阵 9.31 GB,而邻接表只要 6.10 MB ⇒ 存法是被 n 逼出来的
第三个错法 两次遍历共用 vis 忘了清 ⇒ 第二行只剩 1 (283/300)
⚠ 第四个不是错法 BFS 出队才标记:输出必然逐字节相同(可证),代价顶格只有 5.00 倍入队
★★★ 真正会挂的 递归 DFS:每层 341 字节 ⇒ 2.4 万层就爆;把拼串挪出去 ⇒ 48 字节 / 17.4 万层(×7
⚠ 读入 11.23 MB / 1 秒:四种读法都够,但默认 cin 只剩 2.8 倍余量