方向数组 + 网格 DFS 框架 + 回溯撤销标记 + 八皇后的 O(1) 冲突判断
| 0 - 30 min | 方向数组:把「四种走法」变成「一次循环」(第 1 节) |
| 30 - 70 min | 网格 DFS 通用框架 + 回溯撤销(第 2、3 节) |
| 70 - 110 min | 八皇后的列 / 两条对角线冲突标记(第 4 节) |
| 110 - 230 min | 做 4 道题,每题限时 30 分钟 |
| 230 - 260 min | 对照本文题解,把没做出来的补掉 |
| 260 - 300 min | 写题解笔记 + 做自测题 + 存速查卡 |
Day 18 你写的 DFS 都是「在一维的候选集合里挑数字」:P1157 组合、P1706 全排列、P2404 自然数拆分。 递归函数的参数是一个「选到第几个了」的下标。
今天要跨到二维:递归函数的参数变成 (x, y) 坐标,我们要在网格里上下左右地走。
昨天那套「递归 + 撤销」的思路一个字都不用改,只是多了一个「往哪个方向走」的工具——方向数组。
方向数组是后面所有搜索题(DFS、BFS、最短路、迷宫)的公共基础设施,今天必须练到闭着眼睛能写出来。
假设我们在一个格子上,要试着往上下左右四个方向各走一步,检查能不能走。最直白的写法是:
// 分别处理四个方向,四段几乎一样的代码 if(能走(x + 1, y)) dfs(x + 1, y); // 下 if(能走(x - 1, y)) dfs(x - 1, y); // 上 if(能走(x, y + 1)) dfs(x, y + 1); // 右 if(能走(x, y - 1)) dfs(x, y - 1); // 左
能跑,但有两个毛病:一是重复,四段逻辑一模一样;二是不好扩展——如果题目改成八方向(马、水塘、单词方阵), 你就要再手写四段斜着走的代码,很容易写错一个正负号。
把「往第 d 个方向走一步,行要加多少、列要加多少」提前存进两个数组里。
走的时候只要算 nx = x + dx[d]、ny = y + dy[d],然后一个 for 循环转一圈就把所有方向试完了。
int dx[4] = {0, 0, 1, -1}; // 行的变化量
int dy[4] = {1, -1, 0, 0}; // 列的变化量
for(int d = 0; d < 4; d++){
int nx = x + dx[d];
int ny = y + dy[d];
// 现在 (nx, ny) 就是第 d 个方向的邻居
}
| d | dx[d] | dy[d] | 走到的邻居 | 怎么记 |
|---|---|---|---|---|
| 0 | 0 | +1 | (x, y+1) | 列变大 → 往右 |
| 1 | 0 | −1 | (x, y−1) | 列变小 → 往左 |
| 2 | +1 | 0 | (x+1, y) | 行变大 → 往下 |
| 3 | −1 | 0 | (x−1, y) | 行变小 → 往上 |
这四个顺序你喜欢哪个都行(有人喜欢「上下左右」写成 {-1,1,0,0} 配 {0,0,-1,1})。
唯一的要求是:dx[d] 和 dy[d] 必须描述同一个方向,不许写错位。写错位是最经典的错误之一,会让你「斜着走」。
3×3 的九宫格里,除了自己,周围八个格子各对应一个方向。把它们按「从左上角开始一行一行数过去」的顺序排,最好记:
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
// 一一对应:(-1,-1)左上 (-1,0)上 (-1,1)右上 (0,-1)左 (0,1)右 (1,-1)左下 (1,0)下 (1,1)右下
for(int d = 0; d < 8; d++){
int nx = x + dx[d];
int ny = y + dy[d];
}
八个方向都必须是真邻居,不能把「原地不动」也算成一种走法。所以这八个向量里没有 (0,0)。
如果不小心把 (0,0) 写进去,DFS 就会自己调用自己、无限递归,最后栈溢出(RE)。
中国象棋里的马走「日」字:先走两格再拐一格,一共也是八种走法,但步长不是 1 而是 2 和 1 的组合:
int dx[8] = {-2, -2, -1, -1, 1, 1, 2, 2};
int dy[8] = {-1, 1, -2, 2, -2, 2, -1, 1};
看出来了吗?方向数组只是「一堆偏移量」,具体偏移多少完全由题目规则决定。
变化的是数组里的数字,不变的是 nx = x + dx[d] 这个写法。
它把「有几种走法」这个信息,从代码结构(写几段 if)转移到了数据(数组里有几个元素)。
于是「四方向改成八方向」只需要改数组内容,循环体一个字都不用动——这就是今天所有题目的基础工具。
有了方向数组,网格上的 DFS 就只剩下一个固定骨架。今天四道题里有三道都是它的变形。
void dfs(int x, int y){
vis[x][y] = 1; // ① 标记:我来过了
for(int d = 0; d < 4; d++){ // ② 枚举四个方向
int nx = x + dx[d], ny = y + dy[d];
if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue; // ③ 越界
if(vis[nx][ny] || 不能走) continue; // ④ 重复 / 障碍
dfs(nx, ny); // ⑤ 继续深入
}
}
| # | 代码 | 作用 | 写错的后果 |
|---|---|---|---|
| ① | vis[x][y] = 1; | 声明「这个格子已经处理过」 | 漏了会绕圈死循环 / 重复计数 |
| ② | for(d = 0; d < 4; d++) | 四个方向逐个尝试 | 写成 < 8 就变八方向了,看题目要求 |
| ③ | 越界判断 | 别走出棋盘 | 数组越界 → 玄学 WA / RE |
| ④ | 访问 / 障碍判断 | 不重复走、不撞墙 | 死循环或答案偏大 |
| ⑤ | dfs(nx, ny); | 递归深入 | — |
你的数组下标是从 0 开始的(g[0..n-1][0..m-1]),还是从 1 开始的(g[1..n][1..m])?这决定了越界判断怎么写!
| 下标约定 | 越界判断 |
|---|---|
从 0 开始,合法范围 0 ~ n-1 | if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue; |
从 1 开始,合法范围 1 ~ n | if(nx < 1 || nx > n || ny < 1 || ny > m) continue; |
两套写法混用必错。本文的代码统一用「从 1 开始」这套(题目给的坐标就是 1 开始,直接用最省事,数组开大 5 个格子防越界)。
DFS 是用「函数调用栈」实现的,递归太深会爆栈。粗略的估算:递归深度 ≈ 最长的一条路径长度。
今天的题里,P1596 是 100×100 的田地(最深 10000 层),实践下来没问题;
但如果以后遇到 1000×1000 的网格连通块,就要考虑改成 BFS 或者手写栈了。
| 任务类型 | 例子 | DFS 完要不要撤销标记 |
|---|---|---|
| 连通块 / 遍历:把整片区域走一遍,看看有多大、有几块 | P1596 数水塘 | 不用撤销。走过就是走过,一个格子属于哪块水塘是定死的 |
| 路径 / 方案计数:枚举所有从起点到终点的走法 | P1605 迷宫 | 必须撤销!否则不同路径会互相「挡路」 |
想象你在走迷宫,题目要求「每个方格最多经过一次」。
你沿着路线 A 走到了终点,现在要回去试试路线 B。如果你不把路线 A 的脚印擦掉,
路线 B 就会因为「这些格子已经走过了」而走不通,于是你数出来的方案数会偏少,甚至变成 0。
正确做法是:进入格子时打标记,离开格子时(递归返回后)立刻把标记擦掉—— 让这个格子回到「没走过」的状态,好让下一条路径能用它。
// ❌ 连通块写法(不撤销)—— 用在路径计数题上会漏解 void dfs(int x, int y){ vis[x][y] = true; for(int d = 0; d < 4; d++){ ... if(!vis[nx][ny] && 能走) dfs(nx, ny); } } // ✅ 路径计数写法(撤销)—— P1605 要这样写 void dfs(int x, int y){ if(x == fx && y == fy){ ans++; return; } // 到终点,记一笔 for(int d = 0; d < 4; d++){ int nx = x + dx[d], ny = y + dy[d]; if(越界 || 障碍 || vis[nx][ny]) continue; vis[nx][ny] = true; // 进格子:留下脚印 dfs(nx, ny); vis[nx][ny] = false; // 出格子:擦掉脚印 ★★★ } }
「标记」和「撤销」总是成对出现在递归调用的上下两行。
修改状态 → dfs(...) → 恢复状态 这个三明治结构,就是「回溯」的全部秘密。
Day 18 的全排列(选完数字要把它放回候选池)用的是同一个招数。
这是今天最「聪明」的一节。P1219 的关键不在 DFS,而在于如何瞬间判断一个位置能不能放皇后。
n×n 棋盘上放 n 个皇后,要求:每行、每列有且只有一个;每条对角线上至多有一个。
注意一个天然的好消息:我们按「一行一行放」的顺序做 DFS,第 i 行就放一个皇后。 这样一来「每行只有一个」自动满足,我们只需要检查列和两条对角线。
| 冲突类型 | 怎么判断两个格子在同一条线上 | 标记数组 | 数组开多大 |
|---|---|---|---|
| 同一列 | 列号相同:j 相同 | bool col[15] | n + 5 |
| ↘ 主对角线(左上→右下) | i − j 相同 | bool udg[30],下标用 i − j + n | 2n + 5 |
| ↙ 副对角线(右上→左下) | i + j 相同 | bool dg[30],下标用 i + j | 2n + 5 |
在 5×5 的棋盘上,把每个格子的 i+j 写出来(行 i 向下增大,列 j 向右增大):
所有「↙ 方向」(右上到左下)的格子,i+j 完全相同。 比如 (1,5)、(2,4)、(3,3)、(4,2)、(5,1) 的 i+j 都是 6——它们正好在同一条副对角线上。
再看 i−j:
所有「↘ 方向」(左上到右下)的格子,i−j 完全相同。 比如 (1,1)、(2,2)、(3,3)、(4,4)、(5,5) 的 i−j 都是 0。
i−j 的范围是 1−n 到 n−1,是负数。数组下标不能为负,所以统一加上 n 变成
1 到 2n−1,全部落在非负区间里。
这就是 udg[i - j + n] 里那个 +n 的由来——不是为了好看,是为了把负数搬进合法下标范围。
这和 Day06 讲的「把 −100~100 映射成 0~200」是同一招。
if(col[j] || dg[i + j] || udg[i - j + n]) continue; // 三个数组都查一遍,O(1)
对比一下暴力做法:要扫描已经放好的所有皇后、逐个算「是不是同列 / 同对角线」,那是 O(n)。 n=13 时总体差距大约是 13 倍——这就是「用数组换时间」的又一次胜利(Day 06 的桶思想在这里再次出现)。
col[j] = dg[i+j] = udg[i-j+n] = true; 一句话同时占三个位置,撤销时也一句话同时放开三个。
占位和撤销必须严格对称,少撤一个,后面的分支就全被堵死了(这是 P1219 最常见的 WA 原因)。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是真实结果。
题意:在 n×n 的棋盘上放 n 个棋子,要求每行、每列都恰好有一个,每条对角线上至多一个。 把所有解按字典序输出前 3 个(每个解输出 n 个数:第 i 个数表示第 i 行的棋子在第几列),最后输出解的总数。
对于 100% 的数据,6 ≤ n ≤ 13。
为什么上限只有 13?因为解的数量增长得非常快:n=8 有 92 个解,而 n=13 有 73712 个。 朴素的回溯在 n=13 时大约要跑几千万次操作,正好卡在 1 秒的时限附近——所以冲突检测必须是 O(1),慢一点就超时。
dfs(i) 表示「现在要给第 i 行放一个皇后」。放完第 n 行就得到一个解。for(j = 1; j <= n; j++),看第 j 列能不能放。col[j] || dg[i+j] || udg[i-j+n] 任意一个为真就跳过(见第 4 节)。dfs(i+1) 之后再全部清零。total 计数器,只在前 3 个解时打印序列,第 4 行打印总数。6
2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 4
含义:第一个解是「第 1 行放第 2 列、第 2 行放第 4 列、第 3 行放第 6 列……」,6×6 棋盘一共 4 个解。
#include <iostream> using namespace std; int n, total = 0; int pos[15]; // pos[i] = 第 i 行的皇后放在第几列 bool col[15]; // col[j] :第 j 列有没有被占 bool dg[30]; // dg[i+j] :副对角线(右上到左下,i+j 是常数) bool udg[30]; // udg[i-j+n] :主对角线(左上到右下,i-j 是常数,+n 是为了不变负数) void dfs(int i){ // 现在要给第 i 行放皇后 if(i > n){ // 1~n 行都放好了,得到一个解 total++; if(total <= 3){ // 只输出前 3 个解 for(int r = 1; r <= n; r++){ if(r > 1) cout << " "; cout << pos[r]; } cout << "\n"; } return; } for(int j = 1; j <= n; j++){ // 试着把皇后放在第 j 列 if(col[j] || dg[i + j] || udg[i - j + n]) continue; // O(1) 冲突检测 pos[i] = j; col[j] = dg[i + j] = udg[i - j + n] = true; // 占位 dfs(i + 1); // 递归下一行 col[j] = dg[i + j] = udg[i - j + n] = false; // 回溯:撤销标记 } } int main(){ cin >> n; dfs(1); // 从第 1 行开始放 cout << total << endl; // 最后一行是解的总个数 return 0; }
官方样例 6 → 输出
2 4 6 1 3 5 3 6 2 5 1 4 4 1 5 2 6 3 4
与样例完全一致(三行解 + 一行总数 4)。
最大数据 13 → 输出前三行 1 3 5 2 9 12 10 13 4 6 8 11 7 /
1 3 5 7 9 11 13 2 4 6 8 10 12 / 1 3 5 7 12 10 13 6 4 2 8 11 9,
总数为 73712(这是八皇后问题的标准答案),用时约 225 ms,在 1 秒时限内很安全。
col 忘了撤销对角线,后面分支会被莫名堵死,答案偏少。+n。udg[i-j] 在 i < j 时下标是负数,直接越界(RE 或玄学错误)。if(r > 1) cout << " "; 控制。total <= 3 控制,别把 73712 个解全打出来。col[15] 够用;对角线下标最大到 2n = 26,开 [30] 稳妥。题意:给一个 N×M 的方格迷宫,里面有 T 个障碍。从起点 (SX,SY) 走到终点 (FX,FY), 每次只能上下左右走一格,每个方格最多经过一次,问一共有多少种走法。
对于 100% 的数据,1 ≤ N, M ≤ 5,1 ≤ T ≤ 10,1 ≤ SX, FX ≤ N,1 ≤ SY, FY ≤ M。 数据保证起点上没有障碍。
棋盘最大只有 5×5 = 25 格!这不是「数据太小了没意思」,而是题目在暗示你可以放心地暴力搜索: 我们数了一下,5×5 无障碍时从 (1,1) 到 (5,5) 的走法一共 8512 种,DFS 一瞬间就跑完了。
坐标都是 1 开头的(行号从 1 到 N),所以数组开成 [10][10] 并把下标当 1~5 用最省事。
g[x][y] = 1。vis[sx][sy] = true;。起点在路径上,也得算「已经走过」。dfs(x, y) 表示「我现在站在 (x,y) 上」。(x,y) == (fx,fy),ans++ 然后 return(不用继续走了)。2 2 1 1 1 2 2 1 2
1
2×2 的迷宫,(1,2) 是障碍。从 (1,1) 只能先下到 (2,1) 再右到 (2,2),只有 1 条路。
#include <iostream> using namespace std; int n, m, t, sx, sy, fx, fy, ans = 0; int g[10][10]; // g[x][y] = 1 表示这格是障碍 bool vis[10][10]; // 这格走过了没有 int dx[4] = {0, 0, 1, -1}; // ★ 方向数组:行怎么变 int dy[4] = {1, -1, 0, 0}; // ★ 方向数组:列怎么变 void dfs(int x, int y){ if(x == fx && y == fy){ // 走到终点,找到一条路径 ans++; return; } for(int d = 0; d < 4; d++){ // 四个方向逐个试 int nx = x + dx[d], ny = y + dy[d]; if(nx < 1 || nx > n || ny < 1 || ny > m) continue; // 越界(坐标从 1 开始) if(g[nx][ny] == 1 || vis[nx][ny]) continue; // 障碍 或 走过的格子 vis[nx][ny] = true; // 进格子:留下脚印 dfs(nx, ny); // 继续深入 vis[nx][ny] = false; // ★ 出格子:擦掉脚印(回溯) } } int main(){ cin >> n >> m >> t; cin >> sx >> sy >> fx >> fy; for(int i = 0; i < t; i++){ int x, y; cin >> x >> y; g[x][y] = 1; // 标记障碍 } vis[sx][sy] = true; // 起点也算走过 dfs(sx, sy); cout << ans << endl; return 0; }
官方样例 → 输出 1 与样例完全一致。
补充测试:5×5 无障碍、从 (1,1) 到 (5,5) → 输出 8512(不考虑障碍时角到角的走法总数),瞬间返回,说明搜索规模完全可控。
vis[nx][ny] = false;)→ 第一条路径把格子全占了,后面的路径全走不通,答案偏小。< 0:本题坐标从 1 开始,要用 nx < 1。[10][10] 时下标能到 9,而 N、M 最大才 5,多出来的空间是留给越界缓冲的。题意:给一块 N×M 的田地,每格是水(W)或干地(.)。
一个水塘由连通的水格子组成,这里的「相邻」是八邻居(包括斜角)。
问田地里一共有多少个水塘。
1 ≤ N ≤ 100,1 ≤ M ≤ 100。字符之间没有空格,所以要么一个字符一个字符读(cin >> g[i][j] 会自动跳过空白),
要么整行读成字符串。用 cin >> g[i][j] 读单个字符最省事。
最关键的一点:这里是 8 方向,不是 4 方向!斜着相邻的两个水格属于同一个水塘。方向数组必须写 8 个元素。
这就是第 2 节那个「连通块」任务的模板题,思路非常朴素:
W:说明发现了一个新水塘 → 水塘数 +1。W 全部「抽干」(改成 .)。因为「一个格子属于哪个水塘」是客观事实,一旦确认它属于当前这块水塘,就永远不会再需要它了。
所以直接把 W 改成 .(或者 vis = true)就行,不用回头擦掉。
对比 P1605:那里要数「所有路径」,同一个格子在不同路径里要反复使用,所以必须撤销。 「要不要撤销」取决于题目问的是「一块区域」还是「一条路径」。
10 12 W........WW. .WWW.....WWW ....WW...WW. .........WW. .........W.. ..W......W.. .W.W.....WW. W.W.W.....W. .W.W......W. ..W.......W.
3
题面说明:共有三个水塘——一个在左上角,一个在左下角,还有一个沿着右侧。
#include <iostream> using namespace std; int n, m, ans = 0; char g[105][105]; // ★ 八个方向:左上、上、右上、左、右、左下、下、右下(注意没有 (0,0)) int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; void dfs(int x, int y){ g[x][y] = '.'; // 把水抽干,等价于「标记已访问」 for(int d = 0; d < 8; d++){ // ★ 这里是 8,不是 4 int nx = x + dx[d], ny = y + dy[d]; if(nx < 1 || nx > n || ny < 1 || ny > m) continue; // 越界 if(g[nx][ny] == 'W') dfs(nx, ny); // 是水就继续扩散 } } 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] == 'W'){ // 找到一格还没被抽干的水 → 新水塘 ans++; dfs(i, j); // 把整个水塘抽干 } cout << ans << endl; return 0; }
官方样例 → 输出 3 与样例完全一致。
补充测试:100×100 全部是 W → 输出 1(整块田只有一个水塘,说明递归 10000 层也没有爆栈)。
cin >> g[i][j] 读字符是可以的(>> 会跳过空白字符),
但千万不要用 getline 混着读,那样会读到空行。整道题统一用一种读法。W 改成 . 之后,判断条件必须还是 == 'W',
别同时又用 vis 数组又改字符,两套状态容易打架。nx < 1 || nx > n。题意:给一个 n×n 的字母方阵,里面可能藏着若干个单词 yizhong。
单词沿着八个方向之一连续摆放,摆放过程中不许拐弯。单词之间可以交叉、共用字母。
输出时把不属于任何单词的字母换成 *,属于单词的字母原样保留。
7 ≤ n ≤ 100。方阵最大 100×100 = 10000 个格子。
单词长度固定是 7(y-i-z-h-o-n-g),方向固定是 8 个。所以最坏情况枚举量是
10000 × 8 = 8 万次,每次最多比对 7 个字符,总共几十万次操作——小得可以忽略,随便怎么写都不会超时。
这题和前三题的姿势都不一样:它不是「一步一格地递归」,而是「沿着一个方向一口气走到底」。
y(单词的第一个字母)时才可能是单词的开头。i z h o n g。mark 数组里标成 true。mark 为真输出原字母,否则输出 *。如果找到一个单词就立刻把方阵里的字母改成别的字符(比如改成 *),
会破坏后面的搜索——因为单词之间可以共用字母,别的单词还要靠这些字母来匹配。
所以正确顺序是:先把所有单词都找完、只做标记;等搜索彻底结束,再根据标记统一决定每个格子印什么。
7 aaaaaaa aaaaaaa aaaaaaa aaaaaaa aaaaaaa aaaaaaa aaaaaaa
******* ******* ******* ******* ******* ******* *******
一个单词都没有 → 全部是星号。
8 qyizhong gydthkjy nwidghji orbzsfgz hhgrhwth zzzzzozo iwdfrgng yyyygggg
*yizhong gy****** n*i***** o**z**** h***h*** z****o** i*****n* y******g
第 1 行横着的 yizhong,加上从左上往右下的一条斜线,
两个单词在第 1 行第 2 列的 y 处共用了一个字母。
#include <iostream> #include <string> using namespace std; int n; char g[105][105]; bool mark[105][105]; // mark[x][y] = 这格属于某个 yizhong string word = "yizhong"; // ★ 八个方向 int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; int main(){ cin >> n; for(int i = 1; i <= n; i++) for(int j = 1; j <= n; j++) cin >> g[i][j]; for(int i = 1; i <= n; i++) for(int j = 1; j <= n; j++){ if(g[i][j] != word[0]) continue; // 起点必须是 'y' for(int d = 0; d < 8; d++){ // 8 个方向各试一次 bool ok = true; for(int k = 1; k < 7; k++){ // 沿这个方向再走 6 步 int nx = i + dx[d] * k, ny = j + dy[d] * k; // ★ 步长 k if(nx < 1 || nx > n || ny < 1 || ny > n || g[nx][ny] != word[k]){ ok = false; break; } } if(ok) // 七个字母全对上了 for(int k = 0; k < 7; k++) mark[i + dx[d] * k][j + dy[d] * k] = true; // 标记这 7 格 } } for(int i = 1; i <= n; i++){ for(int j = 1; j <= n; j++) cout << (mark[i][j] ? g[i][j] : '*'); // 属于单词就印原字母 cout << "\n"; } return 0; }
dx[d] * k 是今天的第二个小技巧
普通 DFS 是「走一步」:nx = x + dx[d]。
这里是「沿着方向 d 走第 k 步」:nx = x + dx[d] * k。
k 就是步数乘以方向向量,所以 (1,1) 沿方向 (1,1) 走到第 3 步就是 (4,4)。
以后遇到「沿直线摆放」「射线上的格子」这类题,都是这个写法。
样例 1(7×7 全 a)→ 输出 7 行 ******* 与样例完全一致。
样例 2 → 实际输出
*yizhong gy****** n*i***** o**z**** h***h*** z****o** i*****n* y******g
与样例完全一致(8 行逐字符比对相同)。
补充测试:100×100 全是 y → 输出 100 行,非星号字符数 0,符合预期(yy 开头的七连不构成 yizhong)。
k 忘了:写成 i + dx[d] 就变成「只看紧邻的那一格」,永远匹配不上 7 个字母。|| 连起来,写成 && 就漏判了。k = 1; k < 7(比对后 6 个字母),标记时才是 k = 0; k < 7,
两处的范围不一样,容易写混。| 错误写法 | 后果 | 正确写法 |
|---|---|---|
vis[nx][ny] = true; 之后没有对应的一句 false | 路径计数题答案偏少甚至为 0 | vis[nx][ny] = true; dfs(nx,ny); vis[nx][ny] = false; |
| P1596 方向数组只写 4 个 | 斜向连通的水塘被拆成好几块,答案偏大 | 八方向,dx[8]/dy[8],且不含 (0,0) |
udg[i - j] 不加 n | 下标为负 → 越界,RE 或玄学 WA | udg[i - j + n] |
| 八皇后的三个标记数组只撤销了一部分 | 后面的分支被莫名堵死,解的总数偏少 | 占位和撤销写成对称的两行 |
越界判断用 nx < 0,但坐标是 1~n | 访问到下标 0 的非法格子 | 从 1 开始就用 nx < 1 || nx > n |
P1101 找到单词就立刻把字母改成 * | 破坏后面的匹配(单词之间会共用字母) | 用 mark 数组先标记,最后统一输出 |
P1101 写成 i + dx[d] 忘了乘 k | 永远匹配不上 7 个字母 | i + dx[d] * k |
数组只开到 n(如 char g[5][5]) | 下标 1~5 越界,RE | 1 开始的下标 → 数组至少开 n + 5 |
要求顺序是「左上、上、右上、左、右、左下、下、右下」。
int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
★ 检查方法:把每一对 (dx, dy) 念一遍,和九宫格的方位对上号。八个里不能有 (0,0)。
看题目问的是「一片区域」还是「一条路径」:
· 问「有几个连通块 / 这块区域有多大」→ 格子用过就永远不会再用 → 不用撤销(P1596)。
· 问「有多少种走法 / 枚举所有方案」→ 同一个格子在不同方案里要反复使用 → 必须撤销(P1605、P1219)。
棋盘 5×5,已经在 (3, 2) 放了一个皇后。下面哪些格子会被它「攻击」到?
(3, 5)、(1, 4)、(5, 4)、(2, 2)、(4, 1)、(5, 0)。
答:(3,5) 同行但列相同才冲突——列 = 5 ≠ 2,i−j = −2 ≠ 1,i+j = 8 ≠ 5 → 安全。
(1,4):i−j = −3,i+j = 5 → i+j 与 (3,2) 的 5 相同 → 在副对角线上,冲突。
(5,4):i+j = 9 ≠ 5,i−j = 1 = (3−2) → 在主对角线上,冲突。
(2,2):i−j = 0,i+j = 4 → 都不同 → 安全。
(4,1):i−j = 3,i+j = 5 → i+j 相同 → 冲突(和 (1,4) 同一条副对角线)。
(5,0):列 0 越界,棋盘上不存在这个格子。
int n, m, ans = 0;
int g[105][105];
int dx[4] = {0, 0, 1, -1}, dy[4] = {1, -1, 0, 0};
void dfs(int x, int y){
g[x][y] = 0; // 抹掉,等价于标记
for(int d = 0; d < 4; d++){
int nx = x + dx[d], ny = y + dy[d];
if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
if(g[nx][ny] == 1) dfs(nx, ny);
}
}
// 主函数里:
// for i, j: if(g[i][j] == 1){ ans++; dfs(i, j); }
★ 注意:这里不撤销,因为问的是「有几块」而不是「有几条路径」。把 P1596 的 8 改成 4 就是这道题。
【四方向 / 八方向】
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
int dx[8] = {-1,-1,-1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1,-1, 1,-1, 0, 1}; // 八个里没有 (0,0)!
【网格 DFS 框架(连通块:不撤销)】
void dfs(int x, int y){
g[x][y] = 0; // 标记
for(int d = 0; d < 4; d++){
int nx = x + dx[d], ny = y + dy[d];
if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
if(能走) dfs(nx, ny);
}
}
// 主函数:每个没走过的起点 dfs 一次,次数就是连通块数
【网格 DFS 框架(路径计数:必须撤销)】
void dfs(int x, int y){
if(到终点){ ans++; return; }
for(int d = 0; d < 4; d++){
int nx = x + dx[d], ny = y + dy[d];
if(越界 || 障碍 || vis[nx][ny]) continue;
vis[nx][ny] = true;
dfs(nx, ny);
vis[nx][ny] = false; // ★ 回溯,必须有
}
}
【八皇后冲突标记(i 行 j 列)】
col[j] 列
dg[i + j] 副对角线 ↙(i+j 是常数)
udg[i - j + n] 主对角线 ↘(i-j 是常数,+n 防负数)
// 数组开 2n+5;占位和撤销必须成对
【沿固定方向走(P1101 那种)】
for(int d = 0; d < 8; d++)
for(int k = 0; k < len; k++){
int nx = i + dx[d] * k; // ★ 别忘了乘 k
int ny = j + dy[d] * k;
}
【铁律】
1 开始的下标 → 数组开到 n + 5,越界判断用 nx < 1
0 开始的下标 → 越界判断用 nx < 0 || nx >= n
修改状态 → dfs() → 恢复状态,三行永远成套出现
BFS 入门:一层一层地扩散
今天的方向数组明天原封不动继续用(网格 BFS 也是 nx = x + dx[d])。
区别在于:DFS 一条道走到黑,用的是递归;BFS 一圈一圈往外扩,用的是队列。
明天的主角是 P1443 马的遍历——一匹中国象棋的马在棋盘上跳,求它到每个格子的最少步数。
这类「最少步数」的问题,DFS 是做不动的(会超时),必须换 BFS。
而且马的方向数组不是那八个「斜一格」,而是 {-2,-2,-1,-1,1,1,2,2} 配 {-1,1,-2,2,-2,2,-1,1}——今天先混个脸熟。
另外 P1219 那种「用数组记录冲突」的思路还会反复出现:以后学 N 皇后变形、数独、图着色,都是同一套「状态标记 + 回溯」。