阶段 6 · 图论 · 第 30 章

图上的 DFS 与 BFS、连通性

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

例题:连通块个数 + 单源最短边数 建议用时:100 分钟
上一章章末那句话,这一章要当场兑现

第 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标准答案:并查集 + 枚举所有简单路径
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

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

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

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

命令边数 m平均度数暴力(枚举所有路径)DFS + BFS
./genBig 30 60604.00.20 秒0.00 秒
./genBig 30 65654.31.10 秒0.00 秒
./genBig 30 70704.79.81 秒0.00 秒
./genBig 30 75755.042.84 秒0.00 秒

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

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

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

第一版我是拿「点数」当旋钮的:./genBig 121416…… 结果一路到 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 求最短路
输入(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 看对应关系)
输入(stdin)
输出
点「运行 ▶」看结果

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

./gridToGraph < map.txt | ./fast
★ 两份互不相识的代码,答案一个字不差
第 13 / 14 章(网格版)本章(图版)
连通块33
(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 出队时才标记(头号错误)
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

wrongDfsDist.cpp✗ 用 DFS 求最短路(拿递归深度当距离)
输入(stdin)
输出
点「运行 ▶」看结果

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

★ DFS 的深度不是距离

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

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

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

order.cpp把两者逐点并排打出来
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

wrongIsolated.cpp✗ 只从「有边的点」发起 DFS
输入(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同样的答案,一行递归都没有
输入(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 全躲在那两件事后面。

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

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

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

档位改了什么从 1 号出发深度当距离出队才标记走不到输出 0孤立点
0(最初)保证连通 + 起点固定 1 号022118600
1不再保证连通0182149141102
2(在用)起点也随机244184151141102

最初那一版一次性掩盖了三个 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 32 1 0 1 2

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

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

下一章预告

第 31 章:拓扑排序。

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

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

14 自测

自测清单0 / 10
配套练习
  • 洛谷 P5318 【深基18.例3】查找文献 —— 本章的模板题:一张图上分别跑 DFS 和 BFS。要求邻居按编号从小到大 —— 正好逼你想清楚建图之后要不要排序
  • 洛谷 P3916 图的遍历 —— ★ 反向建图 + 从大到小跑 DFS。做完你会真正明白「有向图存一遍 vs 存两遍」
  • 洛谷 B3625 迷宫寻路 —— 网格连通性 —— 故意留一道网格题,用本章的图版思路再做一遍,对照第 13 章
  • 洛谷 P1443 马的遍历 —— ★ BFS 求最短步数,但「邻居」是马走日的 8 个方向 —— 这一章那句话的最好注脚:换的只是邻居
  • 洛谷 P1141 01迷宫 —— ★ 连通块 + 记住每块的大小。多次询问,一次染色全部答完 —— 「连通块编号」这个技巧非常常用
  • 洛谷 P1162 填涂颜色 —— 进阶:从外圈往里灌水(补集思维)。它会逼你想清楚「哪些点该当起点」
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)