内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。
迷宫游戏
课程的开始,我们来玩一个迷宫游戏,尝试走一下面的迷宫。
这是一种最短的走法:
接下来,我们通过一个实际问题——《迷宫游戏》来学习 DFS(在后面的课程中,我们都会用 DFS 来替代深度优先搜索)。
迷宫游戏
我们用一个二维的字符数组来表示前面画出的迷宫:
S**.
....
***T
其中字符S
表示起点,字符T
表示终点,字符*
表示墙壁,字符.
表示平地。你需要从S
出发走到T
,每次只能向上下左右相邻的位置移动,不能走出地图,也不能穿过墙壁,每个点只能通过一次。你需要编程来求解出一种从起点到终点的走法。
很明显,当我们从任意格子出发,都有可能往四个方向走:上,下,左,右。而初始的时候,我们是在起点S
处,之后开始进行我们的搜索过程,也就是我们要讲的 DFS 算法。
那么当我们搜索到了某一个格子(也就是我们下一步会从该格子出发的时候):
- 首先要判断一下当前格子是否就是终点,如果是,那么就表示我们已经成功的从起点
S
移动了若干步之后到达了终点T
,便成功地完成了这个问题。 - 否则我们就需要从该格子出发,可以分别枚举向左、向下、向右、向上四个方向,依次去判断它旁边的四个点是否可以作为下一步合法的目标点,如果可以,那么我们就进行这一步,走到目标点,然后继续进行操作。
- 当然有可能左、下、右、上四个点都无法再成为合法的目标点了,那么我们就回退一步,然后从上一步所在的那个格子向其他 未尝试的方向 继续枚举。
关于合法的定义如下:
-
必须在所给定的迷宫范围内。 如样例中是一个 4 行 3 列的迷宫,那么这个点必须在 (0,0)−(3,2) 的范围中才能称为合法,否则即为不合法。
-
这个点在搜索过程中必须没有被访问过。 也就是说,一个点在 DFS 的过程中只能被访问一次,不能重复访问。这样做是因为,如果一个点允许多次访问,那么肯定会出现死循环的情况——在两个点中间来回走。不过,根据题意,在某些情况下,你回溯了之后可以视回溯前的点为没有访问过。
-
这个点必须不是墙壁。这个显然很好理解,我们只能走在平地上,不能走在墙壁上也不能穿过墙壁。
DFS 走迷宫对应的伪代码框架如下:
// 对坐标为 (x, y) 的点进行搜索
int dfs(int x, int y) {
if (x, y) 是终点 {
// 找到了路径
return 1;
}
标记 (x, y) 已经访问
向上走到位置 (tx, ty)
if (tx, ty) 合法 {
if (dfs(tx, ty)) {
// 递归调用 DFS 函数,将(tx, ty)作为当前状态进行搜索,下同。
return 1;
}
}
向左走到位置 (tx, ty)
if (tx, ty) 合法 {
if (dfs(tx, ty)) {
return 1;
}
}
向下走到位置 (tx, ty)
if (tx, ty) 合法 {
if (dfs(tx, ty)) {
return 1;
}
}
向右走到位置 (tx, ty)
if (tx, ty) 合法 {
if (dfs(tx, ty)) {
return 1;
}
}
return 0;
}
迷宫上深度优先搜索
略
迷宫搜索实践
在这个题目中,我们需要完成对迷宫问题的求解。例如,对于样例输入
3 4
S**.
....
***T
我们应该输出
Yes
表示找到了一条从起点 S
到终点 T
的路径。
参考程序:
#include <stdio.h>
int n, m;
char maze[110][110];
int sx, sy;
int vis[110][110];
int dir[4][2] = { { 1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int in(int x, int y) {
return 0 <= x && x < n && 0 <= y && y < m;
}
int dfs(int x, int y) {
vis[x][y] = 1;
if (maze[x][y] == 'T') {
return 1;
}
for (int i = 0; i < 4; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
if (in(tx, ty) && !vis[tx][ty] && maze[tx][ty] != '*') {
if (dfs(tx, ty)) {
return 1;
}
}
}
return 0;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", maze[i]);
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (maze[i][j] == 'S') {
sx = i;
sy = j;
}
}
}
if (dfs(sx, sy)) {
puts("Yes");
} else {
puts("No");
}
return 0;
}
迷宫搜索实践 2
相比于之前的问题,这次我们要输出一条从起点到终点的路径。
#include <stdio.h>
int n, m;
char maze[110][110];
int sx, sy;
int vis[110][110];
int dir[4][2] = { { 1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int in(int x, int y) {
return 0 <= x && x < n && 0 <= y && y < m;
}
int dfs(int x, int y) {
vis[x][y] = 1;
if (maze[x][y] == 'T') {
return 1;
}
maze[x][y] = 'm';
for (int i = 0; i < 4; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
if (in(tx, ty) && !vis[tx][ty] && maze[tx][ty] != '*') {
if (dfs(tx, ty)) {
return 1;
}
}
}
maze[x][y] = '.';
return 0;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", maze[i]);
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (maze[i][j] == 'S') {
sx = i;
sy = j;
}
}
}
if (dfs(sx, sy)) {
puts("Yes");
} else {
puts("No");
}
for (int i = 0; i < n; i++) {
printf("%s\n", maze[i]);
}
return 0;
}
迷宫解的方案数
接下来我们看怎么求解一个迷宫有多少种可行的路径,这个其实比较简单,就声明一个全局变量表示结果,然后每次搜到终点就把结果加 1 就好了,这个函数也不必有返回值了。
写下代码来就是
int ans = 0; // 全局变量自动清 0 ,所以不手动初始化也行
int dir[4][2] = { { -1, 0},{0, -1},{1, 0},{0, 1}};
void dfs(int x, int y) {
if (maze[x][y] == 'T') {
ans++;
return;
}
vis[x][y] = 1;
for (int i = 0; i < 4; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
if (in(tx, ty) && maze[tx][ty] != '*' && !vis[tx][ty]) {
dfs(tx, ty);
}
}
vis[x][y] = 0;
}
接下来我们思考一个问题,在求解迷宫可行性,就是能不能从起点走到终点的过程中我们是没有取消访问标记的,因为如果这个点之前访问过已经判断出不可行了,那就没有必要再去那个点走一遍了。
但是现在求解的个数的时候是取消了标记的,这是为什么?
因为现在如果通过不同的方案到了同一个点,是需要再往后走的,它到这个点以后还能往后有多少条路径也跟是怎么到这个点有关的,所以没有什么好办法,只能取消标记,让它每次都重新尝试走。
所以判可行性时可以不取消,但是如果求解迷宫的解数相关的问题就必须要取消标记把每条路都跑完。
完整参考程序:
#include <stdio.h>
char maze[15][15];
int vis[15][15];
int n, m, ans;
int dir[4][2] = { { -1, 0}, {1, 0}, {0, -1}, {0, 1}};
int in(int x, int y) {
return x >= 0 && x < n && y >= 0 && y < m;
}
void dfs(int x, int y) {
if (maze[x][y] == 'e') {
ans++;
return;
}
vis[x][y] = 1;
for (int i = 0; i < 4; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
if (in(tx, ty) && maze[tx][ty] != '#' && !vis[tx][ty]) {
dfs(tx, ty);
}
}
vis[x][y] = 0;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", maze[i]);
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (maze[i][j] == 's') {
dfs(i, j);
}
}
}
printf("%d\n", ans);
return 0;
}
连通块问题
连通块可以认为是互相可以到达的一些点。
搜索还常用来解决连通块个数,最大连通块等等问题。
***.
.*.*
**.*
比如这个图,如果*
是草地,.
是空地,如果认为有公共边的格子是相互连通的(即一个格子和它周围上、下、左、右四个格子连通),那么图中草地一共有 2 个连通块,一个大小为 6 ,一个大小为 2 。
求连通块个数可以依次从每一个有效的没被访问过的点出发,往四周搜索直到出界或者遇到空地,一共出发了多少次就是有多少连通块,每一次可以统计这一次走了多少个格子,也就是这个连通块的大小。
写成代码来看是这样
void dfs(int x, int y) {
cnt++; // 这个连通块的大小
vis[x][y] = 1;
for (int i = 0; i < 4; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
if (in(tx, ty) && !vis[tx][ty] && maze[tx][ty] == '*') {
dfs(tx, ty);
}
}
}
然后调用的时候是这样
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (maze[i][j] == '*' && !vis[i][j]) {
ans++; // 连通块数量
cnt = 0; // 这个连通块的大小
dfs(x, y);
if (cnt > ans) ans = cnt; // 求最大连通块
}
}
}
只认为上、下、左、右四个方向是连通的情况常被称作四连通,如果把左上、右上、左下、右下这四个方向也看作连通就常被称作八连通。
马的覆盖点
接下来,我们再看一个问题:
这天,蒜头君迷上了中国象棋,在和一个大师的巅峰对决中处于下风。他知道自己再走三步,大师就会赢下这一局,于是蒜头君想背水一战。
他想知道这个马走三步之内可以到达的位置,是否有好的对策可以给大师致命一击。现在蒜头君的脑子已经不足以想出马走三步之内能到达的所有位置了,于是他找到作为他好朋友的你来帮忙解决这个问题。
输入格式
第一行输入两个整数 n(1≤x≤100), m(1≤m≤100) 代表棋盘行数和列数。
第二行输入两个整数 x(1≤x≤n), y(1≤y≤m) 代表马的初始位置。
输出格式
输出整个棋盘,'.'代表棋盘上可以落子的点,'#'这个代表马走三步能到达的点。
样例输入
10 9
10 1
样例输出
.........
.........
.........
.#.#.....
#.#.#....
####.#...
#####.#..
##.###...
#.###.#..
######...
讲解
本题我们还是采用 dfs 的形式来搜索出马能到达的所有位置。不过马的走法不再是之前说的那 4 种,而是日字形的 8 种,而且和之前的题目相比,题目中多加了一个限制条件:只要求标示出马三步之内能到达的点。
在之前的题目当中,由于搜索的过程只和坐标点有关,因此我们只需要用坐标来表示当前状态即可。而在本题中,搜索的过程还和步数有关,因此状态不但要用坐标来表示,还要用步数来表示。简单来说,之前的状态是一个二维的(x, y)
,而现在的坐标是(x, y, step)
,代表当前搜索到了(x, y)
这个点,搜索了step
步。
同时,在做这个题的时候还有一些需要注意的小细节。之前我们做迷宫问题的题目时,需要vis
数组来判断一个点是否被访问过,现在因为状态不只是现在在哪个点,还有现在走了多少步,因此一步到这个点和两步到这个点对后边的影响是不一样的。
那么我们就不能用之前的vis[x][y]
表示这个点访问没访问过了,但是我们可以加一维,变成vis[x][y][step]
,记录我们是不是曾经在step
步的时候到达了(x, y)
这个点,也就是(x, y, step)
这个状态是不是被访问过了。
核心代码
int dir[8][2] = { { 2, 1}, {2, -1}, {1, 2}, {1, -2}, {-2, 1}, {-2, -1}, {-1, 2}, {-1, -2}};
void dfs(int x, int y, int step) {
if (!in(x, y) || step > 3 || vis[x][y][step]) {
return;
}
maze[x][y] = '#';
vis[x][y][step] = 1;
for (int i = 0; i < 8; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
dfs(tx, ty, step + 1);
}
}
有时为了代码更加简洁,我们会把判断不合法的状态放到dfs
一开始直接return
,这样就省去了递归下去的时候的判断,而让不合法的状态下去以后立刻返回来不造成影响。
完整参考程序:
#include <stdio.h>
int n, m;
char maze[110][110];
int sx, sy;
int dir[8][2] = { { 2, 1}, {2, -1}, {1, 2}, {1, -2}, {-2, 1}, {-2, -1}, {-1, 2}, {-1, -2}};
int vis[110][110][4];
int in(int x, int y) {
return 1 <= x && x <= n && 1 <= y && y <= m;
}
void dfs(int x, int y, int step) {
if (!in(x, y) || step > 3 || vis[x][y][step]) {
return;
}
maze[x][y] = '#';
vis[x][y][step] = 1;
for (int i = 0; i < 8; i++) {
int tx = x + dir[i][0];
int ty = y + dir[i][1];
dfs(tx, ty, step + 1);
}
}
int main() {
scanf("%d%d%d%d", &n, &m, &sx, &sy);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
maze[i][j] = '.';
}
}
dfs(sx, sy, 0);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
printf("%c", maze[i][j]);
}
puts("");
}
return 0;
}