【算法笔记】8.1 深度优先搜索DFS

  • 这是《算法笔记》的读书记录
  • 本文参考自8.1节

一、引子

  • 现在我们身处一个迷宫之中,可能很多人都听说过这个说法:只要每个岔路都走右手边的分支,就一定能走出去。下图是一个示例,从起点开始,每个分岔都走右边,最后找到了出口
    在这里插入图片描述
  • 这里其实就执行了深度优先搜索算法,我们可以这样理解:在岔路位置,如果不拐弯走直线,就看作维持在当前层面;如果拐弯了,就是走到迷宫更深处了。在每个岔路我们总是选择拐弯,总是以“深度”作为前进的关键,不碰到死胡同就不回头,因此称这种方式为深度优先搜索。

二、深度优先搜索DFS

  • 定义:对每一个可能的分支路径深入到不能再深入为止,而且每个节点只能访问一次

  • 深搜可以用栈实现:以引子的走迷宫为例

    1. ABD入栈
    2. H入栈,发现是死胡同,出栈回到D;类似的,I,J也先入栈再出栈回到D
    3. D后面都是死路,D出栈回到B
    4. E入栈
  • 虽然可以用栈实现,但是具体写的时候会很麻烦,所以通常使用递归实现DFS

    1. 递归式 = 岔道口
    2. 递归边界 = 死胡同

    使用递归的时候,因为有函数的反复调用,编译器处理的时候会在内存中使用函数调用栈来存储每一层的状态,所以本质上还是栈实现

  • 下面是一个递归计算斐波那契数列的例子,递归式为f(n) = f(n-1) + f(n-2)

    #include<iostream>
    using namespace std;
    
    int func(int n)
    {
    	if(n==0 || n==1)
    		return 1;
    	return func(n-1)+func(n-2);
    }
    
    int main()
    {
    	int n;
    	cin>>n;
    	cout<<func(n)<<endl;	
    	return 0;
    }
    

    递归树如下
    在这里插入图片描述
    递归进行时,f(n-1)f(n-2)就相当于两个岔路口,我们一直沿f(n-1)这条路走到递归边界(死胡同),再回到上一层走f(n-2),可见这里也蕴含着DFS的思想

三、DFS和回溯

  • DFS和回溯法很相似,主要区别在于:

    1. DFS是对搜索树的搜索过程,标准的DFS要走过整个树(实际是穷举了);回溯法一般要做剪枝
    2. DFS不用保存记录访问过的状态(一般用全局变量),常会定义一个全局的结果,在每次满足递归结束条件时(叶子)刷新计算结果,这样最后只有一个输出;回溯法通常要保存访问过的状态(比如存一下走过的路线),回溯返回时要恢复标志(恢复标记正是回溯名词的由来)。
  • 现在也常常对DFS进行剪枝和记录状态,这种处理方法使得深度优先搜索法与回溯法没什么区别了,具体可以看下面 四.2 部分的背包题例子

  • DFS的一般模板

    void DFS(int 当前状态)  		//常有一个参数 i 代表当前层次
    {  
          if(当前状态为边界状态)  
          {  
            输出  
            return;  
          }  
          
          for(i=0;i<n;i++)    	//横向遍历解答树所有子节点  
          {  
               //扩展出一个子状态。  
               进行一些操作
               dfs(子状态)  		//常有参数i+1代表进入下一层
          }  
    }  
    
  • 回溯法的一般模板

    void DFS(int 当前状态)  			//常有一个参数 i 代表当前层次
    {  
          if(当前状态为边界状态)  
          {  
            记录或输出  
            return;  
          }  
          for(i=0;i<n;i++)       	//横向遍历解答树所有子节点  
          {  
               //扩展出一个子状态。  
               进行一些操作
    
               修改标志(全局变量)  
               if(子状态满足约束条件)	//加了判断就是做剪枝了,也可以不加
                  dfs(子状态)  		//常有参数i+1代表进入下一层
               恢复标志(全局变量)	//回溯部分  
          }  
    }  
    

四、例题

1. 搜索二叉树

  • 考虑DFS搜索一颗完全二叉树,从根节点开始,每一层看成在当前位置做一个二分选择,选左子树为0,右子树找为1,显示找到叶子时的所有路线
    #include<iostream>
    #include<cstring>
    #include<iomanip>
    using namespace std;
    
    const int N=5;		//选择数量(加上根,树的高度为N+1) 
    
    bool select[N]={0};	//记录路线		
    int cnt=0;			//路线计数					
    
    void DFS(int i)
    {
    	if(i==N)		//结束条件,找到叶子了
    	{
    		cout<<left<<setw(2)<<cnt++<<":"; 
    		for(int i=0;i<N;i++)    
    			cout<<select[i]<<" "; 
    		cout<<endl;     
    	
    		return;
    	} 
    
    	select[i]=0;	// 本层岔路走左边
    	DFS(i+1);		// 继续下一层
    						
    	select[i]=1;	// 本层岔路走右边
    	DFS(i+1);		// 继续下一层
    } 
    
    int main()
    {
    	DFS(0);			// 从根节点开始
    	return 0; 
    }
    
  • 输出
    0 :0 0 0 0 0
    1 :0 0 0 0 1
    2 :0 0 0 1 0
    3 :0 0 0 1 1
    4 :0 0 1 0 0
    5 :0 0 1 0 1
    6 :0 0 1 1 0
    7 :0 0 1 1 1
    8 :0 1 0 0 0
    9 :0 1 0 0 1
    10:0 1 0 1 0
    11:0 1 0 1 1
    ...
    31:1 1 1 1 1
    
  • 分析输出:第一条路线是一直走左边,第二条路线是最后一个岔路走右边,然后回到倒数第二层走右边…可以看出这是一个DFS的路线

2. 背包问题

  • 一共有N件物品,每件重w[i],价值c[i]。现在要选出若干物品放入一个容量为V的背包,使得在选入背包的物品重量和不超过容量V的前提下,使包中物品价值最高,求最大价值

  • 分析

    • “岔道口”(递归式):要不要把第i件物品放入背包
    • “死胡同”(递归边界):选择物品的质量超过V
  • 示例代码如下

    #include<iostream>
    #include<cstring>
    #include<iomanip> 
    using namespace std;
    
    
    const int V=8;							//容量限制 
    const int N=5;							//物品数量 
    const int w[N]={3,5,1,2,2};				//物品重量 
    const int c[N]={4,5,2,1,3};				//物品价值 
    
    /* 
    const int V=40;							//容量限制 
    const int N=10;							//物品数量 
    const int w[N]={1,2,3,4,5,6,7,8,9,10};	//物品重量 
    const int c[N]={6,7,8,9,10,1,2,3,4,5};	//物品价值 
    */
    
    bool select[N]={0};						//选择记录 
    int maxC=0;								//最大价值 
    int cnt=0;								//合法选择计数 
    
    void bufCopy(bool *ori,bool *copy,int n)
    {
    	for(int i=0;i<n;i++)
    		copy[i]=ori[i];
    }
    
    //在总质量sumW,总价值sumC的情况下,决定要不要加入第i件物品 
    void DFS(int i,int sumW,int sumC)
    {
    	//递归边界,已经决定了所有N件物品 
    	if(i==N)							
    	{
    		cout<<left<<setw(2)<<cnt++<<":"; 
    		for(int i=0;i<N;i++)    		//显示路线 
    			cout<<select[i]<<" ";
    		cout<<" |"; 
    		
    		for(int i=0;i<N;i++)			//显示选出的物品(不剪枝的情况就是全排列了) 
    		{
    			if(select[i])      
    				cout<<setw(2)<<i;
    			else
    				cout<<setw(2)<<" ";
    		}	
    			
    		cout<<" |"<<setw(4)<<sumC;	 	//显示总价值 
    		
    		if(sumW<=V)						//如果背包方得下,就显示OK 
    		{
    			cout<<setw(2)<<"OK";	
    			if(sumC>maxC)			
    				maxC=sumC;			
    		}	
    		
    		cout<<endl;
    		return;
    	} 
    	
    	//不加入第i件物品,处理第i+1件 
    	DFS(i+1,sumW,sumC);					
    	
    	//if(sumW+w[i]<=V)					//剪枝(这样留下的都是OK的) 
    	//{
    		bool save[N];
    		bufCopy(select,save,N);			//暂存当前选择列表select 
    		select[i]=1;
    		DFS(i+1,sumW+w[i],sumC+c[i]);	//选择物品 
    		bufCopy(save,select,N);			//恢复select列表 
    	//} 
    	
    } 
    
    int main()
    {
    	DFS(0,0,0);
    	cout<<endl<<"MAX:"<<maxC<<endl;
    	
    	return 0; 
    }
    
  • 输出结果

    0 :0 0 0 0 0  |           |0   OK
    1 :0 0 0 0 1  |        4  |3   OK
    2 :0 0 0 1 0  |      3    |1   OK
    3 :0 0 0 1 1  |      3 4  |4   OK
    4 :0 0 1 0 0  |    2      |2   OK
    5 :0 0 1 0 1  |    2   4  |5   OK
    6 :0 0 1 1 0  |    2 3    |3   OK
    7 :0 0 1 1 1  |    2 3 4  |6   OK
    8 :0 1 0 0 0  |  1        |5   OK
    9 :0 1 0 0 1  |  1     4  |8   OK
    10:0 1 0 1 0  |  1   3    |6   OK
    11:0 1 0 1 1  |  1   3 4  |9
    12:0 1 1 0 0  |  1 2      |7   OK
    13:0 1 1 0 1  |  1 2   4  |10  OK
    14:0 1 1 1 0  |  1 2 3    |8   OK
    15:0 1 1 1 1  |  1 2 3 4  |11
    16:1 0 0 0 0  |0          |4   OK
    17:1 0 0 0 1  |0       4  |7   OK
    18:1 0 0 1 0  |0     3    |5   OK
    19:1 0 0 1 1  |0     3 4  |8   OK
    20:1 0 1 0 0  |0   2      |6   OK
    21:1 0 1 0 1  |0   2   4  |9   OK
    22:1 0 1 1 0  |0   2 3    |7   OK
    23:1 0 1 1 1  |0   2 3 4  |10  OK
    24:1 1 0 0 0  |0 1        |9   OK
    25:1 1 0 0 1  |0 1     4  |12
    26:1 1 0 1 0  |0 1   3    |10
    27:1 1 0 1 1  |0 1   3 4  |13
    28:1 1 1 0 0  |0 1 2      |11
    29:1 1 1 0 1  |0 1 2   4  |14
    30:1 1 1 1 0  |0 1 2 3    |12
    31:1 1 1 1 1  |0 1 2 3 4  |15
    
    MAX:10
    
  • 可见,因为每种物品都有放入和不放入两种选择,而上述代码总是先找出所有可能的放置序列,再判断序列是否满足要求,因此n件物品的时间复杂度为 O ( 2 n ) O(2^n) O(2n)。这其实是一种 “暴力” 法。如果再去除记录选择的select数组,就完全成为一个标准的DFS

  • 利用背包容量这个限制条件,我们可以提前禁止某些分支。把上面代码if(sumW+w[i]<=V)这个注释取消,输出如下,可见限制过后所有输出一定是满足条件的了。这是一种 “剪枝” 方法,去除了递归树中的一些分支,提高了效率。也可也说这是回溯法

    0 :0 0 0 0 0  |           |0   OK
    1 :0 0 0 0 1  |        4  |3   OK
    2 :0 0 0 1 0  |      3    |1   OK
    3 :0 0 0 1 1  |      3 4  |4   OK
    4 :0 0 1 0 0  |    2      |2   OK
    5 :0 0 1 0 1  |    2   4  |5   OK
    6 :0 0 1 1 0  |    2 3    |3   OK
    7 :0 0 1 1 1  |    2 3 4  |6   OK
    8 :0 1 0 0 0  |  1        |5   OK
    9 :0 1 0 0 1  |  1     4  |8   OK
    10:0 1 0 1 0  |  1   3    |6   OK
    11:0 1 1 0 0  |  1 2      |7   OK
    12:0 1 1 0 1  |  1 2   4  |10  OK
    13:0 1 1 1 0  |  1 2 3    |8   OK
    14:1 0 0 0 0  |0          |4   OK
    15:1 0 0 0 1  |0       4  |7   OK
    16:1 0 0 1 0  |0     3    |5   OK
    17:1 0 0 1 1  |0     3 4  |8   OK
    18:1 0 1 0 0  |0   2      |6   OK
    19:1 0 1 0 1  |0   2   4  |9   OK
    20:1 0 1 1 0  |0   2 3    |7   OK
    21:1 0 1 1 1  |0   2 3 4  |10  OK
    22:1 1 0 0 0  |0 1        |9   OK
    
    MAX:10
    
  • 事实上,这个例子给出了一类常见DFS问题的解法,即给定一个序列,枚举这个序列的所有子集序列,从中选择一个 “最优” 子序列

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云端FFF

所有博文免费阅读,求打赏鼓励~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值