设想我们现在以第一视角身处一个巨大的迷宫当中,没有上帝视角,没有通信设施,更没有热血动漫里的奇迹,有的只是四周长得一样的墙壁。于是,我们只能自己想办法走出去。
如果迷失了内心,随便乱走,那么很可能被四周完全相同的景色绕晕在其中,这时只能放弃所谓的侥幸,而去采取下面这种看上去很盲目但实际上会很有效的方法。
以当前所在位置为起点,沿着一条路向前走,当碰到岔道口时,选择其中一个岔路前进。
如果选择的这个岔路前方是一条死路,就退回到这个岔道口,选择另一个岔路前进。如果岔路中存在新的岔道口,那么仍然按上面的方法枚举新岔道口的每一条岔路。这样,只要迷宫存在出口,那么这个方法一定能够找到它。
可能有读者会问,如果在第一个岔道口处选择了一条没有出路的分支,而这个分支比较深,并且路上多次出现新的岔道口,那么当发现这个分支是个死分支之后,如何退回到最初的这个岔道口?
其实方法很简单,只要让右手始终贴着右边的墙壁一路往前走,那么自动会执行上面这个走法,并且最终一定能找到出口。下图即为使用这个方法走一个简单迷宫的示例。
从上图可知,从起点开始前进,当碰到岔道口时,总是选择其中一条岔路前进(例如图中总是先选择最右手边的岔路),在岔路上如果又遇到新的岔道口,仍然选择新岔道口的其中一条岔路前进,直到碰到死胡同才回退到最近的岔道口选择另一条岔路。
也就是说,当碰到岔道口时,总是以"深度"作为前进的关键词,不碰到死胡同就不回头,因此把这种搜索的方式称为深度优先搜索(Depth First Search,DFS)。
从迷宫的例子还应该注意到,深度优先搜索会走遍所有路径,并且每次走到死胡同就代表一条完整路径的形成。这就是说,深度优先搜索是一种枚举所有完整路径以遍历所有情况的搜索方法。
那么如何来实现深度优先搜索(DFS)呢?
在上图中,把迷宫中的关键结点(岔道口或死胡同)用字母代替,然后来看看在DFS的过程中是如何体现在这些关键结点上的(已经在图中标记了字母):
① 从第一条路可以得到先后访问的结点为 ABDH,此时 H 到达了死胡同,于是退回到D;再到 I ,但是 I 也是死胡同,再次退回到 D;接着来到 J ,很不幸 J 还是死胡同,于是退回到 D ,但是此时 D 的岔路已经走完了,因此退回到上一个岔道口 B。
② 从 B 到达 E ,接下来又是三条岔路(K、L、M),依次进行枚举;前往 K ,发现 K 是死胡同,退回到 E;前往 L ,发现 L 是死胡同,退回到 E ;前往 M,发现 M 是死胡同,退回到 E 。
最后因为 E 的岔路都访问完毕了,于是退回到 B 。但是 B 的所有岔路( D 和 E)也都访问完了,因此退回到 A。
③ 访问 A 的另一个岔路可以到达 C ,而 C 仍然有两条岔路( F 和 G ),于是先访问 F ,发现 F 是死胡同,退回到 C ;再访问 G ,发现是出口,DFS 过程结束,整个 DFS 过程中先后访问结点的顺序为 ABDHIJEKLMCFG 。
这个过程是不是和出栈入栈的过程很相似?
第一步,ABD 入栈,然后把 D 的三条岔路 HIJ 先后入栈和出栈,再把 D 出栈;第二步和第三步执行与第一步类似的操作,我们不妨自己模拟出栈与入栈的过程。
由此可以知道如何去实现深度优先搜索;先对问题进行分析,得到岔道口和死胡同;
再定义一个栈,以深度为关键词访问这些岔道口和死胡同,并将它们入栈;而当离开这些岔道口和死胡同时,将它们出栈。
因此,深度优先搜索(DFS)可以使用栈来实现。
这听起来很容易,但是实现起来却并不轻松,有没有既容易理解又容易实现的方法呢?
有的——递归。
现在从 DFS 的角度来看求解 Fibonacci 数列的过程。
回顾一下 Fibonacci 数列的定义:F(0)=1,F(1)=1,F(n)=F(n-1)+F(n-2)(n≥2)。
可以从这个定义中挖掘到,每当将 F(n) 分为两部分 F(n-1) 与 F(n-2) 时,就可以把 F(n) 看作迷宫的岔道口,由它可以到达两个新的关键结点 F(n-1) 与 F(n-2) 。
而之后计算 F(n-1) 时,又可以把 F(n-1) 当作在岔道口 F(n) 之下的岔道口。
既然有岔道口,那么一定有死胡同。很容易想象,当访问到 F(0) 和 F(1) 时,就无法再向下递归下去,因此 F(0) 和 F(1) 就是死胡同。
这样说来,递归中的递归式就是岔道口,而递归边界就是死胡同,这样就可以把如何用递归实现深度优先搜索的过程理解得很清楚。
为了使上面的过程更清晰,可以直接来分析递归图,如下图:
可以在递归图中看到,只要 n>1,F(n) 就有两个分支,即把 F(n) 当作岔道口;而当 n为 1 或 0 时,F(1) 与 F(1) 就是迷宫的死胡同,在此处程序就需要返回结果。这样当遍历完所有路径(从顶端的 F(4)到底层的所有F(1)与 F(0))后,就可以得到 F(4)的值。
因此,使用递归可以很好地实现深度优先搜索。
这个说法并不是说深度优先搜索就是递归,只能说递归是深度优先搜索的一种实现方式,因为使用非递归也是可以实现 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。
下面的代码体现了上面的思路,请注意 “岔道口” 和 “死胡同” 在代码中是如何体现的:
#include <cstdio> 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 // 5 件物品,背包容量为 8
3 5 1 2 2 //重量分别为 3 5 1 2 2
4 5 2 1 3 //价值分别为 4 5 2 1 3
输出结果:
10
可以注意到,由于每件物品有两种选择,因此上面代码的复杂度为O(2n),这看起来不是很优秀。
但是可以通过对算法的优化,来使其在随机数据的表现上有更好的效率。
在上述代码中,总是把 n 件物品的选择全部确定之后才去更新最大价值,但是事实上忽视了背包容量不超过 V 这个特点。
也就是说,完全可以把对 sumW 的判断加入 “岔道口” 中,只有当 sumW≤V 时才进入岔道,这样效率会高很多,代码如下:
void DFS(int index,int sumW,int sumC)
{
if(index==n)
{
return; //已经完成对n件物品的选择
}
DFS(index+1,smW,sumC); //不选第 index件物品
//只有加入第 index 件物品后未超过容量 V,才能继续
if(sumW+w[index]<=V)
{
if(sumC+c[index]>ans)
{
ans=sumC+c[index]; //更新最大价值maxValue
}
DFS(index+1,sumW+w[index],sumC+c[index]);//选第index件物品
}
}
可以看到,原先第二条岔路是直接进入的,但是这里先判断加入第 index 件物品后能否满足容量不超过 V 的要求,只有当条件满足时才更新最大价值以及进入这条岔路,这样可以降低计算量,使算法在数据不极端时有很好的表现。
这种通过题目条件的限制来节省 DFS 计算量的方法称作剪枝(前提是剪枝后算法仍然正确)。
剪枝是一门艺术,学会灵活运用题目中给出的条件,可以使得代码的计算量大大降低,很多题目甚至可以使时间复杂度下降好几个等级。
事实上,上面的这个问题给出了一类常见 DFS 问题的解决方法,即给定一个序列,枚举这个序列的所有子序列(可以不连续)。
例如对序列{1,2,3}来说,它的所有子序列为{1}、{2}、{3}、{1,2}、{1,3}、{2,3}、{1,2,3}。
枚举所有子序列的目的很明显—可以从中选择一个"最优"子序列,使它的某个特征是所有子序列中最优的;如果有需要,还可以把这个最优子序列保存下来。
显然,这个问题也等价于枚举从 N 个整数中选择 K 个数的所有方案。
例如这样一个问题:给定 N 个整数(可能有负数),从中选择 K 个数,使得这 K 个数之和恰好等于一个给定的整数 X;
如果有多种方案,选择它们中元素平方和最大的一个。数据保证这样的方案唯一。例如,从4个整数{2,3.3.4}中选择2个数,使它们的和为6,显然有两种方案 {2,4} 与 {3,3} ,其中平方和最大的方案为 {2,4} 。
与之前的问题类似,此处仍然需要记录当前处理的整数编号 index;由于要求恰好选择 K 个数,因此需要一个参数 nowK 来记录当前已经选择的数的个数;另外,还需要参数 sum 和 sumSqu 分别记录当前已选整数之和与平方和。
于是 DFS 就是下面这个样子:
void DFS(int index,int nowK,int sum,int sumSqu){...}
此处主要讲解如何保存最优方案,即平方和最大的方案。
首先,需要一个数组 temp,用以存放当前已经选择的整数。
这样,当试图进入"选 index 号数" 这条分支时,就把 A[index] 加入 temp 中;
而当这条分支结束时,就把它从 temp 中去除,使它不会影响 “不选 index 号数” 这条分支。
接着,如果在某个时候发现当前已经选择了 K 个数,且这 K 个数之和恰好为 x 时,就去判断平方和是否比已有的最大平方和 maxSumSqu 还要大:
如果确实更大,那么说明找到了更优的方案,把 temp 赋给用以存放最优方案的数组 ans。
这样,当所有方案都枚举完毕后,ans 存放的就是最优方案,maxSumSqu 存放的就是对应的最优值。
下面给出了主要部分的代码,建议大家能完整理解并自行写出:
//序列 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);
}
上面这个问题中的每个数都只能选择一次,现在稍微修改题目;假设 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])
即可。