回溯法
1、0-1背包问题: 给定n种物品,价值分别为v[1],v[2],...,v[n],重量分别为w[1],w[2],...,w[n]。有一个背包其载重容量为c,问应如何选择装入背包中的物品,使得装入背包中的物品的总价值最大。这里每种物品只能装入一次或者不装入背包。这是一个特殊的整数线性规划问题,即在w[1]x[1]+w[2]x[2]+...+w[n]x[n]<=c,x[i]为0或1的约束条件下,求使max{v[1]x[1]+v[2]x[2]+...+v[n]x[n]}最大的向量解(x[1],x[2],...,x[n])。
一般情况下0-1背包问题是NP难的。用回溯法求解:
(1)定义问题的解空间:对于有n种可选物品的0-1背包问题,解是长度为n的0-1向量。因此解空间为所有的2**n个0-1向量。例如当n=3时,解空间为所有的8个0-1向量。
(2)确定解空间树的结构:通常将解空间组织成树(或图)的形式。这里可将2**n个解向量组织成一棵完全二叉树,树枝上用1或0标记,从树根到树叶的任一路径表示解空间中的一个解。一般地,当所给问题是从n个元素(这里为n个物品)的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树。0-1背包问题的解空间就是一棵子集树,这个子集树是完全二叉树,有2*n个叶结点,其结点总个数为2**(n+1)-1,遍历子集树的任何算法需要至少2**n的计算时间。
(3)以深度优先方式搜索整个解空间树,找出所要的解:搜索从根结点开始(成为当前扩展结点),不断地向纵深方向移动到新结点,成为新的扩展结点。如果当前扩展结点不能再向纵深方向移动了,即再往前移动就会导致无效解或非最优解,则成为死结点。此时应回溯到最近一个活结点处,使其成为当前扩展结点,继续向另外一个方向移动。回溯法即以这种工作方式递归地在解空间中搜索,直到找到所要的解或解空间中已无活结点时为止。在搜索过程中通常可以用约束函数或限界函数(统称为剪枝函数)剪去导致无效解或非最优解的子树,以避免无效搜索,提高平均搜索效率。
注意解空间树是在递归搜索过程中动态产生的,并不需要事先建立一个解空间树,然后再去搜索。对0-1背包问题,只要左儿子是一个可行结点,搜索就进入其左子树,右子树只在有可能包含最优解时才进入,否则剪去右子树。我们引入上界函数来判断当前扩展结点是否满足上界条件,即右子树是否有可能包含最优解,若不满足则进行剪枝。
(4)回溯法的数据结构描述:包括解空间树的结点信息,用于构造最优解的数据成员、可选的剪枝函数,回溯函数Backtrack(i),算法实现函数等。通常用一个类来描述,算法实现函数也可以一个独立的全局函数。对0-1背包问题,回溯法的数据结构描述如下:
- //问题及其解空间描述
- template<class VType,class WType>
- class KnapDescription{
- private:
- friend VType Knapsack(VType*,WType*,WType,int,int*); //0-1背包算法的实现函数
- void Backtrack(int i); //回溯函数:深度优先搜索
- WType c; //背包容量
- int n; //物品数
- int* x; //当前解
- int* bestx; //当前最优解
- VType* v; //物品价值数组
- WType* w; //物品重量数组
- VType cv; //当前价值
- WType cw; //当前重量
- VType bestv; //当前最优总价值
- VType r; //剩余物品的总价值
- };
上界函数:r是当前剩余物品的价值总和,这里的上界函数为cv+r,当cv+r<=bestv时,说明右子树不可能包含最优解,可剪去右子树。由于cv+r<=bestv比较简单,可以在搜索函数Backtrack中直接使用,因此并没有设计成一个独立的函数。
回溯函数Backtrack(i):当前扩展结点位于子集树中,它有x[i]=1和x[i]=0两个儿子结点。左儿子为x[i]=1的情形,仅当cw+w[i]<=c时进入左子树,递归地对左子树进行搜索。右儿子为x[i]=1的情形,用上界函数计算他是否包含最优解,包含则进入递归地搜索右子树,否则直接剪去右子树。实现如下:
- //搜索第i层结点:i为0到n-1
- template<class VType,class WType>
- void KnapDescription<VType,WType>::Backtrack(int i){
- if(i>=n){ //到达叶结点
- if(cv>bestv){
- bestv=cv; //修正当前最优总价值
- for(int j=0;j<n;j++) //修正当前最优解
- bestx[j]=x[j];
- }
- return;
- }
- r-=v[i];
- if(cw+w[i]<=c){ //搜索左子树:进入左子树时无需计算上界,满足条件则装入当前的物品i
- x[i]=1; //装入当前物品i
- cw+=w[i];
- cv+=v[i];
- Backtrack(i+1); //递归搜索左子树
- cw-=w[i]; //递归搜索返回后要重置当前重量和价值为原来值
- cv-=v[i];
- }
- if(cv+r>bestv){ //搜索右子树:若右子树包含最优值,则进入右子树搜索,否则直接剪去右子树
- x[i]=0;
- Backtrack(i+1); //进入右子树
- }
- r+=v[i]; //本层结点搜索完,重置r的值为搜索前的值
- }
算法实现函数:返回最大的总价值,最优解向量在bestx中。如下:
- template<class VType,class WType>
- VType Knapsack(VType* v,WType* w,WType c,int n,int* bestx){
- KnapDescription<VType,WType> alg;
- alg.v=v;
- alg.w=w;
- alg.c=c;
- alg.n=n;
- alg.bestx=bestx;
- alg.x=new int[n];
- alg.cv=alg.cw=alg.bestv=alg.r=0;
- //r初始化为所有物品总价值
- for(int i=0;i<n;i++)
- alg.r+=v[i];
- alg.Backtrack(0); //搜索整个解空间树
- delete[] alg.x;
- return alg.bestv;
- }
函数Backtrack动态地生成问题的解空间树,在每个叶子结点处bestx可能被更新,需花费O(n),子集树中的叶子结点个数为O(2**n),因此算法时间为O(n*2**n),另外还需要O(n)的递归栈空间。通过一些技巧,可将算法运行时间改进为O(2**n)。比如可根据递归来动态地更新bestx,而不是静态的一次更新。在第i层的当前结点处,当前最优解由x[0:i-1]和bestx[i:n-1]组成,每当算法回溯一层时,将x[i]存入bestx[i],这样每个结点处更新bestx只需O(1)时间,整个算法运行时间为O(2**n)。
2、旅行商问题: 某售货员要到若干城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线(即哈密顿回路),使总的路程(或总旅费)最小。给定一个有n个顶点V={1,2,...,n}的带权图G=(V,E),旅行商问题就是要在图G中找出一条有最小费用的周游路线(哈密顿回路,即经过V中每个顶点一次的简单回路)。
旅行商问题是一个NP完全问题,因此不大可能找到多项式时间算法(除非P=NP),下面用回溯法求解:
(1)定义问题的解空间:一条周游路线相当于顶点集的一个排列,例如对n=4,顶点序列1,3,2,4,1为一条周游路线,对应排列1,3,2,4。一个排列可以用向量x来表示。解空间包含了所有的周游路线表示的排列。初始时为单位排列x[1:n]={1,2,...,n}。对n=4,从顶点1出发的所有周游路线条数为2,3,4的全排列个数,共3!=6个。
(2)确定解空间树的结构:解空间可以组织成一棵树,树枝上用顶点编号来标记,从树根到任一叶子结点的路径都定义了图G的一条周游路线。一般地,当所给问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树通常有(n-1)!个叶结点,因此遍历排列树至少需要(n-1)!的计算时间。
(3)以深度优先方式搜索整个解空间树,找出所要的解:这里图G用邻接矩阵表示。对排列树的回溯搜索与生成1,2,...,n所有排列的递归算法Perm类似,设开始时x[1:n]={1,2,...,n},则相应的排列树由x[1:n]的所有排列组成。每当搜索到达叶子结点时,若找到了一条回路,就更新当前最小费用和当前最优解。若搜索还没有到达叶子结点,且已搜索的路径上的费用小于当前最小费用,则进入排列树的下一层继续向前搜索,否则直接剪去相应的子树。算法实现如下:
- //问题及其解空间描述
- template<class Type>
- class TravelDescription{
- private:
- friend Type TSP(int**,int*,int,Type); //Traveling-salesman problem的求解
- void Backtrack(int i); //回溯搜索函数
- int n; //图G的顶点个数
- int* x; //当前解
- int* bestx; //当前最优解
- Type** a; //图G的邻接矩阵
- Type cc; //当前费用
- Type bestc; //当前最优值(当前最小费用)
- Type NoEdge; //无边标记
- };
- //搜索第i层结点:i为1到n
- template <class Type>
- void TravelDescription<Type>::Backtrack(int i){
- if(i==n){ //遍历到达叶子结点
- if(a[x[n-1]][x[n]]!=NoEdge &&
- a[x[n]][1]!=NoEdge &&
- (cc+a[x[n-1]][x[n]]+a[x[n]][1]<bestc || bestc==NoEdge)){
- bestc=cc+a[x[n-1]][x[n]]+a[x[n]][1]; //修正当前最小费用
- for(int j=1;j<=n;j++) //修正当前最优解
- bestx[j]=x[j];
- }
- }else{
- for(int j=1;j<=n;j++)
- if(a[x[i-1]][x[j]]!=NoEdge &&
- (cc+a[x[i-1]][x[i]]<bestc || bestc==NoEdge)){ //若可以进入x[j]子树
- Swap(x[i],x[j]);
- cc+=a[x[i-1]][x[i]]; //修正当前费用
- Backtrack(i+1); //递归搜索子树
- cc-=a[x[i-1]][x[i]]; ////递归搜索返回后要重置当前费用为原来值
- Swap(x[i],x[j]);
- }
- }
- }
- template<class Type>
- inline void Swap(Type& a,Type& b){
- Type temp=a; a=b; b=temp;
- }
- //算法实现函数:返回回路的最小费用,最优的哈密顿回路在bestPath中
- //若图G中没有哈密顿回路,则返回NoEdge值
- template<class Type>
- Type TSP(Type** a,int* bestPath,int n,Type NoEdge){
- TravelDescription<Type> alg;
- alg.x=new int[n+1];
- for(int i=1;i<=n;i++) //初始x为单位排列
- alg.x[i]=i;
- alg.a=a;
- alg.n=n;
- alg.bestc=NoEdge; //当前最小费用初始化为表示无边的NoEdge值
- alg.bestx=bestPath;
- alg.cc=0;
- alg.NoEdge=NoEdge;
- alg.Backtrack(2); //搜索整个解空间树:即x[2:n]的全排列
- delete[] alg.x;
- return alg.bestc;
- }
在递归函数Backtrack中,当i=n时,到达排列树的第n-1层,即叶子结点的父结点处,若存在一条从顶点x[n-1]到顶点x[n]的边和一条从顶点x[n]到顶点1的边,则找到一条旅行商回路,若小于当前最优值,则更新当前最优值和当前最优解。当i<n时,到达排列树的第i-1层,若存在边(x[i-1],x[i]),则x[1:i]为图G的一条路径,且当x[1:i]的费用(记录在cc中)小于当前最优值时进入排列树下一层去搜索,否则剪去这个子树。
算法在每个叶子结点处bestx可能被更新,需花费O(n),子集树中的叶子结点个数为O((n-1)!),因此算法时间复杂度为O(n!)。
3、n皇后问题: 要求在一个n*n格的棋盘上放置n个皇后,使得她们彼此不受攻击,即使得任何两个皇后不能被放在同一行或同一列或同一斜线上(国际象棋中一个皇后可以攻击与之处在同一行或同一列或同一斜线上的其他任何棋子)。注意通常会多种可行的放置方案。
n皇后问题是NP完全的,用回溯法求解:
(1)定义问题的解空间:用n元组x[1:n]表示它的解,即一个可行的放置方案。其中x[i]表示皇后i放在棋盘的第i行第x[i]列。由于不允许将任何两个皇后放在同一列,所以解向量中诸x[i]互不相同。而任何两个皇后不能放在同一斜线上是问题的隐约束。在搜索时,可通过这个斜线约束来剪去无效的子树。将棋盘看作是二维方阵,从左上角到右下角的主对角线及其平行线上(即斜率为-1的各斜线),
元素的两个下标值的差值相等。同理,斜率为+1的每一条斜线上,元素的两个下标值的和相等。因此,若两个皇后放置位置为(i,j)和(k,l),且i-j=k-l或i+j=k+l,则这两个皇后处于同一斜线上。这里两个等式可统一表示为|i-k|=|j-l|。
(2)确定解空间树的结构:可以用一棵完全n叉树来表示n皇后问题的解空间。树枝上用放置的列号来标记,从树根到任一叶子结点的路径都是一种放置方案。这个完全n叉树共有n**n(即n的n次方)个叶子结点,因此遍历解空间树的算法需要O(n**n)的时间复杂度。
(3)以深度优先方式搜索整个解空间,找出所要的解:约束函数为Plack(k),它用来测试将皇后k放在x[k]列是否与前面已放置的k-1个皇后都不在同一列,且都不在同一斜线上。在回溯函数中,若搜索到达叶子结点(i>n),则得到一个新的n皇后互不攻击的可行方案,可行方案数加1。若还未到达叶子结点(i<=n),由于是n叉树,当前扩展结点有n个放置列号x[i]=1,2,...,n所表示的n个儿子结点,对皇后i的每一种列号放置情况,用结束函数Place检查其可行性,若可行则进入这个子树进行递归搜索,否则剪去这个子树。算法实现如下:
- //问题及其解空间描述
- class Queen{
- private:
- friend int nQueen(int); //算法实现函数
- bool Place(int k); //约束函数
- void Backtrack(int i); //回溯搜索函数
- int n; //皇后个数
- int* x; //当前解
- long sum; //当前已找到的可行方案数
- };
- //约束函数
- bool Queen::Place(int k){
- for(int j=1;j<k;j++) //判断若将皇后k放在(k,x[k])处,与前面的每一个皇后(j,x[j])是否都不在同一列,且都不在同一斜线上
- if( (x[j]==x[k]) || (abs(k-j)==abs(x[j]-x[k])) )
- return false;
- return true;
- }
- void Queen::Backtrack(int i){
- if(i>n) //搜索到达叶子结点,得到一个新的可行的放置方案
- sum++;
- else //否则没达到叶子结点,当前扩展结点有n个列号x[i]=1,2,...,n表示的n个儿子结点
- for(int j=1;j<=n;j++){
- x[i]=j; //将皇后i放置在第j列
- if(Place(i)) //若这个放置可行,则进入这个子树进行递归搜索
- Backtrack(i+1);
- }
- }
- //算法实现函数:返回可行的放置方案个数,这里数组x的长度应该为n+1,
- //x[1]~x[n]存放了其中的一个可行放置方案
- int nQueen(int* x,int n){
- Queen alg;
- alg.n=n;
- alg.sum=0;
- for(int i=1;i<=n;i++) //初始化各皇后的放置情况
- alg.x[i]=0;
- alg.Backtrack(1); //搜索整个解空间树
- delete[] alg.x;
- return alg.sum;
- }
由于解空间树有n**n(即n的n次方)个叶子结点,因此算法的时间复杂度为O(n**n),非常耗时。
回溯法有通用解题法之称。因为它采用的是搜索整个解空间的策略,对一些无从下手的、组合数较大的问题(特别是很多NP完全问题),回溯法是一个有力的工具。回溯法对解空间进行了有效的组织(组织成树或图的结构),还可以用剪枝函数来提高平均搜索效率,因此它的效率大大高于纯粹的穷举法。
回溯法的基本步骤:定义问题的解空间、确定解空间树的结构、以深度优先方式搜索整个解空间,找到所要的解(剪枝函数的使用)。
两种基本的回溯法框架:子集树算法框架、排列树算法框架。