回溯算法
今天我们来学习一下回溯算法,什么是回溯?根据搜索结果所做选择:如果发现已经得到最优解了,那么就结束。如果发现选择这一步可能得到最优解,那么继续下去。如果发现这次选择继续下去不可能得到最优解,则剪枝回溯,回到上一步,继续做选择。 我们先来看一个组合问题,问题描述:找出从自然数1、2、……、n中任取r个数的所有组合。 例如n=5,r=3的所有组合为:(1)1、2、3 (2)1、2、4 (3)1、2、5 (4)1、3、4 (5)1、3、5 (6)1、4、5 (7)2、3、4 (8)2、3、5 (9)2、4、5 (10)3、4、5 如何解答?开始可能觉得用暴力穷举比较好用,但是复杂度很厉害。怎么办?剪枝,碰到不可能产生最优解的选择,直接舍弃。回溯就是一种通用性解法,也可以将回溯法看作是带优化的暴力穷举。 既然是组合,不妨假设后面的元素大于前面的,即array[i+1]>array[i] (for every 0<=i<n)。而且还要满足一个条件,那就是第i位(i>=0)的元素的最大值减去i不能超过n-r+1。为啥呢?就拿n=5,r=3来说,i=0的最大值array[i]=3,n-r+1=3,满足条件。如果array[0]=4,那很显然肯定是没有解的。所以要满足条件array[i]-i<=n-r+1。这两个条件就是约束条件。 我们从i=0开始,将第一个取出的值赋为1,然后开始寻找接下来的两个值,同时要满足两个约束条件。一旦发现不满足的就立刻剪枝回溯,不再浪费时间。如果找到最优解了则输出。 上代码
void comb(int array[],int n,int r) { int i,j; i=0; array[i]=1; while(i>=0) { if(array[i]-i<=n-r+1) //第二条件 { if(i==r-1) { for (j=0;j<r;j++) cout<<" "<<array[j]; cout<<endl; array[i]++; } else { i++; array[i]=array[i-1]+1; //第一条件 } } else { i--; //回溯 array[i]++; } } }
这就是回溯算法,比穷举要好很多。(回溯法 = 穷举 +剪枝。) 回溯法其实是搜索算法中的一种控制策略。它的基本思想是:为了求得问题的解,先选择某一种可能情况向前探索试探,在探索试探过程中,一旦发现原来的选择是错误的,就退回一步重新选择,继续向前探索,如此反复进行,直至得到解或证明无解。
复杂问题通常有很多的可能解,这些可能解构成了问题的解空间。解空间也就是进行穷举的搜索空间,所以解空间中应该包括所有的可能解。选择正确的解空间很重要,如果没有确定正确的解空间就开始搜索,可能会做很多重复无用功,或者根本就搜索不到正确的解。这一点一定要注意! 用回溯法求解一个具有n个输入的问题,一般情况下,将其可能解表示为满足某个约束条件的等长向量X=(x1, x2, …, xn),其中分量xi (1≤i≤n)的取值范围是某个有限集合Si={a1, a2 ... aj...},所有可能的解向量构成了问题的解空间。 问题的解空间一般用解空间树(Solution Space Trees,也称状态空间树)的方式组织,树的根结点位于第1层,表示搜索的初始状态,第2层的结点表示对解向量的第一个分量做出选择后到达的状态,第1层到第2层的边上标出对第一个分量选择的结果,依此类推,从树的根结点到叶子结点的路径就构成了解空间的一个可能解。 例子: 对于n=3的0/1背包问题,其解空间树如图所示,树中的8个叶子结点分别代表该问题的8(2^3)个可能解。
对于n=4的TSP(旅行商问题)问题,其解空间树如图所示,树中的24个叶子结点分别代表该问题的24( 4! )个可能解,例如结点5代表一个可能解,路径为1→2→3→4→1,长度为各边代价之和。
回溯法从根结点出发,按照深度优先策略遍历解空间树,搜索满足约束条件的解。 初始时,根结点成为一个活结点,同时也称为当前的扩展结点。在当前扩展结点处,搜索向纵深方向移至一个新结点。这个新结点成为一个新的活结点,并成为当前的扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前的扩展结点就成为一个死结点。此时,应往回移动回溯至最近的一个活结点处,并使这个活结点成为当前的扩展结点。 也就是说在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(如果用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。 如果仅仅是求任意一个解时,只要搜索到问题的一个解就可以结束。) 搜索至树中任一结点时,先判断该结点对应的部分解是否满足约束条件,或者是否超出目标函数的界,也就是判断该结点是否包含问题的(最优)解,如果肯定不包含,则跳过对以该结点为根的子树的搜索,即所谓剪枝(Pruning)。否则进入以该结点为根的子树,继续按照深度优先策略搜索。
例如,对于n=3的0/1背包问题,三个物品的重量为{20, 15, 10},价值为{20, 30, 25},背包容量为25,从图所示的解空间树的根结点开始搜索,搜索过程如下:
再如,对于n=4的TSP问题,其代价矩阵如图所示,
搜索过程:
回溯法搜索解空间树时,通常采用两种策略避免无效搜索,提高回溯法的搜索效率。其一是用约束函数在当前节点(扩展节点)处剪去不满足约束的子树;其二是用限界函数剪去得不到最优解的子树。这两类函数统称为剪枝函数。 回溯法解题通常包含以下三个步骤: 1.针对所给问题,定义问题的解空间; 2.确定易于搜索的解空间结构; 3.以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。 需要注意的是,问题的解空间树是虚拟的,并不需要在算法运行时构造一棵真正的树结构,只需要存储从根结点到当前结点的路径上的结点。
再来看看这个背包问题,对于n=3的0/1背包问题,三个物品的重量为{16, 15, 15},价值为{45, 25, 25},背包容量为30,从其解空间树的根结点开始搜索,搜索过程如下:
回溯法的基本思想是在一棵含有问题全部可能解的状态空间树上进行深度优先搜索,解为叶子结点,搜索过程中,每到达一个结点时,则判断该结点为根的子树是否含有问题的解,如果不含有问题的解,则放弃对该子树的搜索,退回到上层父结点,继续下一步深度优先搜索过程。 在回溯法中,并不是先构造出整棵状态空间树,再进行搜索,而是在搜索过程,逐步构造出状态空间树,即边搜索,边构造;但是状态树并不保存,保存的只是根结点到叶子节点的路径上的结点而已。 用回溯法解题的一个显著特征是在搜索过程中动态产生问题的解空间。在任何时刻,算法只保存从根结点到当前扩展结点的路径。如果解空间树中从根结点到叶结点的最长路径的长度为h(n),则回溯法所需的空间通常为O(h(n))。而显式地存储整个解空间则需要O(2^h(n))或O(h(n)!)内存空间。
回溯算法一般有一些框架来做,递归的,迭代的。 递归回溯:回溯法对解空间作深度优先搜索,一般情况下用递归函数来实现回溯法。一般函数结构如下:
void Bcktrack(int t) //参数t表示当前递归深度 { if(t>n)Output(x); //遍历到解(叶子节点),则将解输出或其他处理 else { //f(n,t)和g(n,t)表示当前节点(扩展节点)处未搜索过的子树的起始编号和中指编号 for(int i=f(n,t);i<=g(n,t);i++) { x[t]=h(i); //h(i)表示当前节点(扩展节点)处x[i]的第i个可选值 if(Constarint(t)&&Bound(t)) //满足剪枝函数:约束函数,限界函数 Bcktrack(t+1); } } }
迭代回溯:采用树的非递归深度优先算法遍历算法,也可以将回溯法表示为一个非递归的迭代过程。一般函数形式如下:
void IterativeBacktrack(void) { int t=1; //t表示当前递归深度 while(t>0) { if(f(n,t)<=g(n,t)) { //f(n,t)和g(n,t)表示当前节点(扩展节点)处未搜索过的子树的起始编号和中指编号 for(int i=f(n,t);i<=g(n,t);i++) { x[t]=h(i); if(Constraint(t)&&Bound(t)) //满足剪枝函数:约束函数,限界函数 { if(Solution(t)) Output(x); //判断当前节点是否已经得到问题的可行解 else t++ } } } else t--; } }
在用回溯法求解问题时,常常遇到两种典型的解空间树: (1)子集树(Subset Trees):当所给问题是从n个元素的集合中找出满足某种性质的子集时,相应的解空间树称为子集树。在子集树中,|S1|=|S2|=…=|Sn|=c,即每个结点有相同数目的子树,通常情况下c=2,所以子集树中共有2^n个叶子结点,因此,遍历子集树需要O(2^n)时间。 (2)排列树(Permutation Trees):当所给问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。在排列树中,通常情况下,|S1|=n,|S2|=n-1,…,|Sn|=1,所以,排列树中共有n!个叶子结点,因此,遍历排列树需要O(n!)时间。
当所给问题是从n个元素的集合S中找出S满足某种性质的子集时,相应的解空间称为子集树。比如:n个物品的0-1背包问题所相应的解空间是一棵子集树,这类子集树通常有2^n个叶结点,其结点总数为(2^(n+1))-1。遍历子集树的算法通常需O(2^n)计算时间。
回溯法搜索子集树的算法一般可以描述如下:
void backtrack(int t) { if (t > n) output(x); else for (int i = 0; i < l; i++) { x[t] = i; if (constraint(t) && bound(t)) backtrack(t + 1); } }
当所给问题的确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树通常有n!个叶结点。因此遍历排列树需要O(n!)计算时间。旅行售货员问题的解空间是一棵排列树。
回溯法搜索排列树的算法一般可以描述如下:
void backtrack(int t) { if (t > n) output(x); else for (int i = t; i < n; i++) { swap(x[t], x[i]); if (constraint(t) && bound(t)) backtrack(t+1); swap(x[t], x[i]); } }
说了这么多的理论,我们再来看一个题目。八皇后问题 :在8×8的棋盘上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上。可以把八皇后问题扩展到n皇后问题,即在n×n的棋盘上摆放n个皇后,使任意两个皇后都不能处于同一行、同一列或同一斜线上。 怎么做?很显然,棋盘的每一行上可以而且必须摆放一个皇后,所以n皇后问题的可能解用一个n元向量X=(x1, x2, …, xn)表示,其中1≤i≤n并且1≤xi≤n,即第i个皇后放在第i行第xi列上。由于两个皇后不能位于同一列上,所以,解向量X必须满足约束条件: xi≠xj 。若两个皇后摆放的位置分别是(i, xi)和(j, xj),在棋盘上斜率为-1的斜线上,满足条件i-j= xi-xj,在棋盘上斜率为1的斜线上,满足条件i+j= xi+xj,综合两种情况,由于两个皇后不能位于同一斜线上,所以解向量X必须满足约束条件: |i-xi|≠|j-xj| 。 为了简化问题,我们只讨论一下四皇后问题。 四皇后问题的解空间树是一个完全4叉树,树的根结点表示搜索的初始状态,从根结点到第2层结点对应皇后1在棋盘中第1行的可能摆放位置,从第2层结点到第3层结点对应皇后2在棋盘中第2行的可能摆放位置,依此类推。
搜索过程
首先对每一行的皇后位置的列进行初始化,然后从第一列开始搜索回溯,找到一个解就输出,全部找到程序结束。 代码
void Queue(int n) { int i; for (i=1;i<=n; i++) //初始化 x[i]=0; k=1; while(k>=1) { x[k]=x[k]+1; //在下一列放置第k个皇后 while((x[k]<=n)&&(!Place(k))) x[k]=x[k]+1; //搜索下一列 if((x[k]<=n)&&(k==n)) { //得到一个解,输出 for (i=1;i<=n; i++) cout<<x[i]; cout<<endl; } else if ((x[k]<=n)&&(k<n)) k=k+1; //放置下一个皇后 else { x[k]=0; //重置x[k],回溯 k=k-1; } } } bool Place(int k) //考察皇后k放置在x[k]列是否发生冲突 { int i; for (i=1;i<k;i++) if ((x[k]==x[i] )||(abs(k-i)==abs(x[k]-x[i]))) return false; return true; }
图的着色问题 图着色问题描述为:给定无向连通图G=(V, E)和正整数m,求最小的整数m使得用m种颜色对G中的顶点着色,使得任意两个相邻顶点着色不同。 由于用m种颜色为无向图G=(V, E)着色,其中V的顶点个数为n,可以用一个n元组C=(c1, c2, …, cn)来描述图的一种可能着色,其中,ci∈{1, 2, …, m}(1≤i≤n)表示赋予顶点i的颜色。例如,5元组(1, 2, 2, 3, 1)表示对具有5个顶点的无向图的一种着色,顶点1着颜色1,顶点2着颜色2,顶点3着颜色2,如此等等。 如果在n元组C中,所有相邻顶点都不会着相同颜色,就称此n元组为可行解,否则为无效解。 首先把所有顶点的颜色初始化为0,然后依次为每个顶点着色。在图着色问题的解空间树中,如果从根结点到当前结点对应一个部分解,也就是所有的颜色指派都没有冲突,则在当前结点处选择第一棵子树继续搜索,也就是为下一个顶点着颜色1,否则,对当前子树的兄弟子树继续搜索,也就是为当前顶点着下一个颜色,如果所有m种颜色都已尝试过并且都发生冲突,则回溯到当前结点的父结点处,上一个顶点的颜色被改变,依此类推。
代码
void GraphColor(int n,int m) { //所有数组下标从1开始 int i for (i=1;i<=n;i++ ) //将数组color[n]初始化为0 color[i]=0; k=1; while(k>=1) { color[k]=color[k]+1; while ((color[k]<=m)&&(!Ok(k))) color[k]=color[k]+1; //搜索下一个颜色 if (color[k]<=m && k==n) //求解完毕,输出解 { for (i=1;i<=n;i++) cout<<color[i]; cout<<endl; } else if (color[k]<=m && k<n) k=k+1; //处理下一个顶点 else { color[k]=0; k=k-1; //回溯 } } } bool Ok(int k) //判断顶点k的着色是否发生冲突 { int i; for (i=1;i<k; i++) if ((c[k][i]==1)&&(color[i]==color[k])) return false; return true; }
回溯法就说到这里,以后还有新的想法,继续更新。
转载请注明出处http://blog.csdn.net/liangbopirates/article/details/10242563