ACM 一个月学习计划 · DAY 19

Day 19 · DFS 回溯:网格搜索与冲突检测

方向数组 + 网格 DFS 框架 + 回溯撤销标记 + 八皇后的 O(1) 冲突判断

今日目标:掌握网格类 DFS 的通用写法,会用方向数组,会写带约束条件的回溯
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1219 / P1605 / P1596 / P1101
← Day 18 · DFS 入门:枚举所有方案

⏱今日时间安排

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、最短路、迷宫)的公共基础设施,今天必须练到闭着眼睛能写出来。

1方向数组:搜索题的「方向盘」

没有方向数组时,代码长这样

假设我们在一个格子上,要试着往上下左右四个方向各走一步,检查能不能走。最直白的写法是:

// 分别处理四个方向,四段几乎一样的代码
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 个方向的邻居
}
ddx[d]dy[d]走到的邻居怎么记
00+1(x, y+1)列变大 → 往右
10−1(x, y−1)列变小 → 往左
2+10(x+1, y)行变大 → 往下
3−10(x−1, y)行变小 → 往上
💡 顺序随便,只要 dx 和 dy 一一对应

这四个顺序你喜欢哪个都行(有人喜欢「上下左右」写成 {-1,1,0,0} 配 {0,0,-1,1})。 唯一的要求是:dx[d] 和 dy[d] 必须描述同一个方向,不许写错位。写错位是最经典的错误之一,会让你「斜着走」。

八方向(含四个斜角)

3×3 的九宫格里,除了自己,周围八个格子各对应一个方向。把它们按「从左上角开始一行一行数过去」的顺序排,最好记:

(−1,−1) 左上(−1, 0) 上(−1,+1) 右上 ( 0,−1) 左( 0, 0) 我自己( 0,+1) 右 (+1,−1) 左下(+1, 0) 下(+1,+1) 右下
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)。
如果不小心把 (0,0) 写进去,DFS 就会自己调用自己、无限递归,最后栈溢出(RE)。

还有第三种:马走日(Day 20 要用)

中国象棋里的马走「日」字:先走两格再拐一格,一共也是八种走法,但步长不是 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)转移到了数据(数组里有几个元素)。
于是「四方向改成八方向」只需要改数组内容,循环体一个字都不用动——这就是今天所有题目的基础工具。

2网格 DFS 的通用框架(背下来)

有了方向数组,网格上的 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-1if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
从 1 开始,合法范围 1 ~ nif(nx < 1 || nx > n || ny < 1 || ny > m) continue;

两套写法混用必错。本文的代码统一用「从 1 开始」这套(题目给的坐标就是 1 开始,直接用最省事,数组开大 5 个格子防越界)。

💡 递归深度够不够?

DFS 是用「函数调用栈」实现的,递归太深会爆栈。粗略的估算:递归深度 ≈ 最长的一条路径长度。
今天的题里,P1596 是 100×100 的田地(最深 10000 层),实践下来没问题; 但如果以后遇到 1000×1000 的网格连通块,就要考虑改成 BFS 或者手写栈了。

3回溯:走过去要留脚印,走回来要擦掉

先分清两种任务

任务类型例子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 的全排列(选完数字要把它放回候选池)用的是同一个招数。

4八皇后的冲突标记:O(1) 判断能不能放

这是今天最「聪明」的一节。P1219 的关键不在 DFS,而在于如何瞬间判断一个位置能不能放皇后。

先看规则

n×n 棋盘上放 n 个皇后,要求:每行、每列有且只有一个;每条对角线上至多有一个。

注意一个天然的好消息:我们按「一行一行放」的顺序做 DFS,第 i 行就放一个皇后。 这样一来「每行只有一个」自动满足,我们只需要检查列和两条对角线。

三个标记数组

冲突类型怎么判断两个格子在同一条线上标记数组数组开多大
同一列列号相同:j 相同bool col[15]n + 5
↘ 主对角线(左上→右下)i − j 相同bool udg[30],下标用 i − j + n2n + 5
↙ 副对角线(右上→左下)i + j 相同bool dg[30],下标用 i + j2n + 5

为什么 i+j 和 i−j 能表示对角线

在 5×5 的棋盘上,把每个格子的 i+j 写出来(行 i 向下增大,列 j 向右增大):

23456 34567 45678 56789 678910

所有「↙ 方向」(右上到左下)的格子,i+j 完全相同。 比如 (1,5)、(2,4)、(3,3)、(4,2)、(5,1) 的 i+j 都是 6——它们正好在同一条副对角线上。

再看 i−j:

0−1−2−3−4 10−1−2−3 210−1−2 3210−1 43210

所有「↘ 方向」(左上到右下)的格子,i−j 完全相同。 比如 (1,1)、(2,2)、(3,3)、(4,4)、(5,5) 的 i−j 都是 0。

⚠️ 负数不能当数组下标!所以要 +n

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 原因)。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是真实结果。

P1219 [USACO1.5] 八皇后 Checker Challenge 普及/提高- · 回溯 + 冲突检测

题意:在 n×n 的棋盘上放 n 个棋子,要求每行、每列都恰好有一个,每条对角线上至多一个。 把所有解按字典序输出前 3 个(每个解输出 n 个数:第 i 个数表示第 i 行的棋子在第几列),最后输出解的总数。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,6 ≤ n ≤ 13。

为什么上限只有 13?因为解的数量增长得非常快:n=8 有 92 个解,而 n=13 有 73712 个。 朴素的回溯在 n=13 时大约要跑几千万次操作,正好卡在 1 秒的时限附近——所以冲突检测必须是 O(1),慢一点就超时。

思路

  1. 按行放:dfs(i) 表示「现在要给第 i 行放一个皇后」。放完第 n 行就得到一个解。
  2. 这一行试每一列:for(j = 1; j <= n; j++),看第 j 列能不能放。
  3. O(1) 判断冲突:col[j] || dg[i+j] || udg[i-j+n] 任意一个为真就跳过(见第 4 节)。
  4. 占位 → 递归 → 撤销:放下去就把三个数组标上,dfs(i+1) 之后再全部清零。
  5. 输出控制:用一个 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 << " "; 控制。
  • 只输出前 3 个解,不是全部解。用计数器 total <= 3 控制,别把 73712 个解全打出来。
  • 数组要开够:n 最大 13,所以 col[15] 够用;对角线下标最大到 2n = 26,开 [30] 稳妥。
P1605 迷宫 普及- · 网格 DFS 路径计数

题意:给一个 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 用最省事。

思路

  1. 存地图:读入 N、M、T,然后读起点终点,最后把 T 个障碍点标记成 g[x][y] = 1。
  2. 起点先标记:vis[sx][sy] = true;。起点在路径上,也得算「已经走过」。
  3. DFS 从起点出发:dfs(x, y) 表示「我现在站在 (x,y) 上」。
  4. 终点就是答案 +1:一旦 (x,y) == (fx,fy),ans++ 然后 return(不用继续走了)。
  5. 回溯:四个方向试着走,能走就标记、递归、然后把标记擦掉(这是本题的灵魂)。
样例输入
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;)→ 第一条路径把格子全占了,后面的路径全走不通,答案偏小。
  • 忘记标记起点 → 搜索可能绕回起点再走一遍,(1,1)↔(1,2) 之间来回晃,路径数虚高。
  • 障碍点在起点上:本题保证不会,但读入顺序要注意——先读起点终点,再读 T 个障碍。
  • 越界判断写成 < 0:本题坐标从 1 开始,要用 nx < 1。
  • 数组开 [10][10] 时下标能到 9,而 N、M 最大才 5,多出来的空间是留给越界缓冲的。
P1596 [USACO10OCT] Lake Counting S 普及- · 八方向连通块计数

题意:给一块 N×M 的田地,每格是水(W)或干地(.)。 一个水塘由连通的水格子组成,这里的「相邻」是八邻居(包括斜角)。 问田地里一共有多少个水塘。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 100,1 ≤ M ≤ 100。字符之间没有空格,所以要么一个字符一个字符读(cin >> g[i][j] 会自动跳过空白), 要么整行读成字符串。用 cin >> g[i][j] 读单个字符最省事。

最关键的一点:这里是 8 方向,不是 4 方向!斜着相邻的两个水格属于同一个水塘。方向数组必须写 8 个元素。

思路

这就是第 2 节那个「连通块」任务的模板题,思路非常朴素:

  1. 扫一遍全场:从上到下、从左到右看每个格子。
  2. 遇到一格还没被处理过的水 W:说明发现了一个新水塘 → 水塘数 +1。
  3. 立刻从这格出发 DFS,沿着八个方向把所有能连到的 W 全部「抽干」(改成 .)。
  4. 继续扫描:被抽干的格子再也不会被当成「新水塘」,所以每个水塘正好被数一次。
🎯 为什么连通块题不用撤销标记

因为「一个格子属于哪个水塘」是客观事实,一旦确认它属于当前这块水塘,就永远不会再需要它了。 所以直接把 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 层也没有爆栈)。

易错点

  • 方向数组只写了 4 个:斜向相邻的水格会被漏掉,水塘数偏多。这题一定要 8 个。
  • 用 cin >> g[i][j] 读字符是可以的(>> 会跳过空白字符), 但千万不要用 getline 混着读,那样会读到空行。整道题统一用一种读法。
  • 标记方式不统一:把 W 改成 . 之后,判断条件必须还是 == 'W', 别同时又用 vis 数组又改字符,两套状态容易打架。
  • 边界 1 ~ n:本题用 1 开始的下标,越界判断是 nx < 1 || nx > n。
P1101 单词方阵 普及- · 沿固定方向搜索

题意:给一个 n×n 的字母方阵,里面可能藏着若干个单词 yizhong。 单词沿着八个方向之一连续摆放,摆放过程中不许拐弯。单词之间可以交叉、共用字母。 输出时把不属于任何单词的字母换成 *,属于单词的字母原样保留。

⚠️ 数据范围 / 关键约束

7 ≤ n ≤ 100。方阵最大 100×100 = 10000 个格子。

单词长度固定是 7(y-i-z-h-o-n-g),方向固定是 8 个。所以最坏情况枚举量是 10000 × 8 = 8 万次,每次最多比对 7 个字符,总共几十万次操作——小得可以忽略,随便怎么写都不会超时。

思路

这题和前三题的姿势都不一样:它不是「一步一格地递归」,而是「沿着一个方向一口气走到底」。

  1. 找起点:枚举每个格子,只有当它是 y(单词的第一个字母)时才可能是单词的开头。
  2. 八个方向各试一次:对每个方向 d,沿着它走 6 步,依次检查第 2~7 个字母是不是 i z h o n g。
  3. 越界立刻放弃:走的过程中只要出界,这个方向就作废。
  4. 全部对上就标记:把这 7 个格子在 mark 数组里标成 true。
  5. 最后统一输出:mark 为真输出原字母,否则输出 *。
🎯 为什么用 mark 数组「先标记、最后统一输出」

如果找到一个单词就立刻把方阵里的字母改成别的字符(比如改成 *), 会破坏后面的搜索——因为单词之间可以共用字母,别的单词还要靠这些字母来匹配。

所以正确顺序是:先把所有单词都找完、只做标记;等搜索彻底结束,再根据标记统一决定每个格子印什么。

样例输入 1
7
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
样例输出 1
*******
*******
*******
*******
*******
*******
*******

一个单词都没有 → 全部是星号。

样例输入 2
8
qyizhong
gydthkjy
nwidghji
orbzsfgz
hhgrhwth
zzzzzozo
iwdfrgng
yyyygggg
样例输出 2
*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)。

易错点

  • 边匹配边改字符:应该只做标记、最后统一输出(见上面的 idea 框)。
  • 把 k 忘了:写成 i + dx[d] 就变成「只看紧邻的那一格」,永远匹配不上 7 个字母。
  • 越界判断漏了一半:行和列都要判,且四个条件用 || 连起来,写成 && 就漏判了。
  • 方向数组顺序和 dy 对不上:一旦错位就会「斜着匹配」,样例能过但后面的点全错。
  • 单词长度是 7,所以循环写成 k = 1; k < 7(比对后 6 个字母),标记时才是 k = 0; k < 7, 两处的范围不一样,容易写混。

✗今日常见错误汇总

错误写法后果正确写法
vis[nx][ny] = true; 之后没有对应的一句 false路径计数题答案偏少甚至为 0vis[nx][ny] = true; dfs(nx,ny); vis[nx][ny] = false;
P1596 方向数组只写 4 个斜向连通的水塘被拆成好几块,答案偏大八方向,dx[8]/dy[8],且不含 (0,0)
udg[i - j] 不加 n下标为负 → 越界,RE 或玄学 WAudg[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 越界,RE1 开始的下标 → 数组至少开 n + 5

✓自测:你真的会了吗

第 1 题:写出八方向的方向数组

要求顺序是「左上、上、右上、左、右、左下、下、右下」。

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)。

第 2 题:什么时候必须撤销标记?

看题目问的是「一片区域」还是「一条路径」:

· 问「有几个连通块 / 这块区域有多大」→ 格子用过就永远不会再用 → 不用撤销(P1596)。
· 问「有多少种走法 / 枚举所有方案」→ 同一个格子在不同方案里要反复使用 → 必须撤销(P1605、P1219)。

第 3 题:手算冲突标记

棋盘 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 越界,棋盘上不存在这个格子。

第 4 题(手写代码):统计一块网格里有多少个「1」连通块(4 方向)
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() → 恢复状态,三行永远成套出现

→明天预告 · Day 20

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 皇后变形、数独、图着色,都是同一套「状态标记 + 回溯」。