回溯法.docx
《回溯法.docx》由会员分享,可在线阅读,更多相关《回溯法.docx(29页珍藏版)》请在冰豆网上搜索。
![回溯法.docx](https://file1.bdocx.com/fileroot1/2023-7/6/c3c4892c-0ef3-44b6-a58b-ba3b1a826cbd/c3c4892c-0ef3-44b6-a58b-ba3b1a826cbd1.gif)
回溯法
全面解析回溯法:
算法框架与问题求解
目录
什么是回溯法?
回溯法的通用框架
利用回溯法解决问题
•问题1:
求一个集合的所有子集
•问题2:
输出不重复数字的全排列
•问题3:
求解数独——剪枝的示范
•问题4:
给定字符串,生成其字母的全排列
•问题5:
求一个n元集合的k元子集
•问题6:
电话号码生成字符串
•问题7:
一摞烙饼的排序
•问题8:
8皇后问题
总结与探讨
附:
《算法设计手册》第7章其余面试题解答
摘了一段来自XX百科对回溯法思想的描述:
在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。
当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。
(其实回溯法就是对隐式图的深度优先搜索算法)。
若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。
而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
可以把回溯法看成是递归调用的一种特殊形式。
其实对于一个并非编程新手的人来说,从来没使用过回溯法来解决问题的情况是很少见的,不过往往是“对症下药”,针对特定的问题进行解答。
这些天看了《算法设计手册》回溯法相关内容,觉得对回溯法抽象的很好。
如果说算法是解决问题步骤的抽象,那么这个回溯法的框架就是对大量回溯法算法的抽象。
本文将对这个回溯法框架进行分析,并且用它解决一系列的回溯法问题。
文中的回溯法采用递归形式。
在进一步的抽象之前,先来回顾一下DFS算法。
对于一个无向图如下图左,它的从点1开始的DFS过程可能是下图右的情况,其中实线表示搜索时的路径,虚线表示返回时的路径:
可以看出,在回溯法执行时,应当:
保存当前步骤,如果是一个解就输出;维护状态,使搜索路径(含子路径)尽量不重复。
必要时,应该对不可能为解的部分进行剪枝(pruning)。
下面介绍回溯法的一般实现框架:
boolfinished=FALSE;/*是否获得全部解?
*/
backtrack(inta[],intk,datainput)
{
intc[MAXCANDIDATES];/*这次搜索的候选*/
intncandidates;/*候选数目*/
inti;/*counter*/
if(is_a_solution(a,k,input))
process_solution(a,k,input);
else{
k=k+1;
construct_candidates(a,k,input,c,&ncandidates);
for(i=0;ia[k]=c[i];
make_move(a,k,input);
backtrack(a,k,input);
unmake_move(a,k,input);
if(finished)return;/*如果符合终止条件就提前退出*/
}
}
}
对于其中的函数和变量,解释如下:
a[]表示当前获得的部分解;
k表示搜索深度;
input表示用于传递的更多的参数;
is_a_solution(a,k,input)判断当前的部分解向量a[1...k]是否是一个符合条件的解
construct_candidates(a,k,input,c,ncandidates)根据目前状态,构造这一步可能的选择,存入c[]数组,其长度存入ncandidates
process_solution(a,k,input)对于符合条件的解进行处理,通常是输出、计数等
make_move(a,k,input)和unmake_move(a,k,input)前者将采取的选择更新到原始数据结构上,后者把这一行为撤销。
其实回溯法框架就是这么简单,通过这个框架,足以解决很多回溯法问题了。
不信?
下面展示一下:
(由于后文所有代码均为在C中编写,因此bool类型用int类型代替,其中0为FALSE,非0为TRUE。
)
问题1:
求一个集合的所有子集
解答:
将3个主要的函数实现,这个问题就解决了。
由于每次for循环中a[k]=c[i],这是唯一的改动,并且在下次循环时会被覆盖,不需要专门编写make_move()和make_unmove()。
intis_a_solution(inta[],intk,datainput)
{
returnk==input;
}
voidconstruct_candidates(inta[],intk,datainput,intc[],int*ncandidates)
{
c[0]=1;
c[1]=0;
*ncandidates=2;
}
voidprocess_solution(inta[],intk,datainput)
{
inti;
printf("{");
for(i=1;i<=k;i++)
if(a[i])
printf("%d",i);
printf("}\n");
}
候选构造函数construct_candidates()相对简单,因为对每个集合中的元素和一个特定子集,只有出现和不出现这两种可能。
调用这个函数只需:
generate_subsets(intn)
{
inta[NMAX];
backtrack(a,0,n);
}
扩展:
Skiena在《算法设计手册》第14章组合算法部分介绍了生成子集的三种方式:
按排序生成、二进制位变换、格雷码。
上面这个算法是二进制变换的一种,格雷码生成可以参考后面习题解答的7-18;而按排序生成则比较复杂,它按特定顺序生成,如{1,2,3}生成顺序为{},{1},{1,2},{1,2,3},{1,3},{2},{2,3},并且建议除非有这种要求,否则不要使用这个方式。
问题2:
输出不重复数字的全排列
解答:
与上1题不同的是,由于不能重复出现,每次选择的元素都将影响之后的候选元素集合。
构造候选时应从之前获得的部分解中获取信息,哪些元素是可以后续使用的,哪些是不可以的:
voidconstruct_candidates(inta[],intk,datainput,intc[],int*ncandidates)
{
inti;
intin_perm[NMAX+1];
for(i=1;i<=NMAX;i++)
in_perm[i]=0;
for(i=1;iin_perm[a[i]]=1;
*ncandidates=0;
for(i=1;i<=input;i++)
if(!
in_perm[i]){
c[*ncandidates]=i;
*ncandidates+=1;
}
}
不过这里可以看出一个问题,如果每次都是需要选择分支时构造候选元素,势必会造成浪费。
这里仅仅是一个展示,如果提高效率,可以把解空间和原空间优化到一起,这样不必每次都生成解空间。
下面的代码是对这个问题更好的也是更常见的解法,我相信不少人都写过,并对上一种看似复杂的解法表示不屑一顾:
voidpermutaion(int*array,intk,intlength)
{
inti;
if(length==k){
for(i=0;iprintf("%d",array[i]);
printf("\n");
return;
}
for(i=k;iswap(&array[i],&array[k]);
permutaion(array,k+1,length);
swap(&array[i],&array[k]);
}
}
但仔细观察这个解法,可以发现其实它暗含了is_a_solution()、constructcandidates()、process_solution()、make_move()和unmake_move()这些步骤,它其实是一般的回溯法框架的简化和优化。
问题3:
求解数独——剪枝的示范
解答:
由于填入的数字涉及到横纵两个坐标,单纯的解向量a[]不能满足保存解的要求了。
仅a[k]表示填入的值,定义一个结构以保存数独的当前状态和第k步时填入点的坐标:
#defineDIMENSION9
#defineNCELLSDIMENSION*DIMENSION
typedefstruct{
intx,y;
}point;
typedefstruct{
intm[DIMENSION+1][DIMENSION+1];
intfreecount;
pointmove[NCELLS+1];
}boardtype;
typedefboardtype*data;
同时把获取下一步候选的construct_candidates()分解为两步:
获取下一步填入点的坐标next_square()、获取该点可以填入的数值possible_values()。
对于这两步需要进行一些探讨:
next_square()可以采取任意取一个没有填入的点的随机策略(arbitrary);而更有效的策略是最大约束(MostConstrained),即取的点行、列以及所在3*3方阵点数最多的点,这样它的约束最多,填入的数字的可能性最少。
possible_values()也可以采用两种策略:
局部计数(localcount),即只要满足行、列、3*3方阵内部都不冲突,就作为可能填入的数值;预测(lookahead),即对填入的数,预先找下一步时是否所有空都可填入至少一个数字来确认这个数是否可以被填入。
《算法设计手册》作者认为,我们如果采用最大约束和局部计数策略,回溯过程就已经暗含了预测(失败时会回退),我曾经试过,专门写一个lookahead函数是得不偿失的,它并不比直接回溯开销小,甚至更大。
因此,为了提高效率,next_square()采取最大约束策略,possible_values()采取暗含的预测策略。
为了计算出最大约束的点,我还写了一个evaluate()函数用来计算某个未填点的得分,得分越大说明约束越强,约束最强的点将成为候选点。
这个evaluate()不是很严格,因为它重复计算了一些点,不过影响不大。
这两个策略的采取可以看作是剪枝的过程。
剪枝是回溯法的重要加速途径,好的剪枝策略能够提高回溯法的运行速度,这是回溯法与暴力算法的一大区别。
voidconstruct_candidates(inta[],intk,boardtype*board,intc[],int*ncandidates)
{
intx,y;
inti;
intpossible[DIMENSION+1];
next_square(&x,&y,board);
board->move[k].x=x;
board->move[k].y=y;
//printf("k:
%dleft:
%d\n",k,board->freecount);
*ncandidates=0;
if(x<0&&y<0)
return;
possible_values(x,y,board,possible);
for(i=1;i<=DIMENSION;i++)
if(possible[i]){
c[*ncandidates]=i;
*ncandidates+=1;
}
}
//mostconstrainedsquareselection
voidnext_square(int*x,int*y,boardtype*board)
{
intm_x,m_y,i,j;
intscore,max_score;
m_x=-1,m_y=-1,max_score=0;
for(i=1;i<=DIMENSION;i++)
for(j=1;j<=DIMENSION;j++){
if(board->m[i][j])//notblank
continue;
score=evaluate(i,j,board);
if(score>max_score){
m_x=i;
m_y=j;
max_score=score;
}
}
*x=m_x;
*y=m_y;
}
intevaluate(intx,inty,boardtype*board)
{
inti,j,i_start,j_start;
intscore=0;
//row
i=x;
for(j=1;j<=DIMENSION;j++)
score+=(board->m[i][j]>0);
//column
j=y;
for(i=1;i<=DIMENSION;i++)
score+=(board->m[i][j]>0);
//3*3square
i_start=(i-1)/3*3+1;
j_start=(j-1)/3*3+1;
//themostleftanduppointinthe3*3square
for(i=i_start;i<=i_start+2;i++)
for(j=j_start;jscore+=(board->m[i][j]>0);
returnscore;
}
intpossible_values(intx,inty,boardtype*board,intpossible[])
{
inti,j;
volatileinti_start,j_start;
for(i=1;i<=DIMENSION;i++)
possible[i]=1;
//row
i=x;
for(j=1;j<=DIMENSION;j++)
possible[board->m[i][j]]=0;
//column
j=y;
for(i=1;i<=DIMENSION;i++)
possible[board->m[i][j]]=0;
//3*3square
i_start=(x-1)/3;
i_start=i_start*3+1;
j_start=(y-1)/3;
j_start=j_start*3+1;
//printf("i_start:
%dj_start:
%d\n",i_start,j_start);
//themostleftanduppointinthe3*3square
for(i=i_start;i<=i_start+2;i++)
for(j=j_start;j<=j_start+2;j++)
possible[board->m[i][j]]=0;
//printf("(%d,%d):
",x,y);
//for(i=1;i<=DIMENSION;i++)
//if(possible[i]){
//printf("%d",i);
//}
return0;
}
construct_candidates()、next_square()、possible_values() 由于要对定义的数据结构进行修改,make_move()和unmake_move()也需要进行实现了。
voidmake_move(inta[],intk,boardtype*board)
{
fill_square(board->move[k].x,board->move[k].y,a[k],board);
}
voidunmake_move(inta[],intk,boardtype*board)
{
free_square(board->move[k].x,board->move[k].y,board);
}
voidfill_square(intx,inty,intkey,boardtype*board){
board->m[x][y]=key;
board->freecount--;
}
voidfree_square(intx,inty,boardtype*board){
board->m[x][y]=0;
board->freecount++;
}
make_move()和unmake_move() is_a_solution()是对freecount是否为0的判断,process_solution()可以用作输出填好的数独,这两个函数的解法略过。
而backtrack()函数和基本框架相比,看上去没多大的区别。
voidbacktrack(inta[],intk,boardtype*input)
{
intc[DIMENSION];
intncandidates;
inti;
if(is_a_solution(a,k,input))
process_solution(a,k,input);
else{
k=k+1;
construct_candidates(a,k,input,c,&ncandidates);
for(i=0;ia[k]=c[i];
make_move(a,k,input);
backtrack(a,k,input);
unmake_move(a,k,input);
if(finished)
return;
}
}
}
backtrackofsudoku 经测试,《算法设计手册》上的Hard级别的数独,我的这个程序可以获得和原书同样的解。
附注:
这里是以数独为例展示回溯法。
而如果需要专门进行数独求解,可以试试DancingLinks,有一篇文章对其进行介绍,感兴趣的读者可以自行查阅。
另外有关DancingLinks的性能,可以参阅:
算法实践——舞蹈链(DancingLinks)算法求解数独。
。
问题4:
给定一个字符串,生成组成这个字符串的字母的全排列(《算法设计手册》面试题7-14)
解答:
如果字符串内字母不重复,显然和问题2一样。
如果字符串中有重复的字母,就比较麻烦了。
不过套用回溯法框架仍然可以解决,为了简化候选元素的生成,将所有候选元素排列成数组,形成“元素-值”对,其中值代表这个元素还能出现几次,把ASCII码的A~Z、a~z映射为数组下标0~51。
实现如下:
intis_a_solution(chara[],intk,intlen){
return(k==len);
}
voidprocess_solution(chara[],intk,intlen){
inti;
for(i=1;i<=k;i++)
printf("%c",a[i]);
printf("\n");
}
voidbacktrack(chara[],intk,intlen,intcandidate[])
{
inti;
if(is_a_solution(a,k,len))
process_solution(a,k,len);
else{
k=k+1;
for(i=0;iif(candidate[i]){
a[k]=i+'A';
candidate[i]--;//make_move
backtrack(a,k,len,candidate);
candidate[i]++;//unmake_move
if(finished)
return;
}
}
}
}
voidgenerate_permutations_of_string(char*p)
{
//sort
chara[NMAX];
intcandidate[MAXCANDIDATES];
inti,len=strlen(p);
for(i=0;icandidate[i]=0;
for(i=0;icandidate[p[i]-'A']++;
backtrack(a,0,len,candidate);
}
显然,construct_candidates()已经化入了backtrace()内部,而且这也是一个对如何将候选也作为参数传递给下一层递归的很好的展示。
问题5:
求一个n元集合的k元子集(n>=k>0)。
(《算法设计手册》面试题7-15)
解答:
如果想采用问题1的解法,需要稍作修改,使得遍历至叶结点(也即所有元素都进行标记是否在集合中)时,判断是不是一个解,即元素数目是否为k。
满足才能输出。
#include
#defineMAXCANDIDATES2
#defineNMAX3
typedefintdata;
intis_a_solution(inta[],intk,datainput);
voidconstruct_candidates(inta[],intk,datainput,intc[],int*ncandidates);
voidprocess_solution(inta[],intk,datainput);
staticintfinished=0;
voidconstruct_candidates(inta[],intk,datainput,intc[],int*ncandidates)
{
c[0]=1;
c[1]=0;
*ncandidates=2;
}
voidprocess_solution(inta[],intk,datainput)
{
inti;
printf("{");
for(i=1;i<=k;i++)
if(a[i])
printf("%d",i);
printf("}\n");
}
backtrack(inta[],intk,datainput,intn,intnum)
{
intc[MAXCANDIDATES];
intncandidates;
inti;
if(n==num){//isasolution