DFS的算法 —— 不撞南墙不回头
人的认识需要在继承已有的有关知识的基础上,经过多次反复才能深化,在人的认识深化过程中,既包括了从一般到特殊的演绎思维过程,也包括了从特殊到一般的归纳思维过程
—— 张海藩 《软件工程导论》
例1 全排列模板
求全排列问题的几种方法:
- 暴力搜索法
- next_permutation
- DFS
DFS解决全排列问题的过程:(以实现123三个数字的全排列为例)
如下图,假设现在有三个粉粉的盒子,还有一个小明。小明手里持有三张卡片,每张卡片上的数字互不相同,且三张卡片上的数字分别是1,2,3,记一号卡片上的数字为1,二号卡片上的数字为2,三号卡片上的数字为3
初始时小明手中持有三个卡片。现在他走到了第一个盒子的前面,假设他把1号卡片放入了第一个盒子,然后走向第二个盒子,到达第二个盒子时他的手中还有2号和3号两张卡片,假设他又把2号卡片放入了第二个盒子,然后走向第三个盒子,此时他手中只剩下3号卡片,故他只能把3号卡片放入第三个盒子,最后他继续往前行走会发现撞到了墙壁(到达边界),此时就得到了第一个排列 —— 1 2 3
递归可以拆分为递进以及回归,以上是递进的过程,但是当遇到我们提前设置好的边界问题时,程序就需要回归。
小明从墙壁(边界)处开始回归,当回归到第三个盒子的位置时,回收第三个盒子里的3号卡片,这时他手中只有3号卡片,但是由于3号卡片已经放入过一次三号盒子,故不会这么着急再次放入,所以小明继续回归。当小明回归到第二个盒子的位置时,回收第二个盒子里的2号卡片,这时他手中有2号和3号两张卡片。所以他可以把3号卡片放入第二个盒子,然后继续递进,当到达第三个盒子的时候,再把2号卡片放入最后一个盒子,然后继续前进直至撞墙(到达边界)。此时就又得到了另一个排列 —— 1,3,2
小明再次从墙壁(边界)处开始回归,当回归到第三个盒子的位置时,回收第三个盒子里的2号卡片,这时他手中只有2号卡片,但是由于2号卡片刚刚放入过三号盒子,故不会这么着急再次放入,所以小明继续回归。当小明回归到第二个盒子的位置时,回收第二个盒子里的3号卡片,这时他手中有2号和3号两张卡片。但是由于3号卡片已经放入过一次二号盒子,3号卡片也已经放入过一次三号盒子,故不会这么着急再次放入,所以小明继续回归到一号盒子然后回收了1号卡片。
现在小明和初始状态时一样,手中持有三个卡片。并且又处于第一个盒子的前面,假设他把2号卡片放入了第一个盒子,然后走向第二个盒子,到达第二个盒子时他的手中还有1号和3号两张卡片,假设他又把1号卡片放入了第二个盒子,然后走向第三个盒子,此时他手中只剩下3号卡片,故他只能把3号卡片放入第三个盒子,最后他继续往前行走会发现撞到了墙壁(到达边界),此时就得到了第三个排列 —— 2 1 3 … …
依次类推可以得到所有的排列结果
代码实现以及详细注释:
#include <iostream>
#include <cstdio>
using namespace std;
int res[100];//结果数组,用来存储结果
int vis[100];//标记数组,vis[i]=0表示数字i没有放入,1表示数字i已经放入
int n;//经验:由于dfs函数中要用到变量n,故n一般都定义为全局变量
void dfs(int step)//step表示当前面临第几个盒子
{
//递归出口,打印结果(三个数字,n = 3 -> step = 4)
//递归出口的第二种写法:step > n
if (step == n + 1)
{
for (int i = 1;i <= n;i++) printf("%d ",res[i]);
puts(" ");//输出一种排列后换行
return;//回溯
}
//递归搜索1 ~ n的全排列
for (int i = 1;i <= n;i++)//从1开始枚举
{
if (vis[i] == 0)//vis[i] = 0表示i没有放入盒子
{
res[step] = i;//把i放入当前的盒子
vis[i] = 1;//修改标记数组表示i已经放入了盒子(表示i号卡片已经不在手中)
dfs(step + 1);//搜索下一个盒子的排列结果,搜索完毕之后需要回溯(回归)
vis[i] = 0;//回溯,需要将标记数组修改回来表示i没有放入盒子(即回收之后卡片又回到了手中)
}
}
}
int main()
{
cin >> n;
dfs(1);//从第一个盒子开始搜索结果
return 0;
}
练习:
例2 数独游戏
深度优先搜索应用于数独游戏中思想的体现:
- 当前如何做
- 下一步如何做
数独游戏描述:
- 如上图棋盘,大棋盘有9行9列
- 将大棋盘分为9个3行3列的小棋盘
- 每个小棋盘中有几个数字
- 现在需要我们往小棋盘中补充数字,补充完数字之后使得每个小棋盘中都包含1 ~ 9这几个数字
- 还需要保证大棋盘的每一行都包含1 ~ 9这几个数字,每一列也都包含1 ~ 9这几个数字
棋盘初始化:
6 7 2 0 0 5 0 0 1
0 0 0 0 6 4 0 0 8
0 0 9 0 0 3 0 0 2
0 9 0 0 2 0 4 3 0
0 0 7 0 0 0 5 0 0
0 6 5 0 1 0 0 2 0
2 0 0 4 0 0 9 0 0
9 0 0 7 8 0 0 0 0
7 0 0 6 0 0 2 8 3
代码实现以及详细注释:
#include <iostream>
#include <cstdio>
using namespace std;
const int N = 10;//定义一个常量
int a[N][N];//开一个数组
bool check(int x,int y,int i)//检查数字i是否可以放入坐标(x,y)的位置
{
/*
check函数需要检查三点:
1.同一行元素是否有重复
2.同一列元素是否有重复
3.小九宫格中的元素是否有重复
*/
for (int k = 0;k < 9;k++)
{
if (a[x][k] == i) return false;//检查同一行元素中是否存在与i重复
if (a[k][y] == i) return false;//检查同一列元素中是否存在与i重复
}
for (int p = (x / 3) * 3;p < (x / 3 + 1) * 3;p++)
for (int q = (y / 3) * 3;q < (y / 3 + 1) * 3;q++)
if (a[p][q] == i)
return false;
return true;
}
void dfs(int x,int y)//当前坐标为(x,y)时,如何处理
{
if (x == 9)//到达边界,输出结果(棋盘第一个位置的坐标为(0,0))
{
for (int s = 0;s < 9;s++)
{
for (int t = 0;t < 9;t++) cout << a[s][t] << " ";
puts(" ");
}
}
if (a[x][y] == 0)//说明棋盘当前的位置无数字,可以放入数字
{
for (int i = 1;i <= 9;i++)//从1 ~ 9中选择可以符合条件的数字放入棋盘
{
if (check(x,y,i))//表示i可以放入坐标为(x,y)的位置
{
a[x][y] = i;//将i放入棋盘坐标为(x,y)的位置
dfs(x + (y + 1) / 9,(y + 1) % 9);//递归处理下一个单元格
a[x][y] = 0;//回溯
}
}
}
else dfs(x + (y + 1) / 9,(y + 1) % 9);//如果当前位置不为0,则直接处理下一个单元格
}
int main()
{
for (int m = 0;m < 9;m++)
for (int n = 0;n < 9;n++)
cin >> a[m][n];
dfs(0,0);//从大棋盘的第一个位置开始搜索结果
return 0;
}
例3 子集模板
递归搜索树:第一层考虑第一个数,第二层考虑第二个数,第三层考虑第三个数
写法一:
模拟过程中dfs函数:
① 当前考虑的是第几个数
② 前面的数有没有选
#include <iostream>
using namespace std;
const int N = 20;
int vis[N];//vis[i]=0表示i没被选择,vis[i]=1表示i被选择
int n;
void dfs(int step)//step表示当前枚举到第几层/个数字
{
if (step == n + 1)//递归出口
{
for (int i = 1;i <= n;i++)
if (vis[i] == 1)
cout << i << " ";
cout << endl;
return;//回溯
}
vis[step] = 1;//选择当前数字i,修改标记数组
dfs(step + 1);//搜索下一层数字
vis[step] = 0;//恢复现场
dfs(step + 1);//不选当前数字,直接搜索下一层数字
}
int main()
{
cin >> n;
dfs(1);//从第1个数枚举
return 0;
}
写法二:利用栈结构实现列举子集
#include <iostream>
using namespace std;
const int N = 100;
int a[N];
int n;
//利用栈来存储结果
struct stack
{
int res[N];//用来存储结果
int top = 1;//栈顶指针
};
struct stack st;
void dfs(int cur)//cur表示当前面临第几个数字,选/不选
{
if (cur == n + 1)//递归出口
{
for (int i = 1;i < st.top;i++) cout << st.res[i] << " ";//输出子集
puts(" ");//换行
return;//回溯
}
//选当前数字进入子集
st.res[st.top++] = a[cur];//选,即入栈
dfs(cur + 1);//递归搜索下一个数字
st.top--;//恢复现场,即出栈
//不选当前数字进入子集
dfs(cur + 1);
}
int main()
{
cin >> n;
for (int i = 1;i <= n;i++) a[i] = i;//初始化数组a[n] = {0,1,2,3,4,5,6... ...n}
dfs(1);//从第1个数字开始搜索
return 0;
}
练习:
例4 马走日
题目描述
马在中国象棋以日字形规则移动。 请编写一段程序,给定 n ∗ m 大小的棋盘,以及马的初始位置(x,y),要求不能重复经过棋盘上的同一个点,计算马可以有多少途径遍历棋盘上的所有点。
输入格式
第一行为整数 T,表示测试数据组数。
每一组测试数据包含一行,为四个整数,分别为棋盘的大小以及初始位置坐标 n,m,x,y。
输出格式
每组测试数据包含一行,为一个整数,表示马能遍历棋盘的途径总数,若无法遍历棋盘上的所有点则输出 0。
数据范围
1 ≤ T ≤ 9 , 1 ≤m , n ≤ 9 , 0 ≤ x ≤ n − 1 , 0 ≤ y ≤ m − 1
方向数组的构造:
以5x5的棋盘为例,可以看出需要25步可以遍历完整个棋盘
即递归出口为
if (cnt == n * m)
{
ans++;
return;
}
#include <iostream>
using namespace std;
const int N = 10;
int n,m,x,y;
int ans;//表示结果
bool vis[N][N];//表示当前点是否被遍历过
int dir[8][2] = {{-1,-2},{1,-2},{-2,-1},{2,-1},{-2,1},{2,1},{-1,2},{1,2}};//方向数组
void dfs(int x,int y,int cnt)//cnt表示在棋盘上的第几个点
{
if (cnt == n * m)//递归出口
{
ans++;//棋盘被遍历完毕,方案数+1
return;//回溯
}
for (int i = 0;i < 8;i++)
{
int x1 = x + dir[i][0];
int y1 = y + dir[i][1];
//根据数据范围得x不能等于n,y不能等于m,vis[x1][y1] == 1表示该点已经被遍历过
if (x1 < 0 || y1 < 0 || x1 >= n || y1 >= m || vis[x1][y1] == 1) continue;
vis[x][y] = 1;//修改标记数组
dfs(x1,y1,cnt + 1);//遍历下一个点
vis[x][y] = 0;//恢复现场
}
}
int main()
{
int T;
cin >> T;
while (T--)
{
ans = 0;//多组数据,ans需要重置
cin >> n >> m >> x >> y;
dfs(x,y,1);//从第1个点开始遍历
cout << ans << endl;
}
return 0;
}
例5 N皇后问题
问题描述:同一行,同一列以及同一对角线(包括所有的主对角线和副对角线)只允许一个皇后存在
问题主体:需要解决的是找到每一行的皇后可以放置的位置/列号
以八皇后为例:
解题关键:设a[i]
表示第i
行的皇后放置于何处,a[i] = 3
则第i
行的皇后放置在第三列上
编写代码计算N皇后的摆法总数:
#include <iostream>
using namespace std;
const int N = 10;
int a[N];//a[N]表示第N行的皇后放在第a[N]列上,默认每行只放置一个皇后,初始时a[]全为0
int cnt = 0;//n个皇后一共有cnt种摆法
int n;
bool check(int x,int y)//验证皇后是否可以放置在第x行第y列上(以x = 5,y = 1为例)
{
//每行只放置一个皇后,故检查各列和主、副对角线即可
for (int i = 1;i <= x;i++)//枚举x之前的行即可
{
if (a[i] == y) return false;//此时i用来枚举行号,a[i] == y表示其余行的第y列有皇后
if (x + y == i + a[i]) return false;//副对角线上已有皇后
if (x - y == i - a[i]) return false;//主对角线上已有皇后
}
return true;//皇后可以放置在第x行第y列上
}
void dfs(int row)//row:行,表示在第row行寻找皇后的位置
{
if (row == n + 1)//递归出口
{
cnt++;
return;//回溯
}
for (int i = 1;i <= n;i++)//从第1行开始摆放皇后,可以摆放在1~9列,用i来枚举列
{
if (check(row,i))//如果可以把皇后放置在row行的i列
{
a[row] = i;//放置皇后于第i列
dfs(row + 1);//搜索下一行的皇后位置
a[row] = 0;//恢复现场
}
}
}
int main()
{
cin >> n;
dfs(1);//从第1行开始摆放皇后
cout << cnt;//输出n皇后的摆法数量
return 0;
}
练习:
N-皇后经典例题
例6 迷宫
测试样例
5 4
1 1 4 3
1 1 2 1
1 1 1 1
1 1 2 1
1 2 1 1
1 1 1 2
迷宫代码:
#include <iostream>
using namespace std;
const int N = 100;
int m,n;//地图有m行n列
int startx,starty;//表示起点坐标
int p,q;//(p,q)表示终点坐标
int ans = 0x3f3f3f3f;//ans表示最小的步数
int vis[N][N];//标记数组,0表示未被访问,1表示访问过
int a[N][N];//初始化地图,a[i][j] = 1表示空地,a[i][j] = 2表示障碍物
void dfs(int x,int y,int step)//初始点在x行y列,step表示当前走的步数
{
if (x == p && y == q)//到达终点
{
if (step < ans)
ans = step;
return;//回溯
}
if (a[x][y + 1] == 1 && vis[x][y + 1] == 0)//可以向右试探
{
vis[x][y + 1] = 1;//将右边的点标记为已访问
dfs(x,y + 1,step + 1);//向右深搜
vis[x][y + 1] = 0;//恢复现场
}
if (a[x + 1][y] == 1 && vis[x + 1][y] == 0)//可以向下试探
{
vis[x + 1][y] = 1;//将下边的点标记为已访问
dfs(x + 1,y,step + 1);//向下深搜
vis[x + 1][y] = 0;//恢复现场
}
if (a[x][y - 1] == 1 && vis[x][y - 1] == 0)//可以向左试探
{
vis[x][y - 1] = 1;//将左边的点标记为已访问
dfs(x,y - 1,step + 1);//向左深搜
vis[x][y - 1] = 0;//恢复现场
}
if (a[x - 1][y] == 1 && vis[x - 1][y] == 0)//可以向上试探
{
vis[x - 1][y] = 1;//将上边的点标记为已访问
dfs(x - 1,y,step + 1);//向上深搜
vis[x - 1][y] = 0;//恢复现场
}
}
int main()
{
cin >> m >> n;//读入地图的行数列数(5,4)
cin >> startx >> starty >> p >> q;//读入起点(1,1)和终点坐标(4,3)
for (int i = 1;i <= m;i++)//读入地图
for (int j = 1;j <= n;j++)
cin >> a[i][j];
vis[startx][starty] = 1;//将起点坐标标记已搜索
dfs(startx,starty,0);//从起点坐标开始搜索
cout << ans;
return 0;
}
使用循环改进代码:
#include <iostream>
using namespace std;
const int N = 100;
int m,n;//地图有m行n列
int startx,starty;//表示起点坐标
int p,q;//(p,q)表示终点坐标
int ans = 0x3f3f3f3f;//ans表示最小的步数
int vis[N][N];//标记数组,0表示未被访问,1表示访问过
int a[N][N];//初始化地图,a[i][j] = 1表示空地,a[i][j] = 2表示障碍物
int dx[4] = {0,1,0,-1};//方向数组
int dy[4] = {1,0,-1,0};
void dfs(int x,int y,int step)//初始点在x行y列,step表示当前走的步数
{
if (x == p && y == q)//到达终点
{
if (step < ans)
ans = step;
return;//回溯
}
for (int k = 0;k < 4;k++)
{
int new_x = x + dx[k];
int new_y = y + dy[k];
if (a[new_x][new_y] == 1 && vis[new_x][new_y] == 0)//是空地且未被访问,则可以试探
{
vis[new_x][new_y] = 1;//标记为已访问
dfs(new_x,new_y,step + 1);//深搜
vis[new_x][new_y] = 0;//恢复现场
}
}
}
int main()
{
cin >> m >> n;//读入地图的行数列数(5,4)
cin >> startx >> starty >> p >> q;//读入起点(1,1)和终点坐标(4,3)
for (int i = 1;i <= m;i++)//读入地图
for (int j = 1;j <= n;j++)
cin >> a[i][j];
vis[startx][starty] = 1;//将起点坐标标记已搜索
dfs(startx,starty,0);//从起点坐标开始搜索
cout << ans;
return 0;
}
例7 染色
DFS总结与模板
首先,以一个未被访问过的顶点作为起始顶点,沿当前顶点的边走到未访问过的顶点,当没有未访问过的顶点时,则回到上一个顶点,继续试探访问别的顶点,直到所有的顶点都被访问过。显然,深度优先遍历是沿着某一条分支遍历直到末端(到达边界),然后回溯,再沿着另一条进行同样的遍历,直到所有的顶点都被访问过为止.
理解DFS算法的关键:DFS在于解决"当下该如何做"。至于"下一步如何做"其实和"当下该如何做"是一样的过程
实现的方法一般是递归(自己调用自己)