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

文章目录

  • ​​一、引子​​
  • ​​二、深度优先搜索DFS​​
  • ​​三、DFS和回溯​​
  • ​​四、例题​​
  • ​​1. 搜索二叉树​​
  • ​​2. 背包问题​​

一、引子

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

二、深度优先搜索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;
}

递归树如下

【算法笔记】8.1 深度优先搜索DFS_递归


递归进行时,​​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件物品的时间复杂度为【算法笔记】8.1 深度优先搜索DFS_DFS_02。这其实是一种 “暴力” 法。如果再去除记录选择的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问题的解法,即给定一个序列,枚举这个序列的所有子集序列,从中选择一个 “最优” 子序列