第1章 回溯法
1.1 一般原理
可用回溯法求解的问题P,通常要能表达为:对于已知的由n元组(x1,x2,…,xn)组成的一个状态空间E={(x1,x2,…,xn)∣xi∈Si ,i=1,2,…,n},给定关于n元组中的一个分量的一个约束集D,要求E中满足D的全部约束条件的所有n元组。其中Si是分量xi的定义域,且 |Si| 有限,i=1,2,…,n。我们称E中满足D的全部约束条件的任一n元组为问题P的一个解。
解问题P的最朴素的方法就是枚举法,即对E中的所有n元组逐一地检测其是否满足D的全部约束,若满足,则为问题P的一个解。但显然,其计算量是相当大的。
我们发现,对于许多问题,所给定的约束集D具有完备性,即i元组(x1,x2,…,xi)满足D中仅涉及到x1,x2,…,xi的所有约束意味着j(j<=i)元组(x1,x2,…,xj)一定也满足D中仅涉及到x1,x2,…,xj的所有约束,i=1,2,…,n。换句话说,只要存在0≤j≤n-1,使得(x1,x2,…,xj)违反D中仅涉及到x1,x2,…,xj的约束之一,则以(x1,x2,…,xj)为前缀的任何n元组(x1,x2,…,xj,xj+1,…,xn)一定也违反D中仅涉及到x1,x2,…,xi的一个约束,n≥i≥j。因此,对于约束集D具有完备性的问题P,一旦检测断定某个j元组(x1,x2,…,xj)违反D中仅涉及x1,x2,…,xj的一个约束,就可以肯定,以(x1,x2,…,xj)为前缀的任何n元组(x1,x2,…,xj,xj+1,…,xn)都不会是问题P的解,因而就不必去搜索它们、检测它们。回溯法正是针对这类问题,利用这类问题的上述性质而提出来的比枚举法效率更高的算法。
回溯法首先将问题P的n元组的状态空间E表示成一棵高为n的带权有序树T,把在E中求问题P的所有解转化为在T中搜索问题P的所有解。树T类似于检索树,它可以这样构造:
设Si中的元素可排成xi(1) ,xi(2) ,…,xi(mi-1) ,|Si| =mi,i=1,2,…,n。从根开始,让T的第I层的每一个结点都有mi个儿子。这mi个儿子到它们的双亲的边,按从左到右的次序,分别带权xi+1(1) ,xi+1(2) ,…,xi+1(mi) ,i=0,1,2,…,n-1。照这种构造方式,E中的一个n元组(x1,x2,…,xn)对应于T中的一个叶子结点,T的根到这个叶子结点的路径上依次的n条边的权分别为x1,x2,…,xn,反之亦然。另外,对于任意的0≤i≤n-1,E中n元组(x1,x2,…,xn)的一个前缀I元组(x1,x2,…,xi)对应于T中的一个非叶子结点,T的根到这个非叶子结点的路径上依次的I条边的权分别为x1,x2,…,xi,反之亦然。特别,E中的任意一个n元组的空前缀(),对应于T的根。
因而,在E中寻找问题P的一个解等价于在T中搜索一个叶子结点,要求从T的根到该叶子结点的路径上依次的n条边相应带的n个权x1,x2,…,xn满足约束集D的全部约束。在T中搜索所要求的叶子结点,很自然的一种方式是从根出发,按深度优先的策略逐步深入,即依次搜索满足约束条件的前缀1元组(x1i)、前缀2元组(x1,x2)、…,前缀I元组(x1,x2,…,xi),…,直到i=n为止。
在回溯法中,上述引入的树被称为问题P的状态空间树;树T上任意一个结点被称为问题P的状态结点;树T上的任意一个叶子结点被称为问题P的一个解状态结点;树T上满足约束集D的全部约束的任意一个叶子结点被称为问题P的一个回答状态结点,它对应于问题P的一个解。
用回溯法解题的一般步骤:
(1)针对所给问题,定义问题的解空间;
(2)确定易于搜索的解空间结构;
(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
1.2 n元全排列
1.2.1 问题
如果p1,p2,…,pn中的元素取自1,2,…,n,且互不相同,则称之为1,2,…,n的一个全排列。全排列问题就是要求输出所有的全排列。其搜索树为
图1-1全排列搜索树
1.2.2 算法设计
setp1 k=1,p1=p2=…pn=1;
setp2 求pk,从pk当前的值往前寻求pk的值,要求pk与p1,…,pk-1不相同,且不超过n;如果找不到,到step4;
step3 如果k=n,打印全排列,pk=pk+1,回到step2;
step4 回溯,重新设置当前分量pk=1,然后回到上一分量k=k-1;
step5 如果k<1,终止算法;
step6 回到step2。
图1-2 全排列回溯法流程图
1.2.3 C++代码
由于C++代码中的数组索引是从0开始的,所以,下面代码中k的值就是从0开始,求出的是0,1,…,n-1的全排列,在打印部分每个分量加1就得到了1,2,…,n的全排列。
void Backtracking(int n){
int *p=new int[n];
memset(p,0,n*sizeof(int));
int k = 0;
while (k >= 0) {
//找一个合适的值给当前状态
while (p[k] < n){
int i;
for (i = 0; i < k; i++)
if (p[i] == p[k])
break;
if (i < k) p[k]++;
else break;
}
//找到了
if (p[k]<n) {
if (k < n-1) k++;//如果没有找到全部解就前进
else//找到了一个解
{
int i;
for(i=0;i<=k;i++)
cout<<p[i]+1<<" ";
cout<<endl;
p[k]++;//探索下一个解
}
}
else//没找到需要回溯
{
p[k] = 0;//清除当前处理的信息
k--;//回溯
if(k>=0)
p[k]++;//探索下一个解
}
}
}
注:这个算法很容易就修改成输出n个元素中选出m(m<n)个元素进行排列,只需要将判断k<n-1变成k<m-1,余皆不变。
1.3 正整数的拆分
1.3.1 问题
对于给定的正整数n,将其表述成若干个正整数的和,不计先后次序。例如
5=1+1+1+1+1
=1+1+1+2
=1+1+3
=1+2+2
=1+4
=2+3
=5
1.3.2 算法设计
假定我们将n拆分成n=p1+p2+…+pn,并要求p1≤p2≤…≤pl,pl+1=…=pn=0,如5=1+2+2+0+0。基本思想:开始时k=1,pk=1,从pk往前,因为要求pk+1≥pk,所以把余额部分分成等于pk的若干份,赋给后面的pj,不够pk的余额就直接给最后一个分量。一旦得到一个解,就进行回溯,也就是令最后一个分量为0,次后的分量增加1个,然后继续。算法步骤如下:
step1 置p1=p2=…=pn=1,k=n;
step2 打印p1,p2,…,pk,num=pk,pk=0;
step3 如果 k<=1 终止;
step4 pk-1=pk-1+1,num=num-1,k=k-1;
step5 t=num/pk取整,pk+1=pk+2=…=pk+t-1=pk,pk+t=pk+num mod pk, k=k+t,回到step2。
图1-3 整数拆算法分流程图
1.3.3 C++代码
void SplitInteger(int n){
int*p=new int[n];//分量
int rem,k=n-1,i;//剩余、回溯变量、循环变量
for(i=0;i<n;i++)p[i]=1;
while(1){
cout<<n<<"="<<p[0];
for(i=1;i<=k;i++)