基础-深度优先搜索(DFS)

深度优先搜索(DFS)

1.1 定义

先说什么是搜索。搜索算法是利用计算机的高性能来有目的的穷举一个问题解空间的部分或所有的可能情况,从而求出问题的解的一种方法。在忽略效率的情况下,没有什么是搜索解决不了的(?
深度优先搜索(DFS)是基于递归的搜索。
想象你在走迷宫:
1、 这个方向有路可走,我没走过
2、 往这个方向前进
3、 是死胡同(到达了已经走过的地方),往回走,回到上一个路口
4、 重复第一步,直到找着出口
通过这种方式,只要地点的数量是有限的,你就一定可以在有限的时间找到迷宫的出口(或者确定这个迷宫没有出口)。
只要可以前进就往前走,这就是深度优先搜索的思路流程。

1.2 例题

1.2.1 全排列

题目:
输出1~n所有的全排列。
如1~3的全排列:
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
排列:一般地,从n个不同元素中取出m(m≤n)个元素,按照一定的顺序排成一列,叫做从n个元素中取出m个元素的一个排列(permutation)。特别地,当m=n时,这个排列被称作全排列(all permutation)。
思路:
搜索。
代码:

#include <stdio.h>
#define N 1001
int v[N],a[N],n;
void dfs(int t)
{
   if (t==n+1)
   {
   	for (int i=1;i<=n;++i) printf("%d ",a[i]);
   	printf("\n");
   	return;
   }
   for (int i=1;i<=n;++i)
   	if (!v[i])
   	{
   		a[t]=i,v[i]=1;
   		dfs(t+1);
   		v[i]=0;
   	}
}
int main()
{
   scanf("%d",&n);
   dfs(1);
}

分析:
为什么用搜索来完成这项工作?
假设n=3,我们可以用循环来简单的完成这份代码:

#include <stdio.h>
int n;
int main()
{
   for (int i=1;i<=3;++i)
   	for (int j=1;j<=3;++j)
   		if (i!=j)
   			for (int k=1;k<=3;++k)
   				if (k!=i&&k!=j) 
   					printf("%d %d %d\n",i,j,k);
}

那么当n=9时,我们虽然还是可以这样完成工作,但反正我不想写一写(
然而n是未知数,我们需要完成许多分这样功能上几乎没什么差异的代码。这非常不优美,也没有充分利用计算机擅长进行重复性工作的特长。而利用递归式的深搜算法就可以简单的实现1-n的全排列。
下面利用例2.2.1的代码对搜索算法的代码流程进行分析:

  • 递归的终止条件。 在这里插入图片描述
    我们的递归不能无限制的进行下去,需要一个终止条件,就像迷宫的出口(或者已经遍历了所有的地点)。在这个题中,递归的终止条件即已经确定了n个数。
    当我们已经确定了n个数时,我们输出一个排列,并停止继续寻找,返回上一层。
  • 遍历待选集合。
    在这里插入图片描述
    当我们执行到第十二行时,我们所知道的信息有:已经有t-1位数字确定了(同样有t-1个数字已经被使用过了),第t位数字一定是1~n中的某一个,而且不能是已经使用过的数字。v[i]存储了数字i是否已经被使用过的信息。
    我们需要确定第t位数字是谁。 在1到n中找到没有用过的数字i,i可以被放在位置t上。
  • 进入下一层递归。
    把i放在第t个位置上。 给i打上用过了的标记。 进入下一层递归dfs(t+1)。
    ?- 回溯。
    即还原回未前往改点时的状态。 取消i用过了的标记。 清空第t个位置。(在这个题里没有影响)

这样我们就可以得到搜索算法的结构框架:
在这里插入图片描述

1.2.2 细胞

题目:
一nm的矩形阵列由数字 0到 9组成(n和m不超过1000),数字 1到 9代表细胞,细胞的定义为沿细胞数字上下左右若还是细胞数字则为同一细胞,求给定矩形阵列的细胞个数。如以下410的矩阵中,共有4个细胞(已用不同底纹标明)。
0234500067103456050020456006710000000089

思路:
搜索。
一种非常常见的搜索题型,矩阵的遍历。周黑鸭那场比赛和我们两次半月赛的搜索题都是这种题型。即扫描整张地图,遇到符合要求的点就进行dfs拓展,覆盖所有通路,统计答案。
代码:

#include <stdio.h>
int m,n,t=0,b[1001][1001];
int dfs(int x,int y)
{
   b[x][y]=0;
   if (b[x+1][y]==1) dfs(x+1,y);
   if (b[x-1][y]==1) dfs(x-1,y);
   if (b[x][y+1]==1) dfs(x,y+1);
   if (b[x][y-1]==1) dfs(x,y-1);
}
int main()
{
   scanf("%d%d",&n,&m);
   for (int i=1;i<=n;++i)
       for (int j=1;j<=m;++j)
       {
       	char ch=getchar();
       	while (ch>'9'||ch<'0') ch=getchar();
       	if (ch!='0') b[i][j]=1;
       }
   for (int i=1;i<=n;++i)
       for (int j=1;j<=m;++j)
           if (b[i][j]==1) dfs(i,j),t++;
   printf("%d",t);
} 

深搜剪枝

2.1 定义

在搜索过程中,把不会产生答案的,或不必要的分支“剪掉”。

2.2 可行性剪枝

2.2.1 用剪枝思想看例题2.2.1

深度优先搜索的过程会产生一颗搜索树。
比如例2.2.1全排列中,n=3的情况产生的搜索树如下:
在这里插入图片描述

从上向下看,第一层为树的“根”,此时我们还没做出任何选择。第二层我们走向了三个分支,根红色的“儿子”表示我们选择的第一个数是1。然后我们沿着1继续往下走,在第三层确定了第二个数字。同样的,在第四层确定了第三个数字。最终我们到达了树的“叶子节点”,我们已经确定了三个数字。但我们还需要判断产生的答案是否合法。比如上图中的第一个分支:1 1 1,就是一个非法的答案。
但是我们的代码中在输出之前并没有判断答案是否合法,是我们的代码写错了吗?
在这里插入图片描述

当然不是。来看我们的代码:我们使用v数组记录了1~n中哪些元素被使用过了。在第13行中,当我们发现数字i已经被使用过时,我们便不会尝试将其放在第t个位置上。
也就是说,这棵搜索树的部分枝条被我们剪掉了。
在这里插入图片描述

继续走下去一定不可能得到合法解的分支被我们剪掉了,通向合法解的道路被我们保留了下来。
可以对比看上面的两张图,通过剪枝,我们需要探索的分支大大减少了,这就使得我们程序的效率显著提高。

2.2.2 旅游

题目:
一个规则矩形网络状的城市,城市中心坐标为(0,0)。 城市包含M个无法通行的路障(M<=50),采用如下规则游历城市:第一步走1格,第二步走2格,依此类推,第N步走n格(N<=20),除了第一步有四个方向可走,其余各步必须在前一步基础上左转或右转90度,最后回到出发点(0,0)。对于给定的N,M,编程求出所有可行的路径。
思路:

  1. 最朴素的想法:用x,y两个变量存储当前坐标。每一步对x,y的值进行修改,没有遇到障碍就继续走,走完n步看看有没有回到(0,0),没有的话回溯搜索,直到找完所有路径。
    分析这种算法的时间复杂度:一共走n步,每步要搜索四个方向,假设在最坏的情况下没有任何障碍物,那么时间复杂度应为O(4^n)。
  2. 很明显该算法效率很低,所以我们将对程序进行剪枝:在未走完n步之前就提早判断出这种走法是否可行。
    当走了t步时,假设当前坐标为(x[t],y[t]),那么离(0,0)的曼哈顿距离为x[t]+y[t],而剩下的n-t步可以走的最远距离则是(t+1)+(t+2)+……+n。所以,若(t+1)+(t+2)+……+n <x[t]+y[t]的话,就表示就算现在“回头”也没办法到达出发点了。此时,我们应该舍弃这一分支回溯。
  3. 除了上述的优化,还有没有其他的方法呢?
    我们可以这样想:这个城市是规则矩形网络状的。即东、南、西、北四个方向都是对称的。当从一个方向出发,寻找到一个解之后,将这个解旋转90°,180°,270°,就可以其余三个解,节省了3/4的搜索次数。
    由这个设想出发,我们可以设计出下面的优化:
    先不考虑障碍物,第一步固定走方向a,在这个基础上搜索路径,每找到一条路径都将其余三个“对称路径”一起判断,看看有没有经过障碍物,若没有则该路径为解之一.
  4. 通过以上分析,我们就可以得到一个效率较高的搜索程序了。

2.3 最优化剪枝

最优性剪枝,又称为上下界剪枝。在解最优解的问题时,当我们搜索到一个解,就会把这个解保存起来,用来和之后搜索到的解比较,选择其中最优的解。
这个较优解在算法中被称为“下界”,与此类似还有“上界”,在搜索中,如果已判断出这一分支的所有子节点都低于下界,或者高于上界,即该分支不可能出现比我们现持有的解更优的解,我们就可以将它剪枝。

2.3.1 贼

题目:
有n种物品,第 i 种有 i 个。每种物品的价值和重量相同。求小偷偷走总重量不超过m的物品的最大价值。
1 ≤ N ≤ 15,
1 ≤ M ≤ 1000000000 (10 9),
1 ≤ Wk, Ck ≤ 1000000000 (10 9).
思路:
看上去很像个背包,但是背不动(
搜索。由于Wk和Ck的数量级都达到了10^9,数组存不下。而n很小,所以考虑dfs暴搜加上剪枝。
先按性价比递减的顺序排序,即单位重量价值最大的排在最前面;
剪枝1:当前所选物品的总价值+所有剩下物品的价值 <= ans (前一次搜索时的最优解) ,则剪枝。
剪枝2:剩余重量全部装此时性价比最高的物品,即当前所搜pos的物品。装入后,其价值+sum<=ans,则剪枝。
搜索的顺序也要注意:搜索的时候枚举顺序注意一定要从满到空,这样更有利于最优性剪枝。
代码:

#include <stdio.h>
#include <algorithm>
#define maxx(a,b) (a>b?a:b)
using namespace std;
struct asdf{
   long long w,v;
   double x;
   int t;
}a[20];
long long v[20],ans,sum;
int n,m;
int cmp(asdf a,asdf b)    //按性价比降序排列
{
   return a.x>b.x;
}
void dfs(int pos,long long sum,long long left)
{
   ans=maxx(ans,sum);
   if (pos>n) return;
   if (sum+v[pos-1] <= ans) return;   //剪枝1
   if (sum+left*a[pos].x<=ans) return;  //剪枝2
   for (int k=a[pos].t;k>=0;k--)      //注意搜索顺序
   {
       if (left-k*a[pos].w<0) continue;
       dfs(pos+1,sum+k*a[pos].v,left-k*a[pos].w);
   }
}
int main()
{
   int T;
   for (scanf("%d",&T);T--;printf("%lld\n",ans),ans=0)
   {
       scanf("%d%d",&n,&m);
       for (int i=1;i<=n;i++) scanf("%lld",&a[i].w),a[i].t=i;
       for (int i=1;i<=n;i++) scanf("%lld",&a[i].v),a[i].x=1.0*a[i].v/a[i].w;
   	sort(a+1,a+n+1,cmp);
       v[n]=0;
       for (int i=n;i>=1;i--) v[i-1]=v[i]+a[i].t*a[i].v;
       dfs(1,0,m);
   }
   return 0;
}

2.3.2 取数

题目:
一个n*m(n,m不超过6)的由非负整数构成的数字矩阵,你需要在其中取出若干个数字,使得取出的任意两个数字不相邻(若一个数字在另外一个数字相邻8个格子中的一个即认为这两个数字相邻),求取出数字和最大是多少。
思路:
搜索:每次遍历整个二位数组,如果这个位置的数字可以选就选上,不可以选就不选(可行性剪枝),给被选的元素四周打上标记,进入下一层循环……直到没有数字可以选了,就放弃这个数,返回上一层。
过程中不断更新答案。
写法很多。
不推荐我的写法,代码写得太丑了……但是并不想换个思路再写一遍了……所以仅供参考(
代码:

#include <stdio.h>
#include <string.h>
int n,m,ans,sum;
int a[11][11];
int v[11][11];
void dfs(int x,int y)
{
   sum+=a[x][y];
   if (sum>ans) ans=sum;
   for (int i=-1;i<=1;++i)
   	for (int j=-1;j<=1;++j) v[x+i][y+j]++;
   for (int i=0;i<=1;++i)
   	for (int j=2;j<=3;++j) 
   	{
   		if ((x+i<=n&&y+j<=m)&&!v[x+i][y+j]) dfs(x+i,y+j);
   		if ((x+i<=n&&y-j>=1)&&!v[x+i][y-j]) dfs(x+i,y-j);
   		if (i!=0&&(x-i>=1&&y+j<=m)&&!v[x-i][y+j]) dfs(x-i,y+j);
   	}
   for (int i=2;i<=3;++i)
   	for (int j=0;j<=1;++j)
   	{
   		if ((x+i<=n&&y+j<=m)&&!v[x+i][y+j]) dfs(x+i,y+j);
   		if (j!=0&&(x+i<=n&&y-j>=1)&&!v[x+i][y-j]) dfs(x+i,y-j);
   		if ((x-i>=1&&y+j<=m)&&!v[x-i][y+j]) dfs(x-i,y+j);
   	}
   for (int i=-1;i<=1;++i)
   	for (int j=-1;j<=1;++j) v[x+i][y+j]--;
   sum-=a[x][y];
}
int main()
{
   int T;
   for (scanf("%d",&T);T--;ans=sum=0)
   {
   	scanf("%d%d",&n,&m);
   	memset(v,0,sizeof(v));
   	for (int i=1;i<=n;++i) 
   		for (int j=1;j<=m;++j) scanf("%d",&a[i][j]);
   	for (int i=1;i<=2&&i<=n;++i) 
   		for (int j=1;j<=2&&j<=m;++j) dfs(i,j);
   	printf("%d\n",ans);
   }
}

我的剪枝方案:
首先,数字矩阵由非负整数构成,所以矩阵中的所有元素最终一定被选择了或者不能被选择,绝不可能出现可以选但是不选的情况。由此可以得出,前两排前两列的元素中一定有且只有一个会被选中。所以我们只需从这四个元素开始DFS即可。
类似的,如果有一个元素已经被选中了,如下图所示:
在这里插入图片描述
那么以红色区域右侧的蓝色区域为例,如果这六个方块都没有被选,那么深蓝色方块就不可能被覆盖为不能选区域,那么就一定不会是最优解。所以在红色方块被选了的情况下,这六个方块中一定至少有一个数字会被计入最终答案。
事实上这个题代码写得好看一点不优化也能过(

2.4 记忆化搜索

把搜索获得的答案记录下来,以减少重复计算。
常用于统计方案数或者多组数据问题。

2.4.1 统计方案

题目:
在一无限大的二维平面中,我们做如下假设:
1、每次只能移动一格;
2、不能向后走(假设你的目的地是“向上”,那么你可以向左走,可以向右走,也可以向上走,但是不可以向下走);
3、走过的格子立即塌陷无法再走第二次。
求走n步不同的方案数(2种走法只要有一步不一样,即被认为是不同的方案)。n不超过20。
思路:
走n步的方案数=向上走一步再走n-1步的方案数+向左走一步再走n-1步的方案数+向右走一步再走n-1步的方案数。
需要记录一下是从哪个方向来的,从左边来就不能再向左走了,从右边来就不能再向右走了,从下边来往哪走都行。
代码及优化:

#include <stdio.h> 
long long dfs(int n,int x,int y,int f)
{
   if (n==0) return 1;
   long long ans=dfs(n-1,x,y+1,3);//任何情况下都可以向上走 
   if (f!=1) ans+=dfs(n-1,x+1,y,2);//从左边来的不能向左走 
   if (f!=2) ans+=dfs(n-1,x-1,y,1);//从右边来的不能向右走 
   return ans;
}
int main()
{
   int n,T;
   for (scanf("%d",&T);T--;)
   {
   	scanf("%d",&n);
   	printf("%lld\n",dfs(n,0,0,0));	
   }
   return 0; 
}

由思路可以得到这样一份代码,但是我们会发现它的效率非常低下,而且记录了许多冗余的信息。
首先,我们可以发现,当前处于什么位置(x,y)对接下来的答案没有什么影响。我们不必记录当前在什么位置,即可把代码简化如下:

#include <stdio.h> 
long long dfs(int n,int f)
{
   if (n==0) return 1;
   long long ans=dfs(n-1,3);//任何情况下都可以向上走 
   if (f!=1) ans+=dfs(n-1,2);//从左边来的不能向左走 
   if (f!=2) ans+=dfs(n-1,1);//从右边来的不能向右走 
   return ans;
}
int main()
{
   int n,T;
   for (scanf("%d",&T);T--;)
   {
   	scanf("%d",&n);
   	printf("%lld\n",dfs(n,0));	
   }
   return 0; 
}

然后我们会发现,代码做了许多重复的工作。
比如当我们希望计算当n=20的结果时,我们计算了195000+次dfs(5,1),而每一次执行的结果没有任何不同之处。
我们完全可以记录下本次计算结果,在下一次要求计算相同的东西时直接返回我们记录好的答案。

#include <stdio.h> 
long long a[21][5];
long long dfs(int n,int f)
{
   if (a[n][f]) return a[n][f];
   if (n==0) return 1;
   long long ans=dfs(n-1,3);//任何情况下都可以向上走 
   if (f!=1) ans+=dfs(n-1,2);//从左边来的不能向左走 
   if (f!=2) ans+=dfs(n-1,1);//从右边来的不能向右走 
   return a[n][f]=ans;
}
int main()
{
   int n,T;
   for (scanf("%d",&T);T--;)
   {
   	scanf("%d",&n);
   	printf("%lld\n",dfs(n,0));	
   }
   return 0; 
}

(由传参进行了打标记操作)。
这样我们对不同的n,f都只需要计算1次,代码的效率大大提高。当然,我们也可以求出递推式来递推求解,但是在某些情况下递推式很难整理,而且不用脑就可以解决问题显然更加令人高兴。
这种在递归过程中记录阶段性答案的算法叫记忆化搜索。

2.4.2 数的计算

题目:
我们要求找出具有下列性质数的个数(包含输入的正整n)。
先输入一个正整数 n(n≤1000),然后对此正整数按照如下方法进行处理:

  1. 不作任何处理;
  2. 在它的左边加上一个正整数,但该正整数不能超过原数的一半;
  3. 加上数后,继续按此规则进行处理,直到不能再加正整数为止。
    思路:
    递归思路非常显然。

然而T的也很显然(
加个记忆化。
代码:

#include <stdio.h>
long long qwq[1001];
long long num(int n)
{
   if (qwq[n]) return qwq[n];
   int ans=1;
   for (int i=1;i<=n/2;++i)
   	ans+=num(i);
   return qwq[n]=ans;
}
int main()
{
   int n;
   scanf("%d",&n);
   printf("%lld",num(n));
} 

2.5 搜索顺序剪枝

参考性价比之类的东西,见例3.3.1。

  • 0
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值