- 这是《算法笔记》的读书记录
- 本文参考自8.1节
一、引子
- 现在我们身处一个迷宫之中,可能很多人都听说过这个说法:只要每个岔路都走右手边的分支,就一定能走出去。下图是一个示例,从起点开始,每个分岔都走右边,最后找到了出口
- 这里其实就执行了深度优先搜索算法,我们可以这样理解:在岔路位置,如果不拐弯走直线,就看作维持在当前层面;如果拐弯了,就是走到迷宫更深处了。在每个岔路我们总是选择拐弯,总是以“深度”作为前进的关键,不碰到死胡同就不回头,因此称这种方式为深度优先搜索。
二、深度优先搜索DFS
-
定义:对每一个可能的分支路径深入到不能再深入为止,而且每个节点只能访问一次
-
深搜可以用栈实现:以引子的走迷宫为例
- ABD入栈
- H入栈,发现是死胡同,出栈回到D;类似的,I,J也先入栈再出栈回到D
- D后面都是死路,D出栈回到B
- E入栈
- …
-
虽然可以用栈实现,但是具体写的时候会很麻烦,所以通常使用递归实现DFS
- 递归式 = 岔道口
- 递归边界 = 死胡同
使用递归的时候,因为有函数的反复调用,编译器处理的时候会在内存中使用函数调用栈来存储每一层的状态,所以本质上还是栈实现
-
下面是一个递归计算斐波那契数列的例子,递归式为
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和回溯法很相似,主要区别在于:
- DFS是对搜索树的搜索过程,标准的DFS要走过整个树(实际是穷举了);回溯法一般要做剪枝。
- 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问题的解法,即给定一个序列,枚举这个序列的所有子集序列,从中选择一个 “最优” 子序列。