深度优先搜索
深度优先搜索,简称dfs。我们可以将它跟递归联合在一起。
dfs与递归
先回顾一下递归。
我们使用递归完成斐波那契数列的计算:
int fib(int n){
if(n == 1 || n == 2){
return 1;
}
return fib(n - 1) + fib(n - 2);
}
以上递归实现斐波那契实际上就是按照深度优先的方式进行搜索。也就是 “一条路走到黑” 。注意:这里的搜索指的是一种穷举方式,把可行的方案都列举出来,不断尝试,直到找到问题的解。
以上即为Fib(5)的计算过程,我们发现实际上对应着一棵树,这棵树被称为搜索树。
深度优先搜索与递归的区别:
- 深度优先搜索是一种算法,更注重思想。
- 递归是一种基于编程语言的实现方式。
- 深度优先搜索可以使用递归实现!当然也就存在非递归的的方式实现搜索。
模板
参考了一个大神,模板原文链接如下:,这里总结一下。
深度优先搜索适合解决必须走到 最深处(例如对于树,须走到它的叶子节点) 才能得到一个解的问题。
每次递归开始的时候要判断是否达到收敛条件,若达到了则得到一个可行解,若没达到,则对当前状态进行扩展(扩展的时候通常会根据实际情况过滤掉一些非法的状态,这个过程叫 剪枝,适当的剪枝有时能极大地提高搜索的速度),如果要求输出具体解,则此时应该保存该状态,当扩展结束后,需要释放这个保存的状态。下面是一个深度优先搜索的编程模板:
/**
* dfs 模板.
* @param[in] input 输入数据指针
* @param[inout] cur or gap 标记当前位置或距离目标的距离
* @param[out] path 当前路径,也是中间结果
* @param[out] result 存放最终结果
* @return 路径长度,如果是求路径本身,则不需要返回长度
*/
void dfs(type *input, type *path, int cur or gap, type *result) {
if (数据非法) return 0; // 终止条件
if (cur == input.size( or gap == 0)) { // 收敛条件
将 path 放入 result
}
if (可以剪枝) return;
for(...) { // 执行所有可能的扩展动作
执行动作,修改 path
dfs(input, step + 1 or gap--, result);
恢复 path
}
}
模板应用(经典例题)
N皇后问题
这个题刚接触一定难倒不少人,但真的希望大家别怕,别去逃避,完完全全啃完这篇文章,这题算是秒杀了。
在一个 N × N 的棋盘上放置 N 个皇后,每行刚好放置一个并使其不相互攻击(同一行、同一列、同一斜线上的皇后都是自动攻击),求一共有多少种合法的方法放置 N 个皇后 。
输入格式:输入一个整数N,代表N个皇后。
输出格式:输出一个整数,表示有多少种放置皇后方法。
我们采用逐行放置,当行数为N+1行时,即为终止条件。
那我们如何判断某个位置是否能放置皇后呢?因为我们是逐行放置的,因此在行内是不会产生冲突的,于是我们采用三个数组来记录位置是否可用,分别对应了棋盘的 列、对角线1、对角线2 ,若可用,则标记为0,若不可用,则标记为1。最初的情况是棋盘上所有位置都是可用的,但在我们搜索的过程中,我们每次放置一个皇后的时候就需要占用一个位置,如果我们不对该位置进行标记(标记为已占有),则在下一步放置的过程中就有可能出现违规的情况(同一列、同一斜线)。解释这么多,就是想说明我们在迭代前,一定要修改 列、对角线1、对角线2 的标志!
需要注意的是,列我们可以通过坐标表示,那对角线如何表示呢?
于是,对于对角线1(从右上到左下),我们可以表示为 (行 + 列);同理,对于对角线2(从左上到右下)可以表示为(行 - 列 + n)。
于是我们可以写出代码:
//8皇后
#include<bits/stdc++.h>
using namespace std;
int n, ans;
bool vy[1005], vd1[1005], vd2[1005];
bool check(int x, int i){
return !vy[i] && !vd1[x + i] && !vd2[x - i + n];
}
void dfs(int x){
if(x == n){
//从0行开始的,所以当 x = n 时候就结束
ans++;
return;
}
for(int i = 0; i < n; i++){
//每一行内,对列情况讨论(每一个小 path)
if(check(x, i)){
//如果可以放置
vy[i] = true;
vd1[x + i] = true;
vd2[x - i + n] = true;
dfs(x + 1); //进行下一行搜素
//搜索完成后需要释放掉
vd1[x + i] = false;
vd2[x - i + n] = false;
vy[i] = false;
}
}
}
int main(){
scanf("%d", &n);
dfs(0);
printf("%d", ans);
return 0;
}
黑白N皇后问题
通过对N皇后的理解,我们对深度优先搜索的讨论以及有一定理解了,接下来看看两个皇后同时摆放的情况。
给定一个n × n 的棋盘,棋盘中有一些位置不能放皇后。现在要向棋盘中放入 n 个黑皇后和 n 个白皇后,使任意的两个黑皇后都不在同一行、同一列或同一斜线(正负斜线)上,任意两个白皇后都不在同一行、同一列或同一斜线(正负斜线)上。问共有多少种放法?n 小于等于8。
输入格式:输入第一行为一个整数 n,表示棋盘大小。接下来 n 行,每行 n 个 0 或 1 的整数,如果一个整数为1,表示对应的位置可以放皇后。如果一个整数为0,表示对应的位置不可以放皇后。
输出格式:输出一个整数,表示总共有多少种方法。
黑白N皇后问题实际上跟N皇后很相似,唯一不同的就是多加入了一个皇后。因此我们仅需按顺序解决即可。我们先统一解决黑皇后,然后再解决白皇后。又因为存在有些位置这不能放置的情况,因此在前面八皇后的三个用于判断是否能放置的数组需要进行修改。修改情况如下:
- 0:表示黑、白皇后都能放置
- 1:表示黑皇后不能放置
- 2:表示白皇后不能放置
- 3:表示黑、白皇后都不能放置
并且,我们对dfs函数需要做出调整。多加入一个参数,用于标记对黑皇后操作,还是对白皇后操作。
//黑白
#include<bits/stdc++.h>
using namespace std;
int n, ans;
int mp[10][10]; //保存棋盘
int vy[10], vd1[20], vd2[20]; //判断 列、正负对角线是否能放置。
bool check(int row, int x, int p){
return mp[row][x] && vy[x] != 3 && vy[x] != p && vd1[row + x] != 3 && vd1[row + x] != p && vd2[row - x + n] != 3 && vd2[row - x + n] != p;
}
void dfs(int row, int p){
if(row == n && p == 2){
//中止条件
ans++;
return;
}
if(row == n){
//收敛条件,此时黑皇后摆放完成,开始摆放白皇后
dfs(0, p + 1);
}
for(int i = 0; i < n; i++){
//对一个行中的每一列讨论
if(check(row, i, p)){
//如果p皇后能在这里放置
mp[row][i] = 0;
vy[i] += p;
vd1[row + i] += p;
vd2[row - i + n] += p;
dfs(row + 1, p); //进行下一行遍历,注意:这里的p并没有变化
//结束后要释放标记位
vy[i] -= p;
vd1[row + i] -= p;
vd2[row - i + n] -= p;
mp[row][i] = 1;
}
}
}
int main(){
scanf("%d", &n);
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++){
scanf("%d", &mp[i][j]);
}
}
dfs(0, 1); //从第0行,黑皇后开始
printf("%d", ans);
return 0;
}
木棍拼三角形问题
小A 手上有一些小木棍,它们长短不一,小A 想用这些木棍拼出一个等边三角形.并且每根木棍都要用到。例如,小A 手上有长度为 1 , 2 ,3 ,3 的 4 根木棍.他可以让长度为 1 , 2 的木棍组成一条边.另外 2 跟分别组成 2 条边,拼成一个边长为 3 的等边三角形。蒜头君希望你提前告诉他能不能拼出来,免得白费功夫。
输入格式:首先输入一个整数n。( 3 <=n <=20 ) ,表示木数量,接下来输入 n 根木棍的长度p( 1 <=p<= 10000 )。
输出格式:如果小A 能拼出等边三角形,输出” yes " ,否则输出“no”。
由于是等边三角形,因此边长可以一下就判断出来(sum / 3)。接下来,我们逐条边进行拼凑。完成一条边后再进行下一条边的寻找。终止条件是完成三条边的拼凑,因此我们在递归中需要一个参数来记录完成的边数,同时还需要一个参数来记录我们目前的边长和(用来匹配边长),还需要一个参数用来进行索引的推移。
//木棍
#include<bits/stdc++.h>
using namespace std;
int t[10010];
bool vs[10010];
bool f;
int n;
int sum = 0;
void dfs(int cnt, int tot, int index){
if(f) return;
if(cnt == 3){
//完成拼凑
f = true;
return;
}
if(tot == sum / 3){
//完成了一条边,进行下一条
dfs(cnt + 1, 0, 0);
}
for(int i = 0; i < n; i++){
if(!vs[i]){
//说明可用
vs[i] = true;
dfs(cnt, tot + t[i], i + 1);
vs[i] = false;
}
}
}
int main(){
scanf("%d", &n);
for(int i = 0; i < n; i++){
scanf("%d", &t[i]);
sum += t[i];
}
if(sum % 3 != 0){
//直接排除
printf("no\n");
return 0;
}else{
dfs(0, 0, 0);
}
if(f) printf("yes\n");
else printf("no\n");
return 0;
}
数独
是不是已经很有感觉了!其实深度优先搜索的题目并不难,关键找对递归的过程以及终止条件讨论好就行了。常用bool数组来作为访问过的标志。最后练一题。
标准数独是由一个给与了提示数字的 9 × 9 网格组成,我们只需将其空格填上数字,使得每一行,每一列以及每一个 3 × 3 宫都没有重复的数字出现。
输入格式:一个 9 × 9 的数独,数字之间用空格隔开,* 表示需要填写的数字。
输出格式:输出一个 9 × 9 的数独,把输入中的 * 换成需要填写的数字即可。
//数独
#include<bits/stdc++.h>
char s[10][10];
bool f; //是否找到
//vx[x][y]——x:第 x 行;y:1-9,有哪些被占用
//vy[x][y]——x:第 x 列;y:1-9,有哪些被占用
//vx[x][y]——x:第 x 个 3*3 的格子;y:1-9,有哪些被占用
bool vx[10][10], vy[10][10], vv[10][10]; //行、列、3*3 能填几
void dfs(int x, int y){
if(f) return;
if(x == 9){
f = true;
for(int i = 0; i < 9; i++){
for(int j = 0; j < 9; j++){
if(j != 8){
cout << s[i][j] << " ";
}else{
cout << s[i][j] << endl;
}
}
}
return;
}
//换行
if(y == 9){
dfs(x + 1, 0);
return;
}
//本来已经有占位的
if(s[x][y] != '*'){
dfs(x, y + 1);
return;
}
//填写的时候
for(int i = 1; i <= 9; i++){
//对填写内容加上限制
if(!vx[x][i] || !vy[y][i] || !vv[i / 3 * 3 + j / 3][i]){
s[x][y] = '0' + i;
vx[x][i] = true;
vy[y][i] = true;
vv[i / 3 * 3 + j / 3][i] = true;
dfs(x, y + 1);
vx[x][i] = false;
vy[y][i] = false;
vv[i / 3 * 3 + j / 3][i] = false;
s[x][y] = '*';
}
}
}
int main(){
//input
for(int i = 0; i < 9; i++){
for(int j = 0; j < 9; j++){
cin >> s[i][j];
}
}
for(int i = 0; i < 9; i++){
for(int j = 0; j < 9; j++){
if(s[i][j] != '*'){
vx[i][s[i][j] - '0'] = true;
vy[j][s[i][j] - '0'] = true;
vv[i / 3 * 3 + j / 3][s[i][j] - '0'] = true;
}
}
}
dfs(0, 0);
return 0;
}
那么到目前为止,我们练习了这么多题目了,是不是挺有感觉的了?下一步,我们来看看如何通过剪枝减少递归次数。
如何剪枝?
剪枝,顾名思义,就是通过一些判断,砍掉搜索树上不必要的子树。我们来看看几种不同的剪枝
可行性剪枝
有时候,我们会发现某个节点对应的子树的状态都不是我们想要的结果,那么我们其实没必要对每个分支都进行搜索,砍掉这个子树。
最优性剪枝
对于求最优解的一类问题,通常可以用最优性剪枝,比如在求迷宫最短路的时候,如果发现当前的步数已经超过了当前最优解,那么从当前状态开始的搜索都是多余的,因为这样搜索下去也找不到最优解了。通过剪枝,可以省去大量冗余的计算。
此外,对于可行性的问题,如前面例题中木棍拼三角形。一旦我们找到了一组可行解,就不再继续进行,这也算最优性剪枝。
重复性剪枝
对于某些特定的搜索方式,一些方案可能会被搜索很多次,这样是没必要的。
如这个问题:
给定个整数,要求选出
个数,使得选出来的
个数的和为
。
那么在这题中,如果搜索方法是每次从剩下的数里面选一个数,一共搜到第
层,那么 1,2, 3这三个选取方法能被搜索到 6 次,这是没必要的。因此我们只关注选出来的数的和,而根本不关注选出来的数的顺序。因此这里可以使用重复性剪枝。
我们规定选出来的数的位置是递增的,在搜索的时候,用一个参数来记录上一次选区的数的位置,那么此次我们从这个数之后开始选取,这样最后选出来的方案就不会重复了。
来看看例题
引爆炸弹
我们把能够相互引爆的炸弹放到同一个集合中,那么最终划分出集合的个数就是我们要求的答案。显然,引爆一个集合中任意一个炸弹造成的效果都是一样的,我们可以遍历
×
的方格中所有炸弹,如果当前炸弹没被引爆,那就引爆它,及标记它所在集合中的所有炸弹。
这里有一个剪枝,每行每列最多只搜索一遍,于是可以标记一下搜过的行、列,避免重复性搜索,这样每个格子都最多被检查两遍。
//炸弹
#include<bits/stdc++.h>
using namespace std;
char s[1005][1005];
bool vx[1005], vy[1005]; //表示第n行,和第n列 是否被访问过,因为访问过的话肯定都爆炸了。
int n, m, cnt;
void dfs(int x, int y){ //相当于进行一次引爆操作。
s[x][y] = '0';
if(!vx[x]){
vx[x] = true;
for(int i = 0; i < m; i++){
if(s[x][i] == '1'){
dfs(x, i);
}
}
}
if(!vy[y]){
vy[y] = true;
for(int i = 0; i < m; i++){
if(s[i][y] == '1'){
dfs(i, y);
}
}
}
}
int main(){
scanf("%d %d", &n, &m);
for(int i = 0; i < n; i++){
scanf("%s", &s[i]);
}
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++){
if(s[i][j] == '1'){
cnt++;
dfs(i, j);//引爆
}
}
}
printf("%d", cnt);
return 0;
}
那么到这里,其实DFS覆盖的内容就差不多了,剩下的细节还需要大家多多练习,刷题才是硬道理!