深度优先搜索的优化技巧
1、优化搜索顺序
在一些搜索问题中,搜索树的各个层次,各个分支之间的顺序不是固定的。不同的搜索顺序会产生不同的搜索树形态,其规模大小也相差甚远。
2、排除等效冗余
在搜索过程中,如果我们能够判定从搜索树的当前节点上沿着某几条不同分支到达的子树是等效的,那么只需要对其中的一条分支执行搜索。
3、可行性剪枝
在搜索过程中,及时对当前状态进行检查,如果发现分支已经无法到达递归边界,就执行回溯。这好比我们在道路上行走时,远远看到前方是一个死胡同,就应该立即折返绕路,而不是走到路的尽头再返回。
某些题目条件的范围限制是一个区间,此时可行性剪枝也被称为”上下界剪枝“
4、最优化剪枝
在最优化问题的搜索过程中,如果当前花费的代价已经超过当前搜到的最优解,那么无论采取多么优秀的策略到达递归边界,都不可能更新答案。此时可以停止对当前分支的搜索,执行回溯。
5、记忆化
可以记录每个状态的搜索结果,在重复遍历一个状态时直接检索并返回。这好比我们对图进行深度优先遍历时标记一个节点是否已经被访问过。
【例题1】数的划分(可行性剪枝,上下界剪枝)
题目描述
输入
输出
样例输入
7 3
样例输出
4
【思路】:
本题就是求把数字n无序划分成k份的方案数。也就是求方程x1+x2+……+xk = n,1<=x1<=x2<=……xk的解数。
搜索的方法是依次枚举x1,x2……xk的值,然后判断。如果这样直接搜索,程序的运行速度是非常慢的。但由于本题的数据规模比较小,如果控制好扩展结点的“上界”和“下界”,也是能够很快得出解的。
约束条件:
1、由于分解数不考虑顺序,因此我们设定分解数依次递增,所以扩展结点时的“下界”应是不小于前一个扩展结点的值,即a[i-1]<=a[i]
2、假设我们将n已经分解成了a[1]+a[2]+……+a[i-1],则a[i]的最大值为将i~k这k-i+1份平均划分,即设m=n-(a[1]+a[2]+……+a[i-1]),则a[i]<=m/(k-i+1),所以扩展结点的“上界”为 m/(k-i+1)
1 #include2 using namespace std; 3 int n,m,ans; 4 int a[10]; 5 void dfs(int k){ //分第k份 6 if( n==0 ) return ; 7 if( k == m ) { 8 if( n >= a[k-1] ) 9 ans ++; 10 return ; 11 } 12 for (int i=a[k-1];i<=n/(m-k+1);i++){ //第k份的上下界 13 a[k] = i ; //第k份的值 14 n-=i; 15 dfs(k+1); 16 n+=i; 17 } 18 } 19 int main() 20 { 21 scanf("%d%d",&n,&m); 22 a[0] = 1 ; //初始值起步为1 23 dfs(1); 24 printf("%d\n",ans); 25 return 0; 26 }
【例题2】生日蛋糕
题目描述
设从下往上数第i(1<=i<=M)层蛋糕是半径为Ri, 高度为Hi的圆柱。当i
由于要在蛋糕上抹奶油,为尽可能节约经费,我们希望蛋糕外表面(最下一层的下底面除外)的面积Q最小。
令Q= Sπ,请编程对给出的N和M,找出蛋糕的制作方案(适当的Ri和Hi的值),使S最小。(除Q外,以上所有数据皆为正整数)
输入
输出
样例输入
100
2
样例输出
68
提示
体积V=πR2H
侧面积A’=2πRH
底面积A=πR2
【思路】:
搜索框架,从下往上搜索,(最上面的一层是第1层),从下往上搜索,枚举搜索面对的状态有:正在搜索蛋糕第dep层,当前外表面面积S,当前体积V,第dep+1层的高度和半径。不妨用数组h和r分别记录每层的高度和半径。
整个蛋糕的“上表面”面积之和等于最底层的圆面积,可以在第M层直接累加到S中,这样,第M-1层往上的搜索,只需要计算侧面积。
剪枝:
1、上下界剪枝:
在第dep层时,只在下面的范围内枚举半径和高度即可。
首先枚举R∈[ dep , min( {sqrt(N-V)} , R[dep+1] - 1 ) ]
其次枚举H∈[ dep , min( { (N-V)/ R2 }, h[dep+1]-1) ]
上面两个区间右边界中的式子可以通过圆柱体积公式 πR2H = π(N-v)得到
2、最优化搜索:
在上面确定的范围中,使用倒序枚举。
3、可行性剪枝:
可以预处理出从上往下前i(1≤i≤M)层的最小体积和侧面积,显然,当第1~i层的半径分别取1,2,3,……i,高度也分别去1,2,3,……i时,有最小体积和侧面积。
如果当前体积v加上1~dep-1层的最小体积大于N,则可以剪枝。
4、最优化剪枝一
如果当前表面积S加上1~dep-1层的最小侧面积大于已经搜到的结果,剪枝。
5、最优化剪枝二:
利用h与r数组,1~dep-1层的体积可表示为n-v = Σ H[k] * R[k]2 ,1~dep-1层的表面积可表示为2 *Σ H[i] *R[i]
∵2 *Σ H[i] *R[i] = ( 2/r[dep] ) * Σ H[i] *R[i] * r[dep] >= ( 2/r[dep] ) * Σ H[i] *R[i]2 其中Σ H[i] *R[i]2 = n-v
∴2 *Σ H[i] *R[i] >= 2(n-v)/R[dep]
∴当2(n-v)/R[dep] + s 大于已经搜到的结果时,可以剪枝。
加入以上五个剪枝后,搜索算法就可以快速求出该问题的最优解。
实际上,搜索算法面对的状态可以看做一个多元组,其中每一元都是问题状态空间的一个”维度“,例如,本题中的层数dep、表面积S、体积V、第dep+1层的高度和半径就构成状态空间中的五个维度,其中每一个维度发生变化,都会移动状态空间中的另一个”点“。这些维度通常在题目描述中也有所体现,它们一般在输入变量、限制条件、待求解变量等非常关键的位置出现。读者一定要注意提取这些”维度“,从而设计出合适的搜索框架。
搜索过程中的剪枝,其实是针对每个”维度“,与该维度的边界条件,加以缩放、推导,得出一个相应的不等式,以减少搜索树分支的扩张。例如,本题中的剪枝1、剪枝3和剪枝4,就是考虑与半径、高度、体积、表面积这些维度的上下界进行比较而直接得到的。
为了进一步提高剪枝的效果,除了当前花费的”代价“之外,我们还可以对未来至少需要花费的代价进行预算,这样更容易接近每个维度的上下界。例如,本题中求前dep-1层最小体积、最小侧面积。剪枝5则通过表面积与体积之间的关系,对不等式进行缩放。
1 #include2 using namespace std; 3 int n,m; 4 int a[22],b[22],ans=0x7fffffff; //当前层数的最小的体积,最小的面积 5 6 // 当前的体积,面积,层数(倒序),上一次计算的半径,高度 7 // 搜索方向是从最小层到最上层的顺序 8 void dfs(int V,int S,int dep,int R,int H){ 9 if ( dep == 0 ){ //到达最上层 10 if( V == n ) { //当前的体积为V 11 ans = min(ans,S); 12 } 13 return ; 14 } 15 //剪枝部分 16 17 //体积+上一层数的最小值 > n 18 if( V + a[dep-1] > n ) 19 return ; 20 21 //面积+上一层数的最小值 >= 过程中的最小值 22 if( S + b[dep-1] >= ans ) 23 return ; 24 25 //面积+(由体积推导的面积的最小值)>= 过程中的最优值 26 if( S + 2*(n-V)/R >= ans ) 27 return ; 28 29 30 for(int r=min((int)(sqrt(n-V))+1,R-1); r>=dep ; r-- ){ 31 //从上往下看,上视图看到的面积由最外层的面积决定 32 if( dep == m ){ 33 S = r*r ; 34 } 35 for(int h=min((n-V-a[dep-1])/(r*r),H-1); h>=dep ;h-- ){ 36 dfs( V+r*r*h,S+2*r*h,dep-1,r,h); 37 } 38 } 39 } 40 int main() 41 { 42 scanf("%d%d",&n,&m); 43 for(int i=1;i<=20;i++){ 44 a[i] = i*i*i + a[i-1] ; 45 b[i] = i*i*2 + b[i-1] ; 46 } 47 dfs(0,0,m,n+1,n+1); 48 printf("%d\n",ans==0x7fffffff? 0 :ans ); 49 return 0; 50 }
【例题3】小木棍(最优性剪枝,可行性剪枝)
题目描述
现在,他想把小木棍拼接成原来的样子,但是却忘记了自己开始时有多少根木棍和它们的长度。
给出每段小木棍的长度,编程帮他找出原始木棍的最小可能长度。
输入
第一行为一个单独的整数N表示看过以后的小木柜的总数,其中N≤60,第二行为N个用空个隔开的正整数,表示N跟小木棍的长度。
输出
样例输入
9
5 2 1 5 2 1 5 2 1
样例输出
6
【思路】:
从题意来看,要得到原始最短木棍的可能长度,可以按照分段数的长度,依次枚举所有的可能长度len,每次枚举len时,用深度搜索判断是否能用截断后的木棍拼合出整个len,能用的话,找出最小的len即可。对于1s的时间限制,用不加任何剪枝的深度搜索时,时间效率为指数级,效率非常低,程序运行将严重超时。对于此题,可以从可行性和最优性上加以剪枝。
从最优性方面分析,可以做以下两种剪枝:
1、设所有木棍的长度和是sum,那么原长度(也就是答案)一定能够被sum整除,不然就没法拼了,即一定拼出整数根。
2、木棍原来的长度一定大于等于所有木棍最长的那根。
综合上述的两点,可以确定原木棍的商都len在最长木棍的长度与sum之间,且sum能被len整除。所以,在搜索原木棍的长度时,可以设定为截断后所有木棍最长的长度开始,每次增加长度后,必须能整除sum。这样可以有效地优化程序。
从可行性方面分析,可以再做以下七种剪枝:
1、一根长木棍肯定比几根短木棍拼成同样长度的用处小,即短小的可以更灵活组合,所以可以对输入的所有木棍按长度从大到小排序。
2、在截断后的排序好的木棍中,当用木棍i拼合原始木棍时,可以从第 i+1 后的木棍开始搜。因为根据优化1,i前面的木棍已经用过了。
3、用当前最长长度的木棍开始搜,如果拼不出当前设定的原木棍长度len,则直接返回,换一个原始木棍长度len。
4、相同长度的木棍不要搜索多次。用当前长度的木棍搜下去得不到结果时,用一支同样长度的还是得不到结果,所以,可以提前返回。
5、判断搜到的几根木棍组成的长度是否大于原始长度len,如果大于,没必要搜下去,可以提前返回。
6、判断当前剩下的木棍棍数是否够拼成木棍,如果不够,肯定拼合不成功,直接返回。
7、找到结果后,在能返回的地方马上返回上一层的递归出。
1 #include2 using namespace std; 3 const int N = 70; 4 int n,sum,len,tot; 5 int a[N],vis[N]; 6 bool cmp(int u,int v){ 7 return u > v ; 8 } 9 // 第k根,还需要now的长度,枚举第pos根木棍 10 bool dfs(int k,int now,int pos){ 11 12 if( k == tot + 1 ) return true; //已经拼完了前tot根,所以返回答案 13 14 if( now == 0 ){ 15 // 如果当前木棍已经拼接好,接着拼下一根,直到拼完tot根为止 16 return dfs(k+1,len,1); 17 } 18 // 剪枝2,如果当前用了第i个,则从i+1根开始拼 19 for(int i=pos;i<=n;i++){ 20 if( !vis[i] && a[i]<=now ){ // 选择当前木棍,必须是没选中过,同时适合拼接下去 21 vis[i] = 1 ; 22 if( dfs(k,now-a[i],i+1) ) return true; 23 vis[i] = 0 ; 24 if( now == len || now == a[i] ) // 剪枝3、如果拼不出来直接返回,原因是不可能再继续下去 25 return false; 26 while( a[i] == a[i+1] ) i++; // 剪枝4,相同长度不要多次搜索 27 } 28 } 29 return false; 30 } 31 int main() 32 { 33 scanf("%d",&n); 34 for(int i=1;i<=n;i++){ 35 scanf("%d",&a[i]); 36 sum += a[i] ; 37 } 38 sort ( a+1 , a+1+n , cmp ); 39 // 最优性方面分析 40 41 for(int i=a[1];i<=sum;i++){ //长度下界一定是所有木棍中最长的。 42 if( sum%i == 0 ){ //答案一定是能整除sum的。 43 memset(vis,0,sizeof vis) ; 44 len = i ; // 设为全局变量,木棍的长度 45 tot = sum/i; // 木棍的数目 46 if(dfs(1,len,1)){ 47 printf("%d\n",len); 48 return 0; 49 } 50 } 51 } 52 return 0; 53 }
【例题4】Addition Chains (ZOJ 1937)【优化搜索顺序】
题目描述
a0 = 1
am = n
a0
You are given an integer n. Your job is to construct an addition chain for n with minimal length. If there is more than one such sequence, any one is acceptable.
For example, <1,2,3,5> and <1,2,4,5> are both valid solutions when you are asked for an addition chain for 5.
输入
输出
Hint: The problem is a little time-critical, so use proper break conditions where necessary to reduce the search space.
样例输入
5
7
12
15
77
0
样例输出
1 2 4 5
1 2 4 6 7
1 2 4 8 12
1 2 4 5 10 15
1 2 4 8 9 17 34 68 77
【思路】:
由于ak = ai+aj ( 0<= i, j < k ),所以我们在搜索的过程中可以采用由小到大搜索数列的每一项的方法进行试算,在一般搜索的时候,我们习惯于从小到大依次搜索每一个数的取值,但是在这道题目这样的搜索顺序,程序执行时间十分不理想。
由于题目要求的是m的最小值,也就是需要我们尽快得到数n,所以每次构造的数应当是尽可能大的数。根据题目的这个特性,我们将搜索顺序改为从大到小搜索每一个数。后一种搜索顺序使程序执行
1 #include2 using namespace std; 3 int n,m,flag,ans[105]; 4 void dfs(int step,int last){ 5 if( step == m+1 ){ 6 if( ans[m] == n ) 7 flag = 1 ; 8 return ; 9 } 10 11 for(int i=step-1; i>=last;i--){ 12 for(int j=i;j>=1;j--){ 13 ans[step] = ans[i] + ans[j] ; 14 long long s = ans[step] ; 15 //计算当前的位置得到,第m项最大值 16 for (int k=step ;k<=m;k++) s *= 2 ; 17 if ( s < n ) break ; 18 if ( ans[step] <= n ) dfs(step+1,i+1); 19 if ( flag ) return ; 20 } 21 } 22 } 23 int main() 24 { 25 ans[1] = 1; 26 while(~scanf("%d",&n),n){ 27 if( n==1 ){ 28 puts("1"); continue; 29 } 30 for ( flag = 0 , m=2 ; m<=n ; m++ ){ 31 dfs(2,1); 32 if( flag ){ 33 for(int i=1;i<=m;i++){ 34 printf("%d%c",ans[i],i==m?'\n':' '); 35 } 36 break; 37 } 38 } 39 } 40 return 0; 41 42 }
【例题5】weight
【题目描述】
已知原数列 a1,a2,a3,……an 中的前1项,前2项,前 3 项, ,前 n 项的和,以及后 1 项,后 2 项,后 3 项, ,后 n 项的和,但是所有的数都被打乱了顺序。此外,我们还知道数列中的数存在于集合 S 中。试求原数列。当存在多组可能的数列时,求字典序最小的数列。
【输入】
第 1 行,一个整数 n 。
第 2 行,2*n 个整数,注意:数据已被打乱。
第 3 行,一个整数 m ,表示 S 集合的大小。
第 4 行, m 个整数,表示 S 集合中的元素。
【输出】
输出满足条件的最小数列。
【样例输入】
5
1 2 5 7 7 9 12 13 14 14
4
1 2 4 5
【样例输出】
1 1 5 2 5
【样例解释】
1 = 1
2 = 1 + 1
7 = 1 + 1 + 5
9 = 1 + 1 + 5 + 2
14 = 1 + 1 + 5 + 2 + 5
5 = 5
7 = 2 + 5
12 = 5 + 2 + 5
13 = 1 + 5 + 2 + 5
14 = 1 + 1 + 5 + 2 + 5
【数据规模】
n<=1000 , S属于{1,2,……500}
【思路】
因为题目S,最坏的情况下,每个数可以取到的值有500种,从数学方面很难找到较好方法予以解决,而采用搜索是一种很好的解决办法,根据数列从左往右依次搜索原数列每个数可能的值,然后与所知道的值进行比较。这样,我们得到了一个最简单的搜索方法A。
但是搜索方法A在最坏的情况下扩展的节点为5001000,求解时间太长了。
在这个算法中,我们对数列中的每个数分别进行了500次搜索,由此导致了搜索量很大。如何有效地减少搜素量是提高本体算法效率的关键。
搜索方法B:回过头来看一看题目提供给我们的约束条件,我们用Si表示前i项的和,用Ti表示后i项的和。
根据题目,我们得到的数应该是数列中的S1,S2……Sn,以及T1,T2,……Tn。其中的任意Si+1-Si和Ti+1-Ti都属于集合S。另一个比较容易发现的约束条件是对任意的i,有Sn=Tn=Si+Ti+1。同样的,在搜索的过程中尽可能利用这些约束条件是提高程序效率的关键。
那么当我们从已知的数据中任意取出两个数的时候,只会出现两种情况:
1、两个数同属于Si或者Ti
2、两个数分别属于Ti和Si
当两个数同属于Si或者Ti时,两个数之差就是途中Sj-Si那一段,而当j=i+1是,Sj-Si必然是题目给出的集合S。由此可知,当每次得到一个数Si或者Ti时,如果我们已知Si-1或者Ti-1退出Si和Ti的可能取值。
因为题目的约束条件都在Si和Ti中,我们改变搜索的对象,不再搜索原数列中的每个数,而是搜索给出的数出现在Si或者Ti中的位置。又由于约束条件中的Si+1与Si的约束关系,在搜索时可以按照Si和Ti递增或者递减的顺序搜索。
例如,原数列:1 1 5 2 5,由它得到的值为:1 2 7 9 14 5 7 12 13 14
排序为:1 2 5 7 7 9 12 13 14 14
由于最大的两个数为所有数的和,在搜索中不用考虑它们,去掉14,得到数列,1 2 5 7 7 9 12 13.
观察发现,数列中的最小值1,只可能出现在所求数列的头部或者尾部。再假设1的位置已经得到了,去掉它以后,我们再观察剩下的数列中最小的数2,显然也可能在当前状态的头部或者尾部加上一个数得到2。这样,每搜索一个数,都只会将它放在头部或者尾部,也就是放入Si中或者Ti中。
推而广之,我们从小到大对已排序的数进行搜索,判断每个数是出现在原数列头部还是尾部。此时我们由原数列的两头向中间搜索,而不是先前的从一头搜向另一头。由之前的分析已经知道,每个数只可能属于Si和Ti中。当我们已经搜索出原数列的S1,S2……Sn和T1,T2……Tj,此时对于正在搜索的数K,只能在Si+1和Ti+1中,分别搜索这两个集合的元素,即判断K-Si和K-Ti是否属于已知集合S,并且在每搜索出一个数K的时候,我们从排序后的数列中去掉Sn-K。这样当K-Si(Ti)不属于集合S或者
Sn - K 时回溯。
1 #include2 using namespace std; 3 const int N = 1e4+10; 4 int n,m; 5 int S[505],a[N],ans[N],sum; 6 void dfs(int L,int R ,int Sum_L , int Sum_R , int k ){ 7 if ( L == R ){ 8 if( sum - Sum_L - Sum_R <= 500 && S[(sum - Sum_L - Sum_R)] ){ 9 ans[L] = sum - Sum_L - Sum_R ; 10 for(int i=1;i<=n;i++){ 11 printf("%d%c",ans[i],i==n?'\n':' '); 12 } 13 exit(0); 14 } 15 return ; 16 } 17 18 if ( (a[k] - Sum_L) <= 500 && S[(a[k] - Sum_L)] ){ 19 ans[L] = (a[k] - Sum_L) ; 20 dfs( L+1 , R , a[k] , Sum_R , k+1 ); 21 } 22 if ( (a[k] - Sum_R) <= 500 && S[(a[k] - Sum_R)] ){ 23 ans[R] = (a[k] - Sum_R) ; 24 dfs( L , R-1 , Sum_L , a[k] , k+1 ); 25 } 26 } 27 int main() 28 { 29 scanf("%d",&n); 30 for(int i=1;i<=n*2;i++){ 31 scanf("%d",&a[i]); 32 } 33 scanf("%d",&m); 34 for(int i=1,x;i<=m;i++){ 35 scanf("%d",&x); 36 S[x] = 1 ; 37 } 38 sort( a+1 ,a+1+2*n) ; 39 sum = a[2*n]; 40 dfs(1,n,0,0,1); 41 return 0; 42 }
【习题1】埃及分数
题目描述
最好的是最后一种,因为 1/18比 1/180,1/45,1/30,1/18都大。
注意,可能有多个最优解。如:
由于方法一与方法二中,最小的分数相同,因此二者均是最优解。
给出 a,b,编程计算最好的表达方式。保证最优解满足:最小的分数
输入
转载于:https://www.cnblogs.com/Osea/p/11209694.html