第 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暴力:两问都用「完全不是搜索」的思路做一遍
// 标准答案 —— 两个问题都故意用「完全不是搜索」的思路做一遍//// 输入:第一行 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;}点「运行 ▶」看结果
标准答案又一次换了思路(第 9 章那条规矩):
- 连通块用朴素并查集:它根本不「走」图,只是把每条边的两个端点合并到一起, 最后数还剩几个根。(并查集第 36 章才正式讲,这里用的是最朴素的版本。)
- 最短路用枚举所有简单路径:从
s出发一条路走到黑,每走到一个点就拿 「当前这条路的长度」去更新它的答案,然后回溯换一条路 —— 第 4 章那一套。
如果标准答案也写一遍 DFS / BFS,那就是同一个想法写了两遍 —— 只能验出打字错误,验不出想法错误。 这一章尤其危险:待会儿那五个错误版本,每一个都长得和正解几乎一模一样 (改一个字母、改一个变量名)。要是标准答案也是同一个模子刻出来的, 很可能两边一起错。
4实测:暴力慢在哪 —— ★ 旋钮不是点数,是边数
先说一件容易搞错的事。「枚举所有简单路径」听起来是「和点数有关」的指数级, 但真正让它爆炸的是平均度数:点越挤,绕法越多。
本机实测(./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 条边,暴力就慢了两百多倍。
第一版我是拿「点数」当旋钮的:./genBig 12、14、16……
结果一路到 n = 20 全都是 0.00 秒 —— 因为默认边数取的是 2n,
图稀疏得像棵树,从起点出发的简单路径压根没几条,暴力当然快。
第 25 章那句话原样适用:要证明暴力慢,先确认它真的走到底了。 只不过这一次让暴力「假装自己不慢」的不是剪枝,是数据太稀疏。 把旋钮换成边数之后,这张表才立得住 —— 而且顺带得到了一个更准的结论: 指数级的底数藏在平均度数里,不在点数里。
自己动手把那个旋钮拧一遍(genDense 就是「点数固定 30、边数当参数」的 genBig):
5★ 关键一步:把第 13 章的代码原样搬过来
先把第 13 章那份 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 | 图版还剩几个 |
|---|---|
出界了吗(x < 0 || x >= n …) |
没了 |
是墙 / 是水吗(g[x][y] != '1') |
没了 |
走过了吗(vis[x][y]) |
留着 |
为什么能少两个?因为网格里那两句 if,做的其实是同一件事: 每次重新回答「谁是我的邻居」。上下左右四个方向只是候选, 出界的、是墙的都不算数 —— 筛完剩下的才是真邻居。
而邻接表提前把这件事做完了:g[u] 里存的本来就全是合法邻居。
★ 网格是图的一个特例:格子是点,相邻的两格之间有一条边。 「上下左右四个方向」不是搜索的一部分,它只是那张图的建图方式。
除此之外一个字都不用改:DFS 还是那个 DFS,BFS 还是那个 BFS,
vis 还是入队时打,「第一次到达 = 最短到达」还是成立。
6正解:一份代码,两问都解决
// 图上的 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;}点「运行 ▶」看结果
跑出 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 同时在两幅画上走
左边是一张 5×5 的小地图(11 个陆地格),右边是它转成的图: 11 个点被均匀摆在一个圆上,边成了乱七八糟的弦 —— 看着和网格毫无关系。
但请注意:两幅画是同一帧数据画出来的。同一个 DFS、同一个访问顺序、同一批颜色。 右边那个圈之所以看着不像网格,只是因为我把点画到别处去了 —— 图不在乎点画在哪里,只在乎谁和谁有边。
这张地图有 4 个连通块,其中 3 个是孤立点(被水围住的单格陆地)。 把下拉框切到「✗ 只从有边的点发起」,4 块当场塌成 1 块。
8★ 跨章节交叉验证:把第 13 章那张地图真的转成图
光看动画还不够。这一步要动真格的:把第 13 章那张 8×8 地图转成一张图的输入文件,
再交给第 6 步那份 fast.cpp —— 它对「网格」二字一无所知。
// ★ 把第 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;}点「运行 ▶」看结果
那张 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四种把最短路写错的方式
下拉框里那四个错误版本,各对应下面一份代码。先看动画怎么错的,再看代码错在哪。
// ✗ 错误版本一: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;}点「运行 ▶」看结果
跑出 2 1 0 1 2(1 号点应该是 1)。第 14 章说过这个错误「会退化成暴力」, 但在图上它更狠:答案本身就是错的。
道理在动画的计数器里:一个点可能被好几个邻居同时看见,于是被塞进队列好几次, 每塞一次都会重写一遍它的距离 —— 而后写的那次不一定更近。 默认这张图上,正确写法入队 5 次(正好等于走得到的点数), 出队才标记入队 8 次,多出来的 3 次全是在改写别人的答案。
★ 「入队时标记」保证的是:一个点只被记一次距离,而且是第一次 —— 也就是最短的那次。
// ✗ 错误版本二:用 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;}点「运行 ▶」看结果
跑出 2 1 0 4 3 —— 4 号点明明是 3 号的直接邻居,却被记成了 4。
DFS 是一条路走到黑:它到达一个点时走的那条路,通常不是最短的那条,
而 vis 一打上,就再也不会有人用更短的路来看它一眼了。
DFS 的深度是「我沿着这条路走了多久」,BFS 的距离才是「最少要走多久」。
两者只在树上必然相等(树上任意两点之间只有一条路,没得选)。 一旦图里有环,「路不止一条」,它们立刻分家 —— 而树之所以是树,正是因为它没有环。 第 27 章那些树形 DP 之所以能安心用 DFS,靠的就是这一点。
// 把 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;}点「运行 ▶」看结果
默认这张图上,8 个点里有 3 个的「DFS 深度」和「BFS 距离」不一样。 这张表最后两行还把上一个 bug 的入队次数(5 vs 8)数了出来 —— 「顺序错了」和「标记打晚了」从此都是数字,不是一句话。
这两个错误在第 12 步会变成这一章最贵的一课 —— 它们的死活完全取决于数据长什么样。
10第五种错法:孤立点不算一块
// ✗ 错误版本三:数连通块时只从「有边的点」发起 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;}点「运行 ▶」看结果
跑出 2 块(正确是 3)。写法本身看着很讲道理: 「一个点连边都没有,还搜它干嘛?」—— 可它自己就是一个连通块。
回到第 7 步那个动画,把下拉框切到这一档:网格里那三个被水围住的单格陆地全被跳过了, 4 块塌成 1 块。在网格里它叫「一格孤岛」,在图里它叫「孤立点」,是同一样东西。
11⚠ 一个网格题里碰不到的坑:递归 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 地图太小了,碰不上。
// 和 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;}点「运行 ▶」看结果
两种改法,这份代码里各用了一次:
- 数连通块干脆改用 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
// 图上的 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 轮 |
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 |
四次的根因是同一句话:
生成器里那个不假思索的「顺手」写法,悄悄给数据加了一条题目里没有的性质。
而且四次的改动没有一次是在调数值(不是值域、不是规模), 全都在结构 / 角色分配上。所以「数据要随机」这句话得拆成两半: 数值要随机,结构和角色也要随机。
看上面那张表「深度当距离」和「出队才标记」那两列:另外两个 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自测
- 洛谷 P5318 【深基18.例3】查找文献解析 → —— 本章的模板题:一张图上分别跑 DFS 和 BFS。要求邻居按编号从小到大 —— 正好逼你想清楚建图之后要不要排序
- 洛谷 P3916 图的遍历解析 → —— ★ 反向建图 + 从大到小跑 DFS。做完你会真正明白「有向图存一遍 vs 存两遍」
- 洛谷 B3625 迷宫寻路解析 → —— 网格连通性 —— 故意留一道网格题,用本章的图版思路再做一遍,对照第 13 章
- 洛谷 P1443 马的遍历解析 → —— ★ BFS 求最短步数,但「邻居」是马走日的 8 个方向 —— 这一章那句话的最好注脚:换的只是邻居
- 洛谷 P1141 01迷宫解析 → —— ★ 连通块 + 记住每块的大小。多次询问,一次染色全部答完 —— 「连通块编号」这个技巧非常常用
- 洛谷 P1162 填涂颜色解析 → —— 进阶:从外圈往里灌水(补集思维)。它会逼你想清楚「哪些点该当起点」