第 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 暴力:两问都用「完全不是搜索」的思路做一遍
点「运行 ▶」看结果
标准答案又一次换了思路(第 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 正解:一份代码,两问都解决
点「运行 ▶」看结果
跑出 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 —— 它对「网格」二字一无所知。
点「运行 ▶」看结果
那张 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 四种把最短路写错的方式
下拉框里那四个错误版本,各对应下面一份代码。先看动画怎么错的,再看代码错在哪。
点「运行 ▶」看结果
跑出 2 1 0 1 2(1 号点应该是 1)。第 14 章说过这个错误「会退化成暴力」, 但在图上它更狠:答案本身就是错的。
道理在动画的计数器里:一个点可能被好几个邻居同时看见,于是被塞进队列好几次, 每塞一次都会重写一遍它的距离 —— 而后写的那次不一定更近。 默认这张图上,正确写法入队 5 次(正好等于走得到的点数), 出队才标记入队 8 次,多出来的 3 次全是在改写别人的答案。
★ 「入队时标记」保证的是:一个点只被记一次距离,而且是第一次 —— 也就是最短的那次。
点「运行 ▶」看结果
跑出 2 1 0 4 3 —— 4 号点明明是 3 号的直接邻居,却被记成了 4。
DFS 是一条路走到黑:它到达一个点时走的那条路,通常不是最短的那条,
而 vis 一打上,就再也不会有人用更短的路来看它一眼了。
DFS 的深度是「我沿着这条路走了多久」,BFS 的距离才是「最少要走多久」。
两者只在树上必然相等(树上任意两点之间只有一条路,没得选)。 一旦图里有环,「路不止一条」,它们立刻分家 —— 而树之所以是树,正是因为它没有环。 第 27 章那些树形 DP 之所以能安心用 DFS,靠的就是这一点。
点「运行 ▶」看结果
默认这张图上,8 个点里有 3 个的「DFS 深度」和「BFS 距离」不一样。 这张表最后两行还把上一个 bug 的入队次数(5 vs 8)数了出来 —— 「顺序错了」和「标记打晚了」从此都是数字,不是一句话。
这两个错误在第 12 步会变成这一章最贵的一课 —— 它们的死活完全取决于数据长什么样。
10 第五种错法:孤立点不算一块
点「运行 ▶」看结果
跑出 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 地图太小了,碰不上。
点「运行 ▶」看结果
两种改法,这份代码里各用了一次:
- 数连通块干脆改用 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
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 填涂颜色 —— 进阶:从外圈往里灌水(补集思维)。它会逼你想清楚「哪些点该当起点」