深度优先搜索遍历类似于树的先根遍历,是树的先根遍历的推广,在算法竞赛和考试中经常用到,在优化图算法时,也非常方便,就像是在走迷宫过程中,当碰到岔路口时“深度”作为前进的关键词,不碰到死胡同就不回头,因此把这种搜索的方式称为深度优先搜索(Depth First Search, DFS)。
深度优先搜索会走遍所有路径,并且每次走到死胡同就代表一条完整路径的形成。这就是说,深度优先搜索是一种枚举所有完整路径以遍历所有情况的搜索方法。
使用递归可以很好地实现深度优先搜索。当然也可以使用非递归的方法实现DFS,但是非递归的方法一般情况下会比递归的方法要麻烦。在使用递归的时候,系统还会调用一个叫系统栈的东西来存放递归中的每一层状态,因此使用递归来实现DFS的本质其实还是栈。
接下来讲解一个例子,读者需要从中理解其中包含的DFS思想,并尝试学习写出本例的代码。
有n件物品,每件物品的重量为w[i],价值为c[i]。现在需要选出若干件物品放入一个容量为V的背包中,使得在选入背包的物品重量和不超过容量V的前提下,让背包中物品的价值之和最大,求最大价值。(1≤n≤20)
在这个问题中,需要从n件物品中选择若干件物品放入背包,使它们的价值之和最大。这样的话,对每件物品都有选或者不选两种选择,而这就是所谓的“岔道口”。那么什么是“死胡同”呢?——题目要求选择的物品重量总和不能超过V,因此一旦选择的物品重量总和超过V,就会到达“死胡同”,需要返回最近的“岔道口”。
显然,每次都要对物品进行选择,因此DFS函数的参数中必须记录当前处理的物品编号index。而题目中涉及了物品的重量与价值,因此也需要参数来记录在处理当前物品之前,已选物品的总重量sumW与总价值sumC。于是DFS函数看起来是这个样子:
void DFS(int index,int sumW,int sumC){
...
}
于是,如果选择不放入index号物品,那么sumW与sumC就将不变,接下来处理 index+1号物品,即前往DFS(index+1,sumW,sumC)这条分支;而如果选择放入index号物品,那么sumW将增加当前物品的重量 w[index],sumC将增加当前物品的价值c[index],接着处理index+1号物品,即前往DFS(index+1,sumW+w[index],sumC+c[index])这条分支。
一旦index增长到了n,则说明已经把n件物品处理完毕(因为物品下标为从0到n-1),此时记录的sumW和sumC就是所选物品的总重量和总价值。如果sumW不超过V且sumC大于一个全局的记录最大总价值的变量 maxValue,就说明当前的这种选择方案可以得到更大的价值,于是用sumC更新maxValue。
下面的代码体现了上面的思路,请注意“岔道口”和“死胡同”在代码中是如何体现的:
const int maxn = 30;
int n,V,maxValue = 0; //物品件数n,背包容量v,最大价值maxValue
int w[maxn],c[maxn]; //w[i]为每一件物品的重量,c[i]为每件物品的价值
//DFS,index为当前处理的物品编号
//sumW和sumC分别为当前总重量和当前总价值
void DFS(int index,int sumW,int sumC) {
if(index == n){ //已经完成对n件物品的选择(死胡同)
if(sumW<=V&&sumC>maxValue) {
maxValue = sumC; //不超过背包容量时更新最大价值maxValue
}
return;
}
//岔道口
DFS(index+1,sumW,sumC); //不选第index件物品
DFS(index+1,sumW+w[index],sumC+c[index]); //选第index件物品
}
int main(){
scanf("%d%d",&n,&V);
for(int i=0;i<n;i++){
scanf("%d",&w[i]); //每件物品的重量
}
for(int i=0;i<n;i++){
scanf("%d",&c[i]); //每件物品的价值
}
DFS(0,0,0); //初始化为第0件物品、当前总重量和总价值均为0
printf("%d\n",maxValue);
return 0;
}
输入数据:
5 8
3 5 1 2 2
4 5 2 1 3
输出结果:
可以注意到,由于每件物品有两种选择,因此上面代码的复杂度为O(2),这看起来不是很优秀。但是可以通过对算法的优化,来使其在随机数据的表现上有更好的效率。在上述代码中,总是把n件物品的选择全部确定之后オ去更新最大价值,但是事实上忽视了背包容量不超过V这个特点。也就是说,完全可以把对sumW的判断加入“岔道口”中,只有当sumW≤v时才进入岔道,这样效率会高很多,代码如下,只更改了DFS:
const int maxn = 30;
int n,V,maxValue = 0; //物品件数n,背包容量v,最大价值maxValue
int w[maxn],c[maxn]; //w[i]为每一件物品的重量,c[i]为每件物品的价值
//DFS,index为当前处理的物品编号
//sumW和sumC分别为当前总重量和当前总价值
void DFS(int index,int sumW,int sumC) {
if(index == n){ //已经完成对n件物品的选择(死胡同)
return;
}
//岔道口
DFS(index+1,sumW,sumC); //不选第index件物品
//只有加入第index件物品后未超出容量V,才能继续
if(sumW+w[index]<=V) {
if(sumC+c[index]>maxValue)
maxValue = sumC+c[index]; //更新最大价值maxValue
}
DFS(index+1,sumW+w[index],sumC+c[index]); //选第index件物品
}
int main(){
scanf("%d%d",&n,&V);
for(int i=0;i<n;i++){
scanf("%d",&w[i]); //每件物品的重量
}
for(int i=0;i<n;i++){
scanf("%d",&c[i]); //每件物品的价值
}
DFS(0,0,0); //初始化为第0件物品、当前总重量和总价值均为0
printf("%d\n",maxValue);
return 0;
}
可以看到,原先第二条岔路是直接进入的,但是这里先判断加入第index件物品后能否满足容量不超过V的要求,只有当条件满足时才更新最大价值以及进入这条岔路,这样可以降低计算量,使算法在数据不极端时有很好的表现。这种通过题目条件的限制来节省DFS计算量的方法称作剪枝(前提是剪枝后算法仍然正确)。剪枝是一门艺术,学会灵活运用题目中给出的条件,可以使得代码的计算量大大降低,很多题目甚至可以使时间复杂度下降好几个等级。
例如这样一个问题: 给定N个整数(可能有负数),从中选择K个数,使得这K个数之和恰好等于一个给定的整数X:如果有多种方案,选择它们中元素平方和最大的一个。数据保证这样的方案唯一。例如,从4个整数{2,3,3,4}中选择2个数,使它们的和为6,显然有两种方案{2,4}与(3,3},其中平方和最大的方案为{2,4}。
与之前的问题类似,此处仍然需要记录当前处理的整数编号index;由于要求恰好选择K个数,因此需要一个参数nowK来记录当前已经选择的数的个数;另外,还需要参数sum和sumSqu分别记录当前已选整数之和与平方和。于是DFS就是下面这个样子:
void DES(int index,int nowK,int sum, int sumSqu){
...
}
此处主要讲解如何保存最优方案,即平方和最大的方案。首先,需要一个数组temp,用以存放当前已经选择的整数。这样,当试图进入“选index号数”这条分支时,就把A[index]加入temp中;而当这条分支结束时,就把它从temp中去除,使它不会影响“不选 index号数”这条分支。接着,如果在某个时候发现当前已经选择了K个数,且这K个数之和恰好为x时,就去判断平方和是否比已有的最大平方和 maxSumSqu还要大:
如果确实更大,那么说明找到了更优的方案,把temp赋给用以存放最优方案的数组ans。这样,当所有方案都枚举完毕后,ans存放的就是最优方案, maxSumSqu存放的就是对应的最优值
输入:
4 2 6
2 3 3 4
输出:
2 4
程序代码:
using namespace std;
const int maxn = 30;
//序列A中n个数选k个数使得和为x,最大平方和为maxSumSqu
int n,k,x,maxSumSqu = -1,A[maxn];
//temp存放临时方案,ans存放平方和最大的方案
vector<int> temp, ans;
//当前处理index号整数,当前已选取的整数个数时nowK
//当前已选整数之和为sum,当前已选整数平方和为sumSqu
void DFS(int index,int nowK,int sum,int sumSqu){
if(nowK == k && sum == x){ //找到k个数的和为x
if(sumSqu>maxSumSqu){ //如果比当前找到更优
maxSumSqu = sumSqu; //更新最大平方和
ans = temp; //更新最优方案
}
return;
}
//已经处理完n个数,或者超过k个数,或者和超过x,返回
if(index == n||nowK>k||sum>x)
return ;
//选index号数
temp.push_back(A[index]);
DFS(index+1,nowK+1,sum+A[index],sumSqu+A[index]*A[index]);
temp.pop_back();
//不选index号数
DFS(index+1,nowK,sum,sumSqu);
}
int main(){
scanf("%d%d%d",&n,&k,&x);
for(int i=0;i<n;i++){
scanf("%d",&A[i]);
}
DFS(0,0,0,0);
for(vector<int>::iterator it = ans.begin();it!=ans.end();it++){
printf("%d ",*it);
}
return 0;
}
运行结果:
上面这个问题中的每个数都只能选择一次,现在稍微修改题目:假设N个整数中的每个都可以被选择多次,那么选择K个数,使得K个数之和恰好为X。例如有三个整数1、4、7,需要从中选择5个数,使得这5个数之和为17。显然,只需要选择3个1和2个7,即可得到17。
这个问题只需要对上面的代码进行少量的修改即可。由于每个整数都可以被选择多次,因此当选择了 index 号数时,不应当直接进入 index+1 号数的处理。显然,应当能够继续选择 index 号数,直到某个时刻决定不再选择 index 号数,就会通过“不选 index号数”这条分支进入 index+1 号数的处理。因此只需要把“选index号数”这条分支的代码修改为DFS(index,nowK+1,sum+A[index],sumSqu+A[index]*A[index]); 即可。
程序代码:
using namespace std;
const int maxn = 30;
//序列A中n个数选k个数使得和为x,最大平方和为maxSumSqu
int n,k,x,maxSumSqu = -1,A[maxn];
//temp存放临时方案,ans存放平方和最大的方案
vector<int> temp, ans;
//当前处理index号整数,当前已选取的整数个数时nowK
//当前已选整数之和为sum,当前已选整数平方和为sumSqu
void DFS(int index,int nowK,int sum,int sumSqu){
if(nowK == k && sum == x){ //找到k个数的和为x
if(sumSqu>maxSumSqu){ //如果比当前找到更优
maxSumSqu = sumSqu; //更新最大平方和
ans = temp; //更新最优方案
}
return;
}
//已经处理完n个数,或者超过k个数,或者和超过x,返回
if(index == n||nowK>k||sum>x)
return ;
//选index号数
temp.push_back(A[index]);
DFS(index,nowK+1,sum+A[index],sumSqu+A[index]*A[index]);
temp.pop_back();
//不选index号数
DFS(index+1,nowK,sum,sumSqu);
}
int main(){
scanf("%d%d%d",&n,&k,&x);
for(int i=0;i<n;i++){
scanf("%d",&A[i]);
}
DFS(0,0,0,0);
for(vector<int>::iterator it = ans.begin();it!=ans.end();it++){
printf("%d ",*it);
}
return 0;
}
运行结果:
微信扫码关注李歘歘,获取更多的学习资料,联系作者: