← 目录 第 24 / 60 节 全部 60 节

24 · DFS 与回溯:网格搜索与剪枝

把 DFS 搬到网格上:方向数组走四个方向,标记决定撤不撤销——连通块不撤销、路径计数必须撤销;再用三个 bool 数组把八皇后的冲突检测做成 O(1),并分清可行性、最优性、顺序三类剪枝。
⭐ 已学范围普及/提高-预计 65 分钟网格 DFS剪枝八皇后
本节出现的新函数 / 新语法(先看懂再用)

上一节在“1..n 的数字”上做决策,候选集是下标。这一节把决策搬到网格上:每层的候选集变成上下左右四格。网格搜索会逼出一个上一节没遇到过的问题——同一个格子允不允许被两条路径共用。这一个问题的答案决定了标记要不要撤销,而它是一行代码的差别、两种完全不同的算法。

网格 DFS 的错几乎都出在“标记”上:标不标、什么时候标、撤不撤销。三个问题答错一个,40 行里就有 1 行是错的。
新函数拆解 · 方向数组 dx[] / dy[]
int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1};
组成含义 / 格式要求
(dx[k], dy[k])第 k 个方向的行增量与列增量。四个方向捆成一组,用一层 for 代替四段重复的 if。
为什么这么配上 (−1, 0)、下 (1, 0)、左 (0, −1)、右 (0, 1)。行号 i 变大是往下走,列号 j 变大是往右走。
八方向把对角也加进来共 8 组:dx[8]={-1,-1,-1,0,0,1,1,1},dy[8]={-1,0,1,-1,1,-1,0,1}。dx 和 dy 必须一一对应,漏配一组不会报错,只会少算格子。
nx、ny 必须是新变量int nx = x + dx[k] 每次都现算。直接改 x 本身,下一次循环的起点就错了,症状是走出一条斜线或者原地打转。
边界先判if (nx < 1 || nx > n || ny < 1 || ny > m) continue; 四个条件缺一不可。必须先判越界再访问 g[nx][ny],顺序反了就是下标 −1,直接 RE。
下标从几开始从 1 开始,边界就是 1 和 n;从 0 开始,边界就是 0 和 n−1。两种风格不能混,混了必然越界。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 3, m = 4;                          // 3 行 4 列
int dx[4] = {-1, 1, 0, 0};                 // 上 下 左 右
int dy[4] = {0, 0, -1, 1};

int main() {
    int x = 1, y = 1;                      // 站在左上角
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 越界的两个方向被跳过
        printf("(%d,%d) ", nx, ny);        // 输出 (2,1) (1,2)
    }
    return 0;
}
注意

方向数组的顺序只在“按字典序输出路径”时才要紧;只计数的话随便排。

先把四个方向抄在草稿纸上再写代码,比写完对着 WA 猜方向快得多。

新函数拆解 · 对角线编号 i + j 与 i − j + n
d1[i + j] // 左下—右上这一族 d2[i - j + n] // 左上—右下这一族
组成含义 / 格式要求
i + j同一条“左下—右上”斜线上,所有格子的 i + j 相同,取值范围 2..2n。
i − j同一条“左上—右下”斜线上,所有格子的 i − j 相同,取值范围 −(n−1)..(n−1)。是负数,不能直接当下标。
+ n把 i − j 整体右移 n,落到 1..2n−1,全部合法。数组开 2n+1 格就够(n ≤ 13 时 27 格)。
为什么是 O(1)两个 bool 数组记住“这条斜线上有没有棋子”。判冲突从“扫一遍棋盘”变成两次内存访问,整整省掉一个 n 的因子。
标记与撤销必须用同一个下标一处写 d2[i-j]、另一处写 d2[i-j+n],标记和还原对不上,棋子会凭空消失或凭空冲突,症状是答案偏大且不稳定。
行不用管dfs 每层固定一行、一行只放一个,行冲突天生不存在。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 4;

int main() {
    for (int i = 1; i <= n; ++i) {          // 主对角线编号 i - j + n
        for (int j = 1; j <= n; ++j) printf("%2d", i - j + n);
        printf("\n");
    }
    printf("\n");
    for (int i = 1; i <= n; ++i) {          // 副对角线编号 i + j
        for (int j = 1; j <= n; ++j) printf("%2d", i + j);
        printf("\n");
    }
    return 0;
}
/* 第一张:4 3 2 1 / 5 4 3 2 / 6 5 4 3 / 7 6 5 4
   第二张:2 3 4 5 / 3 4 5 6 / 4 5 6 7 / 5 6 7 8
   每一条斜线上的数字都相同 —— 这就是“一条斜线一个下标” */
注意

两族斜线用两个不同的数组,编号区间重叠不会冲突。

n 变大时数组要跟着变大:n = 13 时 i + j 最大 26、i − j + n 最大 25,开 30 格稳。

网格上的 DFS 只有两种命运,先分清它们,再动手写。下面这张表是全节最该记住的东西。

对比项连通块:问“有几块区域”路径计数:问“有几条路”
格子的身份只属于它所在的那一块,是唯一的在一条路径上最多出现一次,但可以属于很多条路
标记之后撤销吗不撤销:标上就是“已归档”必须撤销:下一条路还要踩这一格
漏写撤销的后果本来就不该写,写了反而错方案数偏小,常常只剩 1
多写撤销的后果整块被反复重算,方案数虚高、指数爆炸 → TLE本来就要写,没有副作用
典型题数水洼、最大连通块迷宫路径数、八皇后

差别只有一行,但判断依据很清楚:问“块”就把标记留在原地,问“路”就必须把标记还回去。因为块一旦确定就不再改变,路则是一条一条试出来的。

连通块版:只标记,绝不还原(4 连通;改成 8 连通只需换方向数组)
#include <bits/stdc++.h>
using namespace std;

int n, m, ans;
int g[105][105];                          // 0 空格,1 未访问的格子,2 已归入某块
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

void dfs(int x, int y) {
    g[x][y] = 2;                          // 标记“已访问”:直接改原图,省一个 vis 数组
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
        if (g[nx][ny] != 1) continue;     // 不是未访问的格子就不走
        dfs(nx, ny);
    }
    /* 这里没有 g[x][y] = 1 —— 连通块不撤销 */
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) cin >> g[i][j];

    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (g[i][j] == 1) { ++ans; dfs(i, j); }   // 每从一个没访问过的格子出发,就多一块
    cout << ans << "\n";
    return 0;
}
例题 · 迷宫洛谷 P1605普及-去洛谷做这题 ↗

有一个 N×M 的方格迷宫,其中有 T 处障碍不能通过。给定起点和终点坐标,求从起点走到终点、每个方格最多经过一次的移动方案数。每步只能上下左右走一格。

输入格式:第一行三个整数 N、M、T;第二行四个整数 SX、SY、FX、FY,为起点与终点坐标;接下来 T 行,每行两个整数,为一个障碍的坐标。保证起点上没有障碍。

输出格式:一行一个整数,表示方案数。

数据范围:1 ≤ N, M ≤ 5,0 ≤ T ≤ 10。

输入: 2 2 1 1 1 2 2 1 2 输出: 1
一条方案 = 一条不重复的路径,走到终点就 +1

模型。题目问“有几条路”,一条路就是一串不重复的格子。所以答案 = 所有“从起点到终点、且中途不重复经过任何格子”的路径条数。这个量没法算,只能搜。

状态。当前站在 (x, y),加上“哪些格子已经踩过”。后者直接写进 g 数组:踩过的格子临时改成 1(不可走),离开时改回 0。这样“已经踩过”这件事就变成了“这一格现在不能走”,判定和走一步合并成同一件事,省掉一个 vis 数组。

出口。走到 (fx, fy) 就 ++ans 然后 return。必须 return:如果到了终点还继续走,绕一圈再回到终点就会把同一条前缀数成多个方案。

回溯三行。本题是路径计数,所以第三行还原不能省:g[nx][ny] = 1; dfs(nx, ny); g[nx][ny] = 0;。这正是上一节那三行,只是占位的对象从 vis[i] 换成了网格里的一个格子。

两个容易漏的细节。① 起点也要先标记成 1,否则路径可能“绕回起点”被重复计算;② 障碍在读入时就设成 1,于是“是障碍”和“已经踩过”用同一个判断就能一起处理。

复杂度为什么不能用 4^(NM) 估。4²⁵ ≈ 1.1×10¹⁵,照这个上界早就该放弃了。但“每格最多一次”把分支死死压住:越走可选方向越少,多数分支三五步就撞墙。搜索题的复杂度要按实际能走通的路径数估,而不是按每层分支数硬乘。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n, m, t, sx, sy, fx, fy, ans;
int g[10][10];              // 1 = 障碍或本路径已走过(两者都等于“不能走”)
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

void dfs(int x, int y) {
    if (x == fx && y == fy) {           // 到终点:这条路径成立,计数后立刻返回
        ++ans;
        return;                         // 不返回就会从这里继续走,把一条路径数成多条
    }
    for (int k = 0; k < 4; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 先判越界
        if (g[nx][ny]) continue;                             // 障碍 / 本路径已走过
        g[nx][ny] = 1;                  // ① 占位:这一格在本路径上用掉了
        dfs(nx, ny);                    // ② 递进
        g[nx][ny] = 0;                  // ③ 还原:别的路径还要用这一格
    }
}

int main() {
    cin >> n >> m >> t >> sx >> sy >> fx >> fy;
    for (int i = 1; i <= t; ++i) {
        int x, y; cin >> x >> y;
        g[x][y] = 1;                    // 障碍也记成 1,省掉单独的判断
    }
    g[sx][sy] = 1;                      // 起点算“已走过”,否则路径可以绕回起点
    dfs(sx, sy);
    cout << ans << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
int g[10][10];N, M ≤ 5,开 10 格有足够余量。注意这里 g 承担了两个含义:障碍(永久为 1)与已走过(临时为 1)。两者都表示“现在不能走”,所以能用同一个判断合并。
if (x == fx && y == fy) { ++ans; return; }出口放在函数最前面,进函数就判。放在循环后面也能过,但会多走一些无效分支。
return;到了终点必须马上返回。少了这一句,程序会从终点继续往外走,把同一条路径的前缀反复计数 → 答案偏大。
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;越界判断必须在取 g[nx][ny] 之前。C++ 不检查下标,写反了读到的是一块随机内存,有时不崩、只是答案莫名其妙。
if (g[nx][ny]) continue;一个判断同时挡掉障碍和已走过的格子。若把障碍单独存一个数组,这里就要写两个判断,还容易漏一个。
g[nx][ny] = 1; dfs(nx, ny); g[nx][ny] = 0;回溯三行。第三行是本节的核心区别:路径计数必须还原,连通块必须不还原。
g[sx][sy] = 1;起点也算踩过。不写这一句时,某些数据会出现“走到邻居再走回起点”的路径,答案偏大。
ans 用 int5×5 网格上路径数最多几万条,int 远远够。但换到 30×30 就要先估上界再决定,别默认 int。

搜索慢,唯一的原因是分支太多。剪枝就是在“往下走之前”判一句,把整棵子树砍掉——价码是 O(1),收益可能是指数级。三类剪枝必须能一眼分清。

类型砍的条件典型写法砍掉了什么
可行性剪枝当前状态已经不可能走到任何合法解if (越界 || 已走过 || 是障碍) continue;整棵子树
最优性剪枝求最优值时,当前代价已不可能优于已知最优if (step >= best) return;剩下的所有分支
顺序剪枝先试更可能出解的分支,让 best 尽早变小把候选排序,从收益大的开始试间接砍掉大量分支

三类的共同点:判断都在进入下一层之前,代价 O(1),省下的是一整棵子树。所以剪枝永远比在叶子上优化常数划算——叶子数量本来就是指数的。

八皇后是“可行性剪枝做到极致”的例子:每行放一个棋子,行冲突自动消失;剩下的列与两条斜线,各用一个 bool 数组记着有没有被占,判一次冲突只要三次内存访问。8 皇后裸枚举是 8⁸ = 16777216 种摆法,每种还要 O(n²) 检查;加上行列剪枝,实际只访问 2057 个节点。

例题 · 八皇后 Checker Challenge洛谷 P1219普及/提高-去洛谷做这题 ↗

在 n×n 的棋盘上放 n 个棋子,要求每行、每列、每条对角线上至多有一个棋子(两条主对角线的所有平行线都算)。

输入格式:一行一个整数 n(6 ≤ n ≤ 13)。

输出格式:前三行输出前三个解,每个解给出第 1 行到第 n 行棋子所在的列号,数字之间用一个空格隔开;第四行输出解的总数。

输入: 6 输出: 2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 4
按行放子 + O(1) 冲突检测

抓住“每行一个”。n 个棋子放 n 行,每行恰好一个(否则某行空着就必然有另一行挤两个)。于是 dfs 每层负责一行,层数就是行号,行冲突自然不存在——这就是“选对决策顺序”省掉一个维度的例子。

只剩三种冲突。同列、同一条左上—右下斜线、同一条左下—右上斜线。前者的判据是 j 相同,后两者的判据是 i − j 相同、i + j 相同(见前面的「对角线编号」函数卡)。三个 bool 数组一存,判断就是 O(1)。

回溯三行,只是标记三处。col[j] = d1[i+j] = d2[i-j+n] = 1; 链式赋值一次标三个,递归,再链式赋 0 撤三个。必须用同一个下标表达式标和撤,写错一处就前功尽弃。

出口两个动作。row 走到 n+1 时说明 n 行都放好了:先 ++ans,再判断 ans <= 3 决定要不要输出。注意先加再判,否则前三个解整体错位一个。

为什么这就是剪枝。一行 if (col[j] || d1[...] || d2[...]) continue; 就把“已经打起来”的整棵子树挡在门外。n = 13 时解有 73712 个,节点数在 10⁷ 量级,1 秒能跑完。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n, ans;
int pos[15];                    // pos[i]:第 i 行的棋子放在第几列
bool col[30];                   // 列被占
bool d1[40];                    // i + j 这一族斜线被占
bool d2[40];                    // i - j + n 这一族斜线被占

void dfs(int row) {             // 正在决定第 row 行
    if (row > n) {              // n 行都放好了,得到一个解
        ++ans;                  // 先计数
        if (ans <= 3) {         // 只要前三个解
            for (int i = 1; i <= n; ++i)
                printf("%d%c", pos[i], i == n ? '\n' : ' ');
        }
        return;
    }
    for (int j = 1; j <= n; ++j) {
        // 可行性剪枝:列、两条斜线任意一处冲突,整棵子树都不必进
        if (col[j] || d1[row + j] || d2[row - j + n]) continue;
        pos[row] = j;
        col[j] = d1[row + j] = d2[row - j + n] = 1;     // ① 占三个位置
        dfs(row + 1);                                   // ② 递进
        col[j] = d1[row + j] = d2[row - j + n] = 0;     // ③ 撤这三个
    }
}

int main() {
    cin >> n;
    dfs(1);                    // 从第 1 行开始放
    cout << ans << "\n";       // 第四行:方案总数
    return 0;
}
逐行拆解
代码片段含义与理由
int pos[15];n ≤ 13,开 15 格。pos 存答案,和上一节的 a[step] 一样不需要还原:每一行都会被下一次尝试覆盖。
bool col[30]; bool d1[40]; bool d2[40];col 用 j(1..13);d1 用 row+j(2..26);d2 用 row−j+n(1..25)。开小了就是越界写,症状是答案随机、n 小的时候又碰巧全对。
if (row > n)出口在第 n+1 层,表示第 n 行已经放完。写成 row == n 会漏掉最后一行 → 答案恒为 0。
++ans; if (ans <= 3)先加后判。顺序反了会把“第 zero 个解”当成第一个输出,前三个解整体偏移一个。
if (col[j] || d1[row + j] || d2[row - j + n]) continue;一行写完三重冲突判断。三个条件用 || 短路求值,前一个为真就不再算后面,所以最常冲突的 col 放最前面。
col[j] = d1[row + j] = d2[row - j + n] = 1;赋值表达式从右往左算、返回值是被赋的那个值,所以一句能标三处。撤的时候下标必须一模一样,这是最容易手误的地方。
printf("%d%c", pos[i], i == n ? '\n' : ' ');行内用空格、行末用换行一步到位,不会出现行末多余空格。
这一节的 6 个坑,每个都有具体症状

迷宫忘记 g[nx][ny] = 0:第一条路把格子全占了,后面的路走不通,答案常常是 1 → WA。

迷宫没标记起点:某些数据下会出现“走到邻居再走回起点”的路径,答案偏大 → WA,而且很难复现。

连通块里多写了还原:整块被反复重算,答案虚高且规模指数膨胀 → TLE 或 WA。

先取 g[nx][ny] 再判越界:下标 −1 或 m+1,读到垃圾甚至写到别处 → RE(本机常常不崩,交上去段错误)。

八皇后对角线数组开小(比如 bool d1[15]):n = 13 时 row+j 到 26,越界写 → RE 或答案随机。

最优性剪枝写成 if (step > best) return; 或反成 <:前者少砍一些只是变慢,后者会把正确答案也砍掉 → WA。剪枝的边界符号写完要手算一个小例子验一遍。

自己动手复现

关掉页面,把迷宫那 30 行从零敲一遍,交到 P1605,确认样例输出是 1。

改一改①:把 g[nx][ny] = 0; 注释掉再跑同一组样例,先预测答案再执行——预测是 1,这说明“撤销”是路径计数的命根子。

改一改②:把 4 个方向换成 8 个方向(含对角),答案会变大还是变小?为什么变大的幅度远超直觉?

改一改③:把八皇后那三行还原整段注释掉,n = 6 时 ans 会变成 1 还是 4?跑一遍验证你的推理,顺便观察程序有没有崩。

数学补给 · 斜线编号与搜索树规模

斜线为什么能用一个整数编号。在方格上沿 (1,1) 方向走一步,i 和 j 同时加 1,所以 i − j 不变;沿 (1,−1) 方向走一步,i 加 1、j 减 1,所以 i + j 不变。反过来,i − j = c 是一族斜率为 +1 的直线,i + j = c 是一族斜率为 −1 的直线,每条直线在 n×n 棋盘上切出的格子正好就是一条“斜线”。于是“判两条斜线是否相同”= “判两个整数是否相等”。

取值范围:i − j ∈ [1−n, n−1],加 n 后 ∈ [1, 2n−1];i + j ∈ [2, 2n]。两族用两个数组,编号区间重叠也没有关系。这就是 n ≤ 13 时 bool d1[40] 足够的原因。

搜索树到底多大。8 皇后按行放、带列与斜线剪枝时,每一层还能站住的节点数如下:

层(已放子数)012345678
节点数184214034456855031292

合计 1 + 8 + 42 + 140 + 344 + 568 + 550 + 312 + 92 = 2057 个节点;而裸枚举是 8⁸ = 16777216 种摆法。剪枝把节点数压掉约 8000 倍,靠的不是“更快地排除”,而是整棵子树根本不进去。

更一般地:若每层平均能站住的分支数是 b,深度 n,节点数约 bⁿ。8 皇后这一列的平均 b 约 2.5,2.5⁸ ≈ 1526,与 2057 同一量级。剪枝的本质就是把 b 变小,b 在指数上,所以一点点改进都很值钱。

n678910111213
解的个数4409235272426801420073712

P1219 的 n 从 6 到 13,解的个数最多 73712,int 装得下;最后一次提交若把 ans <= 3 写漏,n = 13 时会输出七万多行,直接格式错。

举一反三 · 想清楚这些再往下走
  1. 迷宫若要求“输出字典序最小的那条路径”,方向数组的顺序该怎么排?为什么顺序会影响答案,而只计数时顺序完全无所谓?
  2. 连通块题若改成“最大的一块有多少格”,代码要加哪两个变量?为什么这时候“不撤销”反而让复杂度更低?
  3. 八皇后只要求输出一个解时(n = 13),还能加什么顺序剪枝或对称性剪枝?提示:棋盘左右对称,第一个解的第一行棋子可以限定在左半边。
  4. 如果迷宫要求“下一步必须转 90°,不能连续直走”,dfs 需要多带什么参数?状态数变成几维?
  5. 最优性剪枝里的 best 初值设成 0 还是设成一个足够大的数?两种分别在求最小值和求最大值时出什么错?
  6. 网格 DFS 深度等于格子数:10⁴ 还能扛,10⁶ 呢?为什么这种时候必须换成第 26 节的 BFS?(提示:dfs 的栈帧要一直留着,而 BFS 的队列里只放“当前层”。)
自测:能全部回答再进下一节
挑战题 · Lake Counting S
洛谷 P1596普及-去洛谷做这题 ↗

一块 N×M 的田地,每个格子要么是积水 'W',要么是干地 '.'。八个方向相连的一群 'W' 算一个水坑(斜着相连也算)。求一共有多少个水坑。

输入格式:第一行两个整数 N、M(1 ≤ N, M ≤ 100);接下来 N 行,每行 M 个字符,只含 'W' 和 '.'。

输出格式:一行一个整数,表示水坑个数。

引导性提示(先只看这里)

从每一个还没被数过的 'W' 出发,把和它连通的 'W' 全部标记掉;每标完一片,答案就加 1。

连通包括 8 个方向,方向数组要写 8 组,而且 dx 与 dy 必须一一对应,错一组就少算一片。

已经数过的格子不需要还原——它属于当前这片水坑,之后不该再作为起点被数第二次。

想看思路了?点开完整思路

模型。把每个 'W' 看成一个点,八方向相邻的两点连一条边,题目问的就是这个图里有几个连通块。

做法。扫整个棋盘,遇到一个没访问过的 'W',就把它当作新水坑的入口:答案 +1,然后 dfs 把和它八方向连通的 'W' 全部改成 '.'(标记 = 直接改图,省掉 vis 数组)。

为什么不撤销。一个格子只属于一个水坑。dfs 把它改成 '.' 之后,外层扫描不会再把这片水坑的格子当成新入口,所以不会重复计数;如果撤销,同一片水坑会被反复数,答案虚高而且指数级变慢。

复杂度。每个格子最多被访问一次,每个格子查 8 个方向,总时间 O(8NM) = 8×10⁴,1 秒绰绰有余。递归深度最坏 10⁴(整块田都是水),栈完全够用。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int n, m, ans = 0;
char g[105][105];
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};    // 八方向:dx 与 dy 必须一一对应
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};

void dfs(int x, int y) {
    g[x][y] = '.';                          // 标记:直接改图,这一格以后不会再当入口
    for (int k = 0; k < 8; ++k) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 先判越界
        if (g[nx][ny] != 'W') continue;     // 只走水
        dfs(nx, ny);
    }
    // 这里没有 g[x][y] = 'W' —— 连通块不撤销
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) cin >> g[i][j];   // cin >> char 会跳过空白,逐字符读正确

    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (g[i][j] == 'W') { ++ans; dfs(i, j); }  // 每发现一个新入口,就多一个水坑

    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
char g[105][105];N, M ≤ 100,开 105 留余量。用 char 存,'W' 和 '.' 直接可比,不需要额外的 vis 数组。
cin >> g[i][j];cin >> char 会自动跳过空格和换行,所以逐字符读入是安全的;换成 scanf("%c", &g[i][j]) 就会把行末换行也读进来,整张图错位。
if (g[i][j] == 'W') { ++ans; dfs(i, j); }外层扫描每遇到一个还是水的格子,就说明发现了一片新水坑:先计数,再让 dfs 把整片吃掉。
g[x][y] = '.';标记和“删除”是同一个动作。放在函数开头而不是循环里,是为了让入口格子也立刻被标记。
if (g[nx][ny] != 'W') continue;一个判断同时挡掉 '.' 和已访问过的 'W'(它们都已经被改成 '.' 了)。
// 没有 g[x][y] = 'W'整段代码唯一省略的那一行,正是连通块与路径计数的分界线。
这题最容易错的地方
  • 只写 4 个方向:斜向相连的水坑被拆成好几片,答案偏大 → WA。
  • 把标记写在 for 循环之后(先遍历再标记):同一格被重复进入,递归互相调用 → RE(爆栈)。
  • 用 scanf("%c") 读图且不处理换行:整张图整体错位,答案离谱。
  • 多写了还原 g[x][y] = 'W';:一片水坑被反复重算,小数据答案偏大、大数据直接 TLE。
延伸阅读 / 参考