上一节在“1..n 的数字”上做决策,候选集是下标。这一节把决策搬到网格上:每层的候选集变成上下左右四格。网格搜索会逼出一个上一节没遇到过的问题——同一个格子允不允许被两条路径共用。这一个问题的答案决定了标记要不要撤销,而它是一行代码的差别、两种完全不同的算法。
| 组成 | 含义 / 格式要求 |
|---|---|
(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 相同,取值范围 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 | 本来就要写,没有副作用 |
| 典型题 | 数水洼、最大连通块 | 迷宫路径数、八皇后 |
差别只有一行,但判断依据很清楚:问“块”就把标记留在原地,问“路”就必须把标记还回去。因为块一旦确定就不再改变,路则是一条一条试出来的。
#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;
}有一个 N×M 的方格迷宫,其中有 T 处障碍不能通过。给定起点和终点坐标,求从起点走到终点、每个方格最多经过一次的移动方案数。每步只能上下左右走一格。
输入格式:第一行三个整数 N、M、T;第二行四个整数 SX、SY、FX、FY,为起点与终点坐标;接下来 T 行,每行两个整数,为一个障碍的坐标。保证起点上没有障碍。
输出格式:一行一个整数,表示方案数。
数据范围:1 ≤ N, M ≤ 5,0 ≤ T ≤ 10。
模型。题目问“有几条路”,一条路就是一串不重复的格子。所以答案 = 所有“从起点到终点、且中途不重复经过任何格子”的路径条数。这个量没法算,只能搜。
状态。当前站在 (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¹⁵,照这个上界早就该放弃了。但“每格最多一次”把分支死死压住:越走可选方向越少,多数分支三五步就撞墙。搜索题的复杂度要按实际能走通的路径数估,而不是按每层分支数硬乘。
#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 用 int | 5×5 网格上路径数最多几万条,int 远远够。但换到 30×30 就要先估上界再决定,别默认 int。 |
搜索慢,唯一的原因是分支太多。剪枝就是在“往下走之前”判一句,把整棵子树砍掉——价码是 O(1),收益可能是指数级。三类剪枝必须能一眼分清。
| 类型 | 砍的条件 | 典型写法 | 砍掉了什么 |
|---|---|---|---|
| 可行性剪枝 | 当前状态已经不可能走到任何合法解 | if (越界 || 已走过 || 是障碍) continue; | 整棵子树 |
| 最优性剪枝 | 求最优值时,当前代价已不可能优于已知最优 | if (step >= best) return; | 剩下的所有分支 |
| 顺序剪枝 | 先试更可能出解的分支,让 best 尽早变小 | 把候选排序,从收益大的开始试 | 间接砍掉大量分支 |
三类的共同点:判断都在进入下一层之前,代价 O(1),省下的是一整棵子树。所以剪枝永远比在叶子上优化常数划算——叶子数量本来就是指数的。
八皇后是“可行性剪枝做到极致”的例子:每行放一个棋子,行冲突自动消失;剩下的列与两条斜线,各用一个 bool 数组记着有没有被占,判一次冲突只要三次内存访问。8 皇后裸枚举是 8⁸ = 16777216 种摆法,每种还要 O(n²) 检查;加上行列剪枝,实际只访问 2057 个节点。
在 n×n 的棋盘上放 n 个棋子,要求每行、每列、每条对角线上至多有一个棋子(两条主对角线的所有平行线都算)。
输入格式:一行一个整数 n(6 ≤ n ≤ 13)。
输出格式:前三行输出前三个解,每个解给出第 1 行到第 n 行棋子所在的列号,数字之间用一个空格隔开;第四行输出解的总数。
抓住“每行一个”。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 秒能跑完。
#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' : ' '); | 行内用空格、行末用换行一步到位,不会出现行末多余空格。 |
迷宫忘记 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 皇后按行放、带列与斜线剪枝时,每一层还能站住的节点数如下:
| 层(已放子数) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 节点数 | 1 | 8 | 42 | 140 | 344 | 568 | 550 | 312 | 92 |
合计 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 在指数上,所以一点点改进都很值钱。
| n | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
|---|---|---|---|---|---|---|---|---|
| 解的个数 | 4 | 40 | 92 | 352 | 724 | 2680 | 14200 | 73712 |
P1219 的 n 从 6 到 13,解的个数最多 73712,int 装得下;最后一次提交若把 ans <= 3 写漏,n = 13 时会输出七万多行,直接格式错。
i + j 与 i − j + n 是两条斜线的编号,并算出 n = 13 时数组该开多大。一块 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' | 整段代码唯一省略的那一行,正是连通块与路径计数的分界线。 |
scanf("%c") 读图且不处理换行:整张图整体错位,答案离谱。g[x][y] = 'W';:一片水坑被反复重算,小数据答案偏大、大数据直接 TLE。