阶段 6 · 图论 · 第 30 章普及组 J

图上的 DFS 与 BFS、连通性

这一章不教新算法。第 13、14 章那两份代码原封不动就能用 —— 唯一变的是「邻居是谁」。我们要做的是亲眼确认这件事,然后顺手把三个只有在「不听话的数据」上才会现形的 bug 抓出来。

上一章章末那句话,这一章要当场兑现

第 29 章结尾我写了这么一句:

第 13、14 章那两份代码原封不动就能用。 唯一变的是「邻居是谁」—— 从「上下左右四个方向」换成 for (int v : g[u])。

这一章不重新讲一遍 DFS 和 BFS(那是第 13、14 章的事), 而是把那句话变成你能自己跑一遍的证据:

★ 这一章的关键一步只有一句:

网格是图的一个特例。 每个格子是一个点,相邻的两格之间有一条边。

所以第 13 章那张 8×8 地图,可以真的转成一张图, 再交给一份从没听说过「网格」的代码去跑 —— 答案必须一个字不差。 第 8 步会当场做这件事。

1这一章拿来练手的问题

给一张无向图(n 个点、m 条边,可能有自环,也可能有重边,而且不保证连通) 和一个起点 s,求: ① 图里有几个连通块; ② 从 s 出发,到每个点最少要走几条边(走不到的输出 -1)。

输入第一行是 n m s,接下来 m 行每行两个端点。

为什么把两问放在一起

因为它们正好是第 13 章和第 14 章那两道题的图版:

第 13、14 章(网格) 这一章(图)
① 数连通块 有几片陆地 有几个连通块
② 最短路 迷宫里走几步 最少经过几条边
「邻居」是谁 上下左右四格 邻接表里那一行

★ 而且题面里那三句限制每一句都是为了对拍准备的: 「可能有自环重边」是第 29 章的遗产,「不保证连通」和「起点是 s 不是 1」 则各自对应一个只有在那种数据上才会现形的 bug。第 12 步会看到它们的威力。

2手算一遍:8 个点、9 条边,起点故意不是 1 号

8 9 3          ← 8 个点、9 条边、起点是 3 号
1 2
2 3
1 3
3 4
4 5
5 1
2 2            ← 自环
1 2            ← 和第 1 条重复(重边)
6 7

先把它画出来:1-2-3-4-5-1 连成一个环(还多一条 1-3 的弦), 6-7 单独连在一起,8 号点一条边都没有。

  • 连通块:{1,2,3,4,5}、{6,7}、{8} —— 一共 3 个。 ⚠ 8 号点虽然孤零零的,但它自己就是一个连通块。
  • 从 3 号出发的距离: 3 号自己是 0;它的邻居 2、1、4 都是 1; 5 号要经过 4(或者经过 1)才到,是 2; 6、7、8 号根本走不到,是 -1。

3 / 1 1 0 1 2 -1 -1 -1 —— 这组数后面每一步都会回来验。

3暴力:两问都用「完全不是搜索」的思路做一遍

brute.cpp标准答案:并查集 + 枚举所有简单路径
// 标准答案 —— 两个问题都故意用「完全不是搜索」的思路做一遍
//
// 输入:第一行 n m s(n 个点、m 条无向边、起点 s),接下来 m 行每行两个端点
// ⚠ 可能有自环、可能有重边、**整张图不保证连通**
// 输出:第一行 连通块个数;第二行 从 s 到每个点的最短边数(走不到写 -1)
//
// 为什么标准答案要换思路(第 9 章、第 15 章那条规矩):
// 如果这里也写一遍 DFS / BFS,那和 fast.cpp 就是同一个想法写了两遍 ——
// 只能验出打字错误,验不出想法错误。所以两问都换:
//
// ① 连通块:**朴素并查集**。它根本不「走」图,只是把每条边的两个端点合并到一起,
// 最后数一数还剩几个根。(并查集第 36 章才正式讲,这里用的是最朴素的版本:
// 不路径压缩、不按秩合并,find 一路往上爬。n 很小,够用。)
//
// ② 最短路:**枚举所有简单路径**。从 s 出发一条路走到黑,
// 每走到一个点就用「当前这条路的长度」去更新它的答案,然后回溯换一条路走。
// 它不需要任何关于「最短」的洞察 —— 把所有路都走一遍,最短的自然就在里面。
// 代价是指数级:n 稍微大一点就跑不完了(正文第 4 步有实测)。
//
// 这两个思路和 fast.cpp 的 DFS 染色 / BFS 没有任何共同之处,
// 所以它们同时错到一块去的概率极低 —— 这正是对拍要的。
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g; // 只有第 ② 问的枚举要用它
vector<int> best; // best[v] = 目前找到的、从 s 到 v 的最短长度
vector<char> onPath;
/* ---------- ① 朴素并查集:不搜索,只合并 ---------- */
vector<int> fa;
int find(int x) { return fa[x] == x ? x : find(fa[x]); } // 不压缩路径,最朴素的版本
/* ---------- ② 枚举所有简单路径 ---------- */
void walk(int u, int len) {
if (len < best[u]) best[u] = len;
for (int v : g[u]) {
if (onPath[v]) continue; // 同一条路上不能重复经过一个点,否则会绕圈绕不完
onPath[v] = 1;
walk(v, len + 1);
onPath[v] = 0; // ★ 回溯:撤销,换一条路走(第 4 章那一套)
}
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
fa.resize(n + 1);
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u); // 无向图存两遍(第 29 章那条)
int a = find(u), b = find(v);
if (a != b) fa[a] = b; // 一条边 = 两个端点在同一块里
}
// ① 还剩几个根,就有几个连通块。孤立点(一条边都没有)自己就是一个根,
// 所以它天然被算成了一块 —— 这一点后面会变成一个专门的错误版本。
int blocks = 0;
for (int i = 1; i <= n; i++) if (find(i) == i) blocks++;
// ②
best.assign(n + 1, INT_MAX);
onPath.assign(n + 1, 0);
onPath[s] = 1;
walk(s, 0);
cout << blocks << "\n";
for (int i = 1; i <= n; i++)
cout << (best[i] == INT_MAX ? -1 : best[i]) << " \n"[i == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

标准答案又一次换了思路(第 9 章那条规矩):

  • 连通块用朴素并查集:它根本不「走」图,只是把每条边的两个端点合并到一起, 最后数还剩几个根。(并查集第 36 章才正式讲,这里用的是最朴素的版本。)
  • 最短路用枚举所有简单路径:从 s 出发一条路走到黑,每走到一个点就拿 「当前这条路的长度」去更新它的答案,然后回溯换一条路 —— 第 4 章那一套。
为什么非要换思路

如果标准答案也写一遍 DFS / BFS,那就是同一个想法写了两遍 —— 只能验出打字错误,验不出想法错误。 这一章尤其危险:待会儿那五个错误版本,每一个都长得和正解几乎一模一样 (改一个字母、改一个变量名)。要是标准答案也是同一个模子刻出来的, 很可能两边一起错。

4实测:暴力慢在哪 —— ★ 旋钮不是点数,是边数

先说一件容易搞错的事。「枚举所有简单路径」听起来是「和点数有关」的指数级, 但真正让它爆炸的是平均度数:点越挤,绕法越多。

genBig.cpp固定种子,同一条命令永远造同一张图

本机实测(./genBig 30 <边数>,点数一直是 30 不变):

命令 边数 m 平均度数 暴力(枚举所有路径) DFS + BFS
./genBig 30 60 60 4.0 0.20 秒 0.00 秒
./genBig 30 65 65 4.3 1.10 秒 0.00 秒
./genBig 30 70 70 4.7 9.81 秒 0.00 秒
./genBig 30 75 75 5.0 42.84 秒 0.00 秒

(最后两行每次跑上下浮动一两成,量级是稳的。)

★ 点数一个都没动,只多加了 15 条边,暴力就慢了两百多倍。

⚠ 这张表差点又做废了(第 25 章那个坑的第二次)

第一版我是拿「点数」当旋钮的:./genBig 12、14、16…… 结果一路到 n = 20 全都是 0.00 秒 —— 因为默认边数取的是 2n, 图稀疏得像棵树,从起点出发的简单路径压根没几条,暴力当然快。

第 25 章那句话原样适用:要证明暴力慢,先确认它真的走到底了。 只不过这一次让暴力「假装自己不慢」的不是剪枝,是数据太稀疏。 把旋钮换成边数之后,这张表才立得住 —— 而且顺带得到了一个更准的结论: 指数级的底数藏在平均度数里,不在点数里。

自己动手把那个旋钮拧一遍(genDense 就是「点数固定 30、边数当参数」的 genBig):

同题对比:枚举所有简单路径 vs DFS + BFS
先跑 65,再改成 70、75。⚠ 变的只有边数 —— 点数一直是 30。75 那一档暴力会直接超时(本机要 40 多秒,而运行服务的上限是 15 秒),而右边一直是 0 毫秒。
枚举所有简单路径
DFS + BFS

5★ 关键一步:把第 13 章的代码原样搬过来

先把第 13 章那份 DFS 摆出来,只看它的核心循环:

第 13 章 fast.cpp(网格版 DFS)一个字都不改,先看它

它的心脏是这一段:

for (int d = 0; d < 4; d++) {                 // 上下左右四个方向
    int x = i + dx[d], y = j + dy[d];
    if (x < 0 || x >= n || y < 0 || y >= m) continue;   // 出界
    if (g[x][y] != '1') continue;                       // 是水
    if (vis[x][y]) continue;                            // 走过了
    dfs(x, y);
}

换到图上,它塌成一行:

for (int v : g[u]) {                          // 邻接表里那一行
    if (vis[v]) continue;                     // 走过了
    dfs(v);
}
★ 少掉的那两个 if,正是这一章的全部内容
网格版那三个 if 图版还剩几个
出界了吗(x < 0 || x >= n …) 没了
是墙 / 是水吗(g[x][y] != '1') 没了
走过了吗(vis[x][y]) 留着

为什么能少两个?因为网格里那两句 if,做的其实是同一件事: 每次重新回答「谁是我的邻居」。上下左右四个方向只是候选, 出界的、是墙的都不算数 —— 筛完剩下的才是真邻居。

而邻接表提前把这件事做完了:g[u] 里存的本来就全是合法邻居。

★ 网格是图的一个特例:格子是点,相邻的两格之间有一条边。 「上下左右四个方向」不是搜索的一部分,它只是那张图的建图方式。

除此之外一个字都不用改:DFS 还是那个 DFS,BFS 还是那个 BFS, vis 还是入队时打,「第一次到达 = 最短到达」还是成立。

6正解:一份代码,两问都解决

fast.cppDFS 数连通块 + BFS 求最短路
// 图上的 DFS 与 BFS —— ★ 这份代码几乎是从第 13、14 章原样抄过来的
//
// 和 brute.cpp 解同一道题,答案必须一样,但快得多。
//
// ★ 这一章唯一的关键一步:**网格是图的一个特例**。
//
// 第 13 章数连通块,第 14 章走最短路,用的是同一个套路:
// 「站在一个位置上 → 看它的邻居 → 没去过就去」。
// 那时候「邻居」的意思是「上下左右四格」,所以代码里写的是:
//
// for (int d = 0; d < 4; d++) {
// int x = i + dx[d], y = j + dy[d];
// if (x < 0 || x >= n || y < 0 || y >= m) continue; // 出界
// if (g[x][y] != '1') continue; // 是墙
// if (vis[x][y]) continue; // 去过了
// ...
// }
//
// 现在换成一般的图,「邻居」由邻接表直接给出,那一段就塌成一行:
//
// for (int v : g[u]) {
// if (vis[v]) continue; // 去过了
// ...
// }
//
// **少了「出界」和「是墙」两个判断**,因为邻接表里存的本来就全是合法的邻居 ——
// 网格里那两句 if,本质上就是在临时地、每次重新地回答「谁是我的邻居」。
// 除此之外:DFS 还是那个 DFS,BFS 还是那个 BFS,vis 还是入队时打,
// 「第一次到达 = 最短到达」还是成立。**一个字都不用改。**
//
// 复杂度:两问都是 O(n + m) —— 每个点进出一次,每条边被两端各看一次
// (第 29 章那张表里「表扫一遍 = 2m」的那一行)。
//
// ⚠ 自环和重边对搜索完全无害:自环指向自己,vis 早就是 1 了;
// 重边只是让同一个邻居出现两次,第二次照样被 vis 挡住。**不用特意去重。**
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g;
vector<char> vis;
/* ---------- ① DFS 染色数连通块 —— 第 13 章 fast.cpp 的图版 ---------- */
void dfs(int u) {
vis[u] = 1; // 先给脚下这个点染色
for (int v : g[u]) { // ★ 唯一变了的一行:邻居从「四个方向」变成邻接表
if (vis[v]) continue; // 已经染过了
dfs(v); // 走过去,重复同样的事
}
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u); // 无向图存两遍(第 29 章)
}
// ① 每发起一次 dfs,就意味着发现了一块新的 —— 和第 13 章那两层循环一模一样,
// 只是「扫每个格子」变成了「扫每个点」。
// ⚠ 必须扫过 1..n 的每一个点:一条边都没有的孤立点,它自己就是一个连通块。
vis.assign(n + 1, 0);
int blocks = 0;
for (int i = 1; i <= n; i++) {
if (!vis[i]) {
blocks++;
dfs(i);
}
}
// ② BFS 求最短边数 —— 第 14 章 fast.cpp 的图版
// 队列先进先出,先进队列的一定离 s 更近,所以队列天然按距离排好了序。
// ★ 第一次到达 = 最短到达。
vector<int> dist(n + 1, -1); // -1 = 还没到过(也正好是「走不到」要输出的值)
queue<int> q;
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : g[u]) { // ★ 又是同一行
if (dist[v] != -1) continue; // 来过了,而且那次一定不比这次远
dist[v] = dist[u] + 1; // ← 入队时就定下距离并标记,不是出队时
q.push(v); // (出队才标记 = BFS 的头号错误,正文第 9 步)
}
}
cout << blocks << "\n";
for (int i = 1; i <= n; i++)
cout << dist[i] << " \n"[i == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 3 / 1 1 0 1 2 -1 -1 -1,和第 2 步手算的一样。复杂度 O(n + m) —— 每个点进出一次,每条边被两端各看一次,正好是第 29 章那张表里「表扫一遍 = 2m」那一行。

两个细节,都在注释里但值得单独说

① 数连通块必须扫过 1..n 的每一个点,不能只扫「有边的点」。 一条边都没有的孤立点,它自己就是一个连通块。第 10 步有这个错误版本。

② 自环和重边对搜索完全无害,不用去重。 自环指向自己,vis 早就是 1 了;重边只是让同一个邻居在 g[u] 里出现两次, 第二次照样被 vis 挡住。

第 29 章花了一整章讲自环和重边有多容易出事,这一章正好补上另一半: 要不要为它们操心,取决于你拿这张图做什么。 数度数要操心,搜索不用。

7★ 动画:左边是网格,右边是图,一个 DFS 同时在两幅画上走

★ 左边是网格,右边是图 —— 一个 DFS 同时在两幅画上走
11 点 7 边 · 数出 4 块
第 1 / 28 步
网格(第 13 章的样子):邻居 = 上下左右四格
1
2
3
4
5
6
7
8
9
10
11
格子里的数字就是它在右边那张图里的编号(空白 = 水,那里没有点)
同一张图(本章的样子):邻居 = 邻接表里那一行
1234567891011
点是按编号均匀摆在圆上的 —— 位置纯属画着好看,图不在乎点画在哪里,只在乎谁和谁有边
递归栈
(空)
已经数出的块数
0
左边是网格,右边是它转成的图:11 个陆地格 → 11 个点,相邻的两格之间连一条边(一共 7 条)。右边的点摆成一个圈,看着和网格毫无关系 —— 但接下来两边会一步不差地同时走。

左边是一张 5×5 的小地图(11 个陆地格),右边是它转成的图: 11 个点被均匀摆在一个圆上,边成了乱七八糟的弦 —— 看着和网格毫无关系。

但请注意:两幅画是同一帧数据画出来的。同一个 DFS、同一个访问顺序、同一批颜色。 右边那个圈之所以看着不像网格,只是因为我把点画到别处去了 —— 图不在乎点画在哪里,只在乎谁和谁有边。

这张地图有 4 个连通块,其中 3 个是孤立点(被水围住的单格陆地)。 把下拉框切到「✗ 只从有边的点发起」,4 块当场塌成 1 块。

8★ 跨章节交叉验证:把第 13 章那张地图真的转成图

光看动画还不够。这一步要动真格的:把第 13 章那张 8×8 地图转成一张图的输入文件, 再交给第 6 步那份 fast.cpp —— 它对「网格」二字一无所知。

gridToGraph.cpp网格 → 图(./gridToGraph info 看对应关系)
// ★ 把第 13 章那张 8×8 地图,真的转成一张图
//
// 这份代码存在的理由只有一个:**把「网格是图的一个特例」从一句话变成一件可验证的事。**
//
// 转换规则就是那句话本身:
// · 每个**陆地格**是一个点(水格不建点);
// · 两个相邻(上下左右)的陆地格之间连一条无向边。
//
// 编号按行优先,从 1 开始 —— 于是 (0,0) 是 1 号点,(n-1,m-1) 是最后一个点。
//
// 输出的格式正好是本章那道题的输入(`点数 边数 起点` + 边表),所以可以直接接管道:
//
// ./gridToGraph < map.txt | ./fast
//
// 跑出来第一行必须是 **3** —— 和第 13 章 fast.cpp 在同一张地图上数出的连通块数一模一样;
// 第二行里终点那个位置必须是 **16** —— 和第 14 章 fast.cpp 走出的最短路一模一样。
//
// ★ 两份代码:一份满脑子「上下左右四个方向」,一份只知道 `for (int v : g[u])`,
// 谁都没听说过对方,**答案却一个字不差**。这就是「网格是图的一个特例」的证据。
//
// scripts/check-viz.mjs 会把这条管道真的跑一遍,和第 13、14 章的 C++ 逐个对答案。
//
// 用法:./gridToGraph 读网格,输出图(可以直接管道给 fast / brute)
// ./gridToGraph info 读网格,输出「谁变成了几号点」的说明
//
// 输入格式和第 13、14 章完全一致:第一行 n m,接下来 n 行每行 m 个字符,'1' 是陆地。
#include <bits/stdc++.h>
using namespace std;
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = {0, 0, -1, 1};
int main(int argc, char** argv) {
bool info = (argc > 1 && string(argv[1]) == "info");
int n, m;
if (!(cin >> n >> m)) return 0;
vector<string> g(n);
for (int i = 0; i < n; i++) cin >> g[i];
// ① 给每个陆地格发一个编号(水格是 0,表示「这里没有点」)
vector<vector<int>> id(n, vector<int>(m, 0));
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (g[i][j] == '1') id[i][j] = ++cnt;
// ② 相邻的两个陆地格之间连一条边。
// 只往「下」和「右」看,每条边就正好被造一次(往上/往左看会把每条边造两遍)。
vector<pair<int, int>> es;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++) {
if (g[i][j] != '1') continue;
for (int d = 1; d <= 3; d += 2) { // 只取 dx/dy 里的「下」和「右」
int x = i + dx[d], y = j + dy[d];
if (x >= n || y >= m) continue;
if (g[x][y] != '1') continue;
es.push_back({id[i][j], id[x][y]});
}
}
// ③ 起点取 (0,0)(它在第 13、14 章里也是起点)。万一它是水,就退而取第一个陆地格。
int s = (n > 0 && m > 0 && g[0][0] == '1') ? id[0][0] : 1;
int target = (g[n - 1][m - 1] == '1') ? id[n - 1][m - 1] : -1;
if (!info) {
cout << cnt << " " << es.size() << " " << s << "\n";
for (auto [u, v] : es) cout << u << " " << v << "\n";
return 0;
}
printf("%d x %d 的网格里有 %d 个陆地格 -> %d 个点,%d 条边\n", n, m, cnt, cnt, (int)es.size());
printf("起点 (0,0) 是 %d 号点,终点 (%d,%d) 是 %d 号点\n", s, n - 1, m - 1, target);
printf("每个格子对应的编号(0 = 水,这里没有点):\n");
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) printf("%4d", id[i][j]);
printf("\n");
}
printf("\n把它交给本章的 fast.cpp(./gridToGraph < map | ./fast):\n");
printf(" 第一行 = 连通块个数,必须和第 13 章 fast.cpp 的答案一样;\n");
printf(" 第二行第 %d 个数 = 到终点的最短边数,必须和第 14 章 fast.cpp 的答案一样。\n", target);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

那张 8×8 地图里有 36 个陆地格 → 36 个点、33 条边, (0,0) 是 1 号点,(7,7) 是 36 号点。接上管道:

./gridToGraph < map.txt | ./fast
★ 两份互不相识的代码,答案一个字不差
第 13 / 14 章(网格版) 本章(图版)
连通块 3 3
到 (7,7) 的最短步数 16 第 36 个数 = 16

一份代码满脑子「上下左右四个方向」,另一份只知道 for (int v : g[u]), 它们谁都没听说过对方 —— 可答案完全一样。

这就是「网格是图的一个特例」的证据,不是一句口号。

check:viz 每次都会把这条管道真的跑一遍, 并且拿两边的输出和第 13、14 章那两份 fast.cpp 逐个对答案 (第 38 章还计划这么干一次:用树状数组重做第 11 章的逆序对)。

9四种把最短路写错的方式

同一张图,四种求最短路的写法
答案 1 1 0 1 2 -1 -1 -1
第 1 / 7 步
1·2·30起点4·5·6·7·8·
队列(队首在左)
3
入队次数
1走得到的点一共 5 个
入队时就标记,所以一个点最多进一次队。
各点的距离
1
-1
2
-1
3
0
4
-1
5
-1
6
-1
7
-1
8
-1
起点是 3 号点。 把 3 号点放进队列,距离 0。 队列是 BFS 的全部机关:先进先出,所以近的一定先被处理。

下拉框里那四个错误版本,各对应下面一份代码。先看动画怎么错的,再看代码错在哪。

wrongPop.cpp✗ BFS 出队时才标记(头号错误)
// ✗ 错误版本一:BFS 在**出队**的时候才标记,不是入队时标记
//
// 这是 BFS 的头号错误,第 14 章就点过名,但那时候只说了「会退化成暴力」。
// 在图上它更狠一点 —— **答案本身就是错的**,不只是慢。
//
// 为什么错:一个点可能被好几个邻居同时看见,于是被塞进队列好几次。
// 每塞一次都会重新写一遍它的 dist,而后写的那次不一定更近 ——
// 于是一个明明距离 1 的点,可能被后来的某次入队改成 2。
//
// ★ 「入队时标记」保证的是:**一个点只会被记一次距离,而且是第一次(也就是最短的那次)**。
// 出队才标记,这个保证就没了。
//
// 只改了 BFS 那一段,其它一个字没动。
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g;
vector<char> vis;
void dfs(int u) {
vis[u] = 1;
for (int v : g[u]) {
if (vis[v]) continue;
dfs(v);
}
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
vis.assign(n + 1, 0);
int blocks = 0;
for (int i = 1; i <= n; i++)
if (!vis[i]) { blocks++; dfs(i); }
vector<int> dist(n + 1, -1);
vector<char> done(n + 1, 0); // ✗ 「已处理」标记,出队时才打
queue<int> q;
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
if (done[u]) continue;
done[u] = 1; // ✗ 出队才标记 —— 晚了
for (int v : g[u]) {
if (done[v]) continue;
dist[v] = dist[u] + 1; // ✗ 已经在队列里的点,距离被后来者覆盖掉
q.push(v);
}
}
cout << blocks << "\n";
for (int i = 1; i <= n; i++)
cout << dist[i] << " \n"[i == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 2 1 0 1 2(1 号点应该是 1)。第 14 章说过这个错误「会退化成暴力」, 但在图上它更狠:答案本身就是错的。

道理在动画的计数器里:一个点可能被好几个邻居同时看见,于是被塞进队列好几次, 每塞一次都会重写一遍它的距离 —— 而后写的那次不一定更近。 默认这张图上,正确写法入队 5 次(正好等于走得到的点数), 出队才标记入队 8 次,多出来的 3 次全是在改写别人的答案。

★ 「入队时标记」保证的是:一个点只被记一次距离,而且是第一次 —— 也就是最短的那次。

wrongDfsDist.cpp✗ 用 DFS 求最短路(拿递归深度当距离)
// ✗ 错误版本二:用 DFS 求最短路(拿递归深度当距离)
//
// 想法听起来很顺:反正 DFS 也能把整块都走一遍,顺手记一下「我是第几层走到你的」不就完了?
//
// 错在哪:DFS 是**一条路走到黑**,它到达一个点时走的那条路,
// 通常不是最短的那条 —— 而 vis 一打上,它就再也不会用更短的路来看你一眼了。
//
// ★ 一句话:**DFS 的深度是「我沿着这条路走了多久」,BFS 的距离是「最少要走多久」。**
// 两者只有在树上才必然相等(树上任意两点之间只有一条路)。
//
// 更阴的地方:它的答案**不是随机地错**,而是「谁先被走到,谁的距离就定死了」——
// 换一个邻接表的存边顺序,错法就变一个样。第 9 步的动画里能看到这一点。
//
// 只改了第 ② 问,连通块那一问是对的。
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g;
vector<char> vis;
vector<int> dist_;
void dfs(int u) {
vis[u] = 1;
for (int v : g[u]) {
if (vis[v]) continue;
dfs(v);
}
}
// ✗ 拿递归深度当最短距离
void dfsDist(int u, int d) {
dist_[u] = d;
for (int v : g[u]) {
if (dist_[v] != -1) continue;
dfsDist(v, d + 1);
}
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
vis.assign(n + 1, 0);
int blocks = 0;
for (int i = 1; i <= n; i++)
if (!vis[i]) { blocks++; dfs(i); }
dist_.assign(n + 1, -1);
dfsDist(s, 0);
cout << blocks << "\n";
for (int i = 1; i <= n; i++)
cout << dist_[i] << " \n"[i == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 2 1 0 4 3 —— 4 号点明明是 3 号的直接邻居,却被记成了 4。

★ DFS 的深度不是距离

DFS 是一条路走到黑:它到达一个点时走的那条路,通常不是最短的那条, 而 vis 一打上,就再也不会有人用更短的路来看它一眼了。

DFS 的深度是「我沿着这条路走了多久」,BFS 的距离才是「最少要走多久」。

两者只在树上必然相等(树上任意两点之间只有一条路,没得选)。 一旦图里有环,「路不止一条」,它们立刻分家 —— 而树之所以是树,正是因为它没有环。 第 27 章那些树形 DP 之所以能安心用 DFS,靠的就是这一点。

order.cpp把两者逐点并排打出来
// 把 DFS 和 BFS 并排跑一遍,逐点打出「访问顺序 / 深度 / 距离」
//
// 这份代码要回答两个问题,而且都是拿数字回答,不是拿话回答:
//
// ① **DFS 的深度不是最短距离** —— 表里第 4 列和第 6 列不一样的那些行,就是反例。
// 两者在树上必然相等(树上两点之间只有一条路),一旦图里有环就可能分家。
//
// ② **BFS 的 vis 为什么必须在入队时打** —— 最后那三行把两种写法的**入队次数**数出来:
// 入队时标记,每个点最多进队一次;出队才标记,同一个点会被反复塞进去,
// 而每塞一次都会覆盖一遍它的距离。这就是「答案会错」的机理。
//
// 输入格式和本章其它程序一致:n m s + m 行边。
// scripts/check-viz.mjs 会拿这张表和动画逐行核对。
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g;
vector<int> dfsOrder, dfsDepth, bfsOrder, bfsDist;
int counter1 = 0, counter2 = 0;
void dfs(int u, int d) {
dfsOrder[u] = ++counter1;
dfsDepth[u] = d;
for (int v : g[u]) {
if (dfsOrder[v]) continue;
dfs(v, d + 1);
}
}
/** 按显示宽度补空格(一个汉字占 2 格宽、3 个字节)——
* printf 的 %-Ns 数的是字节数,含中文的列必须自己补。第 26 章那份 padDisp 的同款。 */
string padDisp(const string& s, int width) {
int w = 0;
for (size_t i = 0; i < s.size();) {
unsigned char c = s[i];
if (c < 0x80) { w += 1; i += 1; }
else if (c < 0xE0) { w += 2; i += 2; }
else if (c < 0xF0) { w += 2; i += 3; }
else { w += 2; i += 4; }
}
return s + string(max(0, width - w), ' ');
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfsOrder.assign(n + 1, 0);
dfsDepth.assign(n + 1, -1);
dfs(s, 0);
bfsOrder.assign(n + 1, 0);
bfsDist.assign(n + 1, -1);
int pushOk = 0;
{
queue<int> q;
bfsDist[s] = 0;
bfsOrder[s] = ++counter2;
q.push(s);
pushOk++;
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : g[u]) {
if (bfsDist[v] != -1) continue;
bfsDist[v] = bfsDist[u] + 1; // ★ 入队时就定下距离并标记
bfsOrder[v] = ++counter2;
q.push(v);
pushOk++;
}
}
}
// ✗ 出队才标记的那份,只数它入了多少次队
int pushBad = 0;
{
vector<char> done(n + 1, 0);
queue<int> q;
q.push(s);
pushBad++;
while (!q.empty()) {
int u = q.front();
q.pop();
if (done[u]) continue;
done[u] = 1;
for (int v : g[u]) {
if (done[v]) continue;
q.push(v);
pushBad++;
}
}
}
printf("起点 s = %d,%d 个点、%d 条边\n\n", s, n, m);
printf("%s%s%s%s%s%s\n",
padDisp("点", 6).c_str(), padDisp("DFS 第几个到", 16).c_str(),
padDisp("DFS 深度", 12).c_str(), padDisp("BFS 第几个到", 16).c_str(),
padDisp("BFS 距离", 12).c_str(), padDisp("深度=距离?", 12).c_str());
printf("---- ------------ -------- ------------ -------- ----------\n");
int diff = 0;
for (int i = 1; i <= n; i++) {
bool reach = (bfsDist[i] != -1);
bool same = (dfsDepth[i] == bfsDist[i]);
if (reach && !same) diff++;
printf("%s%s%s%s%s%s\n",
padDisp(to_string(i), 6).c_str(),
padDisp(dfsOrder[i] ? to_string(dfsOrder[i]) : "-", 16).c_str(),
padDisp(dfsDepth[i] >= 0 ? to_string(dfsDepth[i]) : "-", 12).c_str(),
padDisp(bfsOrder[i] ? to_string(bfsOrder[i]) : "-", 16).c_str(),
padDisp(reach ? to_string(bfsDist[i]) : "-", 12).c_str(),
padDisp(!reach ? "(走不到)" : (same ? "是" : "✗ 不一样"), 12).c_str());
}
printf("\n★ 有 %d 个点的「DFS 深度」和「BFS 最短距离」不一样 —— 深度不是距离。\n", diff);
printf("\n入队次数:入队时标记 %d 次(= 走得到的点数),出队才标记 %d 次。\n", pushOk, pushBad);
if (pushBad > pushOk)
printf("★ 多出来的 %d 次,每一次都会重写一遍某个点的距离 —— 这就是它答案会错的原因。\n",
pushBad - pushOk);
else
printf("(这张图上两者恰好一样多,换一张有环的图再看。)\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

默认这张图上,8 个点里有 3 个的「DFS 深度」和「BFS 距离」不一样。 这张表最后两行还把上一个 bug 的入队次数(5 vs 8)数了出来 —— 「顺序错了」和「标记打晚了」从此都是数字,不是一句话。

wrongStart1.cpp✗ 从 1 号点出发(无视题目给的 s)
wrongUnreach.cpp✗ dist 初值忘了设 -1,走不到的点输出 0

这两个错误在第 12 步会变成这一章最贵的一课 —— 它们的死活完全取决于数据长什么样。

10第五种错法:孤立点不算一块

wrongIsolated.cpp✗ 只从「有边的点」发起 DFS
// ✗ 错误版本三:数连通块时只从「有边的点」发起 DFS —— 孤立点漏掉了
//
// 写法本身看着很讲道理:「一个点连边都没有,还搜它干嘛?」
// 可是**一个孤立点自己就是一个连通块**(它和自己连通,仅此而已)。
//
// ★ 这个 bug 的可怕之处不在代码,在数据:
// 只要生成器保证图是连通的(造一棵生成树再加边 —— 这是绝大多数人写图生成器时的条件反射),
// 图里就永远不会有孤立点,这份代码永远是对的。
// 本章第 12 步实测:那种「顺手保证连通」的数据下,它 **0 / 300**。
//
// 只改了那一处判断。
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g;
vector<char> vis;
void dfs(int u) {
vis[u] = 1;
for (int v : g[u]) {
if (vis[v]) continue;
dfs(v);
}
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
vis.assign(n + 1, 0);
int blocks = 0;
for (int i = 1; i <= n; i++) {
if (g[i].empty()) continue; // ✗ 「没有边的点不用管」—— 它自己就是一块
if (!vis[i]) { blocks++; dfs(i); }
}
vector<int> dist(n + 1, -1);
queue<int> q;
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : g[u]) {
if (dist[v] != -1) continue;
dist[v] = dist[u] + 1;
q.push(v);
}
}
cout << blocks << "\n";
for (int i = 1; i <= n; i++)
cout << dist[i] << " \n"[i == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 2 块(正确是 3)。写法本身看着很讲道理: 「一个点连边都没有,还搜它干嘛?」—— 可它自己就是一个连通块。

回到第 7 步那个动画,把下拉框切到这一档:网格里那三个被水围住的单格陆地全被跳过了, 4 块塌成 1 块。在网格里它叫「一格孤岛」,在图里它叫「孤立点」,是同一样东西。

11⚠ 一个网格题里碰不到的坑:递归 DFS 会爆栈

★ 图上 DFS 的递归深度上限是「点数」

把第 6 步那份 fast.cpp 拿去跑一张 50 万个点的图,本机直接段错误(signal 11)。

不是算法错了,是栈爆了。fastIter.cpp 里那个手写栈把深度数了出来 (./fastIter depth):

./genBig <n>(m = 2n) 递归 DFS 最深要压多少层 递归版 不用递归的版本
10 万 57 230 层 0.13 秒 0.05 秒
30 万 172 194 层 0.61 秒 0.31 秒
45 万 257 516 层 0.86 秒 0.49 秒
50 万 285 380 层 ✗ 段错误 0.68 秒
100 万 572 191 层 ✗ 段错误 1.35 秒

本机默认栈是 8 MB,一个栈帧约 30 字节 —— 二十几万层正好用完。 (顺便注意最深那一列:它稳定在点数的 57% 左右。 随机图上 DFS 一条路能走掉一大半的点,这不是极端构造,是常态。)

和第 21 章那个「记忆化递归 30 万层直接段错误、递推 1000 万都没事」是同一个坑。 只不过第 13 章那张 8×8 地图太小了,碰不上。

fastIter.cpp同样的答案,一行递归都没有
// 和 fast.cpp 完全一样的答案,但**一行递归都没有**
//
// 这份代码为什么存在:因为 fast.cpp 在大图上会**段错误**。
//
// 本机实测(./genBig 500000,m = 2n):递归版直接 signal 11 挂掉,
// 而这一份 0.7 秒跑完。`ulimit -s unlimited` 之后递归版能跑,但那是在改机器,不是在改代码 ——
// 评测机上你改不了。
//
// ★ 原因:**图上 DFS 的递归深度上限是「点数」,不是「层数」。**
// 网格题里其实也一样(第 13 章那张图要是 1000×1000,同样会炸),
// 只是那时候数据小,没碰上。这和第 21 章 stairsDeep.cpp 那个
// 「记忆化递归 30 万层直接段错误、递推 1000 万都没事」是**同一个坑**。
//
// 两种改法,这里各用一次:
// ① 数连通块 —— 干脆改用 **BFS 染色**。
// 「哪些点连在一起」这件事跟走的顺序毫无关系,DFS 能做的 BFS 一样能做,
// 而 BFS 的队列在堆上,没有深度问题。**能用 BFS 就别用递归 DFS。**
// ② 真需要 DFS 的顺序时 —— 用**手写栈**把递归摊开:
// 栈里存 (当前点, 下一条要看的边是第几条),正好就是递归帧里的那两个局部变量。
// 本文件的 maxDepth 就是这么数出来的,它等于递归版要压多少层栈帧。
//
// 用法:./fastIter 和 fast.cpp 一样的输出(check:viz 300 组逐组比对)
// ./fastIter depth 再多打一行:手写栈最深压到多少层
//
// ⚠ 别把这理解成「递归不能用」。n 只有几万时递归版更短更好读,用它;
// n 上十万,先想一想这个深度。**知道界在哪,比背「递归不安全」有用。**
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
bool showDepth = (argc > 1 && string(argv[1]) == "depth");
int n, m, s;
if (scanf("%d %d %d", &n, &m, &s) != 3) return 0;
vector<vector<int>> g(n + 1);
for (int i = 0; i < m; i++) {
int u, v;
if (scanf("%d %d", &u, &v) != 2) return 0;
g[u].push_back(v);
g[v].push_back(u);
}
// ① 连通块:BFS 染色。和 fast.cpp 的 DFS 版给出同一个答案 ——
// 连通块只关心「谁和谁连着」,跟访问顺序无关。
vector<char> vis(n + 1, 0);
int blocks = 0;
{
vector<int> q;
for (int i = 1; i <= n; i++) {
if (vis[i]) continue;
blocks++;
q.clear();
q.push_back(i);
vis[i] = 1;
for (size_t h = 0; h < q.size(); h++)
for (int v : g[q[h]])
if (!vis[v]) { vis[v] = 1; q.push_back(v); }
}
}
// ② 最短路:本来就是 BFS,一个字都不用改
vector<int> dist(n + 1, -1);
{
vector<int> q;
dist[s] = 0;
q.push_back(s);
for (size_t h = 0; h < q.size(); h++)
for (int v : g[q[h]])
if (dist[v] == -1) { dist[v] = dist[q[h]] + 1; q.push_back(v); }
}
printf("%d\n", blocks);
for (int i = 1; i <= n; i++) printf("%d%c", dist[i], " \n"[i == n]);
if (showDepth) {
// ③ 手写栈版的 DFS:栈里那两个字段,就是递归帧里的两个局部变量。
// 它压到多少层,递归版就要压多少层栈帧 —— 这个数就是段错误的根源。
vector<char> seen(n + 1, 0);
vector<pair<int, size_t>> st; // (点, 下一条要看的边是第几条)
size_t maxDepth = 0;
for (int i = 1; i <= n; i++) {
if (seen[i]) continue;
st.clear();
seen[i] = 1;
st.push_back({i, 0});
while (!st.empty()) {
maxDepth = max(maxDepth, st.size());
int u = st.back().first;
size_t k = st.back().second;
if (k == g[u].size()) { st.pop_back(); continue; }
st.back().second = k + 1; // 这一条边看过了,下次从下一条开始
int v = g[u][k];
if (seen[v]) continue;
seen[v] = 1;
st.push_back({v, 0}); // ← 相当于递归调用 dfs(v)
}
}
printf("手写栈最深压到 %zu 层(递归版就要压这么多层栈帧,%d 个点里的 %.1f%%)\n",
maxDepth, n, 100.0 * (double)maxDepth / n);
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

两种改法,这份代码里各用了一次:

  • 数连通块干脆改用 BFS 染色。「哪些点连在一起」跟走的顺序毫无关系, DFS 能做的 BFS 一样能做,而队列在堆上,没有深度问题。 ★ 能用 BFS 就别用递归 DFS —— 这是这一章最实用的一条。
  • 真需要 DFS 的顺序时,用手写栈把递归摊开: 栈里存 (当前点, 下一条要看的边是第几条),正好就是递归帧里那两个局部变量。

⚠ 别把这理解成「递归不能用」。n 只有几万时递归版更短更好读,用它就是了。 n 上十万,先想一想这个深度。知道界在哪,比背「递归不安全」有用得多。

这一节的实验只能在终端里做

上面那张表没有配网页上的「同题对比」小工具 —— 不是偷懒: 50 万个点的图,光输入文件就有十几 MB,本地运行服务对输出有 64 KB 的上限, 数据传不过去。想复现的话在终端里跑:

g++ -O2 -std=c++17 -o genBig genBig.cpp
g++ -O2 -std=c++17 -o fast fast.cpp
g++ -O2 -std=c++17 -o fastIter fastIter.cpp
./genBig 500000 > big.txt
./fast     < big.txt   # ✗ Segmentation fault
./fastIter < big.txt   # ✓ 0.7 秒
./fastIter depth < big.txt | tail -1   # 看看它压了多少层

12★ 对拍:两个「顺手」的写法,一次掩盖三个 bug

对拍器
★ 这个生成器的灵魂是「图可能是碎的,起点也不一定是 1 号」。绝大多数人写图生成器时会顺手保证连通、顺手让 1 号当起点 —— 而这一章有三个 bug 全躲在那两件事后面。
// 图上的 DFS 与 BFS —— ★ 这份代码几乎是从第 13、14 章原样抄过来的
//
// 和 brute.cpp 解同一道题,答案必须一样,但快得多。
//
// ★ 这一章唯一的关键一步:**网格是图的一个特例**。
//
// 第 13 章数连通块,第 14 章走最短路,用的是同一个套路:
// 「站在一个位置上 → 看它的邻居 → 没去过就去」。
// 那时候「邻居」的意思是「上下左右四格」,所以代码里写的是:
//
// for (int d = 0; d < 4; d++) {
// int x = i + dx[d], y = j + dy[d];
// if (x < 0 || x >= n || y < 0 || y >= m) continue; // 出界
// if (g[x][y] != '1') continue; // 是墙
// if (vis[x][y]) continue; // 去过了
// ...
// }
//
// 现在换成一般的图,「邻居」由邻接表直接给出,那一段就塌成一行:
//
// for (int v : g[u]) {
// if (vis[v]) continue; // 去过了
// ...
// }
//
// **少了「出界」和「是墙」两个判断**,因为邻接表里存的本来就全是合法的邻居 ——
// 网格里那两句 if,本质上就是在临时地、每次重新地回答「谁是我的邻居」。
// 除此之外:DFS 还是那个 DFS,BFS 还是那个 BFS,vis 还是入队时打,
// 「第一次到达 = 最短到达」还是成立。**一个字都不用改。**
//
// 复杂度:两问都是 O(n + m) —— 每个点进出一次,每条边被两端各看一次
// (第 29 章那张表里「表扫一遍 = 2m」的那一行)。
//
// ⚠ 自环和重边对搜索完全无害:自环指向自己,vis 早就是 1 了;
// 重边只是让同一个邻居出现两次,第二次照样被 vis 挡住。**不用特意去重。**
#include <bits/stdc++.h>
using namespace std;
int n, m, s;
vector<vector<int>> g;
vector<char> vis;
/* ---------- ① DFS 染色数连通块 —— 第 13 章 fast.cpp 的图版 ---------- */
void dfs(int u) {
vis[u] = 1; // 先给脚下这个点染色
for (int v : g[u]) { // ★ 唯一变了的一行:邻居从「四个方向」变成邻接表
if (vis[v]) continue; // 已经染过了
dfs(v); // 走过去,重复同样的事
}
}
int main() {
if (!(cin >> n >> m >> s)) return 0;
g.assign(n + 1, {});
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u); // 无向图存两遍(第 29 章)
}
// ① 每发起一次 dfs,就意味着发现了一块新的 —— 和第 13 章那两层循环一模一样,
// 只是「扫每个格子」变成了「扫每个点」。
// ⚠ 必须扫过 1..n 的每一个点:一条边都没有的孤立点,它自己就是一个连通块。
vis.assign(n + 1, 0);
int blocks = 0;
for (int i = 1; i <= n; i++) {
if (!vis[i]) {
blocks++;
dfs(i);
}
}
// ② BFS 求最短边数 —— 第 14 章 fast.cpp 的图版
// 队列先进先出,先进队列的一定离 s 更近,所以队列天然按距离排好了序。
// ★ 第一次到达 = 最短到达。
vector<int> dist(n + 1, -1); // -1 = 还没到过(也正好是「走不到」要输出的值)
queue<int> q;
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : g[u]) { // ★ 又是同一行
if (dist[v] != -1) continue; // 来过了,而且那次一定不比这次远
dist[v] = dist[u] + 1; // ← 入队时就定下距离并标记,不是出队时
q.push(v); // (出队才标记 = BFS 的头号错误,正文第 9 步)
}
}
cout << blocks << "\n";
for (int i = 1; i <= n; i++)
cout << dist[i] << " \n"[i == n];
return 0;
}
点一下即可编辑

300 轮实测,五个错误版本:

故意写错的地方 被抓 第几轮
从 1 号点出发 244 / 300 第 1 轮
拿 DFS 深度当距离 184 / 300 第 1 轮
出队才标记 151 / 300 第 1 轮
走不到的点输出 0 141 / 300 第 1 轮
孤立点不算一块 102 / 300 第 1 轮
★ 生成器改了两次,而「最初」那一版一次漏掉三个 bug

gen.cpp 带了三个档位(./gen 种子 档位)。种子固定 1..300:

档位 改了什么 从 1 号出发 深度当距离 出队才标记 走不到输出 0 孤立点
0(最初) 保证连通 + 起点固定 1 号 0 221 186 0 0
1 不再保证连通 0 182 149 141 102
2(在用) 起点也随机 244 184 151 141 102

最初那一版一次性掩盖了三个 bug。 而它的写法一点都不奇怪 —— 「先造一棵生成树保证连通、起点就写 1」几乎是每个人写图生成器时的条件反射, 因为「一张图」在直觉里就该是连成一片的,而起点写哪个都一样。 可题目从头到尾没说过图是连通的,也没说起点是 1 号。

★ 连着前三章,这已经是同一个毛病的第四张脸:

章 生成器里那个「顺手」的写法 于是哪个 bug 隐身了
27 顺手让 1 号点当根 「没找根,直接从 1 号 DFS」 0 / 300
28 顺手把距离矩阵镜像一下 「方向写反」 0 / 300
29 顺手去掉自环和重边 一口气三个 0 / 300
30 顺手保证连通 + 顺手让 1 号当起点 又是一口气三个 0 / 300

四次的根因是同一句话:

生成器里那个不假思索的「顺手」写法,悄悄给数据加了一条题目里没有的性质。

而且四次的改动没有一次是在调数值(不是值域、不是规模), 全都在结构 / 角色分配上。所以「数据要随机」这句话得拆成两半: 数值要随机,结构和角色也要随机。

gen.cpp(带三个档位的生成器)两次改动都能重跑
genNice.cpp(永远连通、永远从 1 号出发)演示用:反面教材
⚠ 一笔老实账:这两次改动不是纯赚的

看上面那张表「深度当距离」和「出队才标记」那两列:另外两个 bug 的抓获率反而掉了 (221 → 184,186 → 151)。

原因不难想:图一旦碎成好几块,从起点走得到的点就少了, 「有环、绕得开」的机会自然也少 —— 而这两个 bug 恰恰要靠环才现形。

但这笔账仍然值得。 掉的那三十几轮换来的是从 0 到 100 多: 一个 0 / 300 的 bug 是完全测不到,而 102 / 300 是第 1 轮就抓住。 两者的差别不是「多一点少一点」,是「有」和「无」。

(第 27 章那个档位 3 也是同样的账,那次的结论是「抓获率是重要指标,但不是唯一指标」。 这次更极端一点:为了把 0 变成非 0,掉多少抓获率都是划算的。)

13这一章可以带走的四样东西

★ 关键的一步

【1】网格是图的一个特例,所以第 13、14 章的代码原封不动能用。

// 网格:每次重新回答「谁是我的邻居」
for (int d = 0; d < 4; d++) { …出界?是墙?走过了?… }

// 图:邻接表提前把这件事做完了
for (int v : g[u]) { …走过了?… }

★ 「上下左右四个方向」不是搜索的一部分,它只是那张图的建图方式。 第 8 步那条管道(8×8 地图 → 图 → 本章的代码 → 3 和 16)是这句话的证据。

【2】DFS 的深度不是距离,BFS 的 vis 必须在入队时打。 前者错在「一条路走到黑,先到的不一定是最近的」; 后者错在「一个点被重复入队,后写的距离会覆盖先写的」。 两个错误在默认那张图上都能一眼看见:2 1 0 4 3 和 2 1 0 1 2。

【3】图上 DFS 的递归深度上限是「点数」,50 万个点就爆栈了。 数连通块用 BFS 染色就没这个问题;真要 DFS 的顺序,就用手写栈。 能用 BFS 就别用递归 DFS。

【4】生成器里的「顺手」写法,已经连续四章是同一个坑。 顺手让 1 号当根、顺手镜像矩阵、顺手去掉自环重边、 顺手保证连通 + 顺手让 1 号当起点 —— 每一次都让真 bug 拿到 0 / 300, 每一次改的都不是数值。写生成器前先问: 题目到底允不允许?我是不是替它做了主?

下一章预告

第 31 章:拓扑排序。

这一章的图是无向的,下一章换成有向的 —— 而方向一来,就冒出一个新问题: 做事有先后顺序,先做哪个?

★ 关键一步是「入度为 0 的先出队」,以及一个漂亮的副产品: 队列空了却还有点没出来,就说明图里有环。 到时候会看到,它其实还是 BFS —— 只是「什么时候能入队」的条件变了。

14自测

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