六大算法之二:回溯法

背景介绍:回溯法是一种穷举类型的算法,与其说它是一种算法,倒不如说它是一种试法。回溯法并没有什么高深的算法思想,虽然名字起的很高规格,但其实它的算法性连二分查找都比不上。这里说的算法性其实就是指技巧性,对问题特性了解越深入,越能创造出很巧妙的算法,在时间复杂度的级别上提高算法效率。这体现了算法效率与适用性之间的矛盾,二分查找效率很高,但适用性比较低,类似的还有著名的KMP算法。而穷举法效率最低,但几乎适用于所有问题。

好像有点儿扯远了,我们还是接着说回溯法。回溯法是一种试探性算法,从这一点上看,它很像穷举法。但它终究不是穷举法,回溯法是有组织的进行穷举,在试探过程中不断通过题设要求减少搜索空间,而这种减少不是一个一个解的减少,而是对搜索空间进行大规模剪枝,从而使得实际搜索空间远远小于问题的解空间,所以回溯法的实际运行效率还是比较高的。

应用场景:回溯法的应用背景说大很大,说小很小。算法大都在“不得不”的情况 下才会使用,如果有别的算法,那它很有可能比回溯法高效,别忘了,回溯法是基于穷举的。回溯法适用于解排列组合类问题,也就是说目标解是从一个候选空间中选择出来的。从数量级上考虑,设候选空间的大小为n,如果选择是可重复的,那生成的搜索树为完全n叉树,搜索空间为n^n(如0-1背包问题,生成的解空间为高度为n完全二叉树,其中n为物体个数)。如果选择不能重复,那生成的解空间为n!(如TSP问题生成的解空间为n!,其中n为城市个数)。也就是说,当我们通过分析发现问题的解空间为n^n或者n!时,那很可能要用到我们的回溯法了。

搜索策略:要用回溯法解决问题,那首先要确定问题的状态空间树。这个并不是很难,就看每一步选择有多少个可选值就可以了,第一步有8个可选值,那树第一层就有8个节点,第二步有5个可选值,那第一层每个节点都有5个分支,则第二层有8×5=40个节点,以此类推……到第n层一共有m1×m2×……×mn个节点,其中mi为第i步的可选值的个数。

确定了状态空间树,那下一步就是搜索了。这时候就体现出回溯法的优势了,前面不是说了嘛,回溯法的特点就是有规律、有组织的进行搜索,那下面就来看一下回溯法是如何进行搜索的:(PS:这部分内容大都取自清华大学郑宗汉的《算法设计与分析》一书,当然也有自己的一些理解)在开始搜索之前,我们先来说一下我们要做的事情,我们要得到一个解向量solution,每个分量对应每一步选择的结果,显然这个解向量的长度应该为n(我们采用C语言的标准,下标范围为0到n-1)。好了,现在我们有了一个状态空间树(逻辑上的,并不用实现)和一个解向量(物理上的,要用来装数据的)。现在可以开始搜索了,先设定一个下标r,这个r就是解向量的下标,也用于标识状态树的第r行。先做第一步,令r=0,选solution[0],也就是从树的第0行选择一个值放入solution[0],显然刚开始我们应该选择第一个,即前面提到的8个里面的第一个。然后看这个半成品解向量是否是可行的,也就是说看看刚才选择的那个值是否满足要求,加入那个值不满足要求,那应该选择第二个,以此类推直到选择一个可行的值,放入solution[0]。然后r++进行第二步,选择solution[1],同样的,我们应该从树的第二行中选择第一个看构成的解是否可行(此时解向量中包含两个元素),这样的步骤一直进行下去,直到出现这样的情况

(1)r=n-1了,也就是说我们得到了问题的一个可行解,这时候就要看题设要求了,如果只要求找到一个可行解,那此时算法就可以停止了。

(2)某一层的候选值选完了,我们知道,没一层的候选值都有一定个数,如上面提到的例子中第二层只有5个候选值,如果这五个候选值都试探完了还是没有可行解那该怎么办呢?这里体现的思想就是我们回溯法名字的由来,回溯。也就是令r--退回去,从新选择上面的解。比如上面的例子先选择8个中的第一个作为解的一部分,然后发现后面的5个和前面这个都不能组成可行解,那这就说明前面那个选择是不可行的,和后面是不搭配的。所以应该返回去选择8个中的第二个,然后再对5个进行选择,看哪个与这个第二个想匹配。

(3)最后一种情况,因为我们这个过程中有回溯过程,即r--的过程,那可能最后r小于0了,这说明整个树都搜索完了,也就是问题没有可行解。

代码实现:

用回溯法解题一般思想为:

  1. 在解空间树中,从根节点出发,采用深度优先搜索的思想来遍历解空间树。每一次遍历节点时都判断当前 节点是否为合法解,如果为合法解,那么继续遍历其自子树,如果不是合法节点,那么访问其下一个兄弟节点,如果没有下一个兄就退回到父节点(回溯),访问父节点下一个兄弟节点。
  2. 回溯法结束的条件是回溯到根节点而且所有子树均已遍历到。
  3. 回溯法归根结底是一种带有节点判断条件的深度优先搜索算法。

回溯法解题一般步骤:

(1)针对所给问题,确定问题的解空间:

            首先应明确定义问题的解空间,问题的解空间应至少包含问题的一个(最优)解。

(2)确定结点的扩展搜索规则

(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

回溯法一般有两种代码实现方案,递归方法和非递归方法。相比之下,递归设计方法比较简单,用前面提到的r作为递归变量即可,如果满足搜索条件,则递归调用r+1对应函数,如果不满足,则递归调用r-1对应的函数。基础步为当r<0或r=n-1分别对应无解和得到可行解,这个就不多说了。非递归方法,也就是循环方法设计细节比较多,但只要掌握了其特点,对不同问题的适用性很强(即代码只通过很少的修改就可以应用到不同问题),加之其效率高于递归算法(循环的优势),所以这里我们着重讲一下回溯的非递归代码实现。

算法框架:

(1)问题框架

      设问题的解是一个n维向量(a1,a2,………,an),约束条件是ai(i=1,2,3,…..,n)之间满足某种条件,记为f(ai)。

(2)非递归回溯框架

      int a[n],i;
   2: 初始化数组a[];
   3: i = 1;
   4: while (i>0(有路可走)   and  (未达到目标))  // 还未回溯到头
   5: {
   6:     if(i > n)                                              // 搜索到叶结点
   7:     {   
   8:           搜索到一个解,输出;
   9:     }
  10:     else                                                   // 处理第i个元素
  11:     { 
  12:           a[i]第一个可能的值;
  13:           while(a[i]在不满足约束条件且在搜索空间内)
  14:           {
  15:               a[i]下一个可能的值;
  16:           }
  17:           if(a[i]在搜索空间内)
  18:          {
  19:               标识占用的资源;
  20:               i = i+1;                              // 扩展下一个结点
  21:          }
  22:          else 
  23:         {
  24:               清理所占的状态空间;            // 回溯
  25:               i = i –1; 
  26:          }
  27: }

(3)递归的算法框架

         回溯法是对解空间的深度优先搜索,在一般情况下使用递归函数来实现回溯法比较简单,其中i为搜索的深度,框架如下:

      int a[n];
   2: try(int i)
   3: {
   4:     if(i>n)
   5:        输出结果;
   6:      else
   7:     {
   8:        for(j = 下界; j <= 上界; j=j+1)  // 枚举i所有可能的路径
   9:        {
  10:            if(fun(j))                 // 满足限界函数和约束条件
  11:              {
  12:                 a[i] = j;
  13:               ...                         // 其他操作
  14:                 try(i+1);
  15:               回溯前的清理工作(如a[i]置空值等);
  16:               }
  17:          }
  18:      }
  19: }
 
  

回溯法有“通用解题法”之称。用它可以系统地搜索问题的所有解。回溯法是一个既带有系统性又带有跳跃性的搜索

算法。

    在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一

结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的

解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。若用回溯法求问题的所有解

时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。 而若使用回溯法求任一个解时,只要搜索到

问题的一个解就可以结束。

应用举例:纸上谈兵已经做完了,该来点儿实践了。这里举三个小例子以示回溯法的基本应用,更高深的留待大家自己去研究。

第一个例子是八皇后问题,是回溯法应用中很典型的一个。还有一个是图的着色问题,也是很明显的回溯问题。最后一个是全排列问题,也可以用回溯法来解。

直接上代码吧:

八皇后问题:

在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。

思路是按行来规定皇后,第一行放第一个皇后,第二行放第二个,然后通过遍历所有列,来判断下一个皇后能否放在该列。直到所有皇后都放完,或者放哪都不行。第一个皇后先放第一行第一列,然后第二个皇后放在第二行第一列、然后判断是否OK,然后第二列、第三列、依次把所有列都放完,找到一个合适继续第三个皇后,还是第一列、第二列……直到第8个皇后也能放在一个不冲突的位置,算是找到了一个正确解。然后回头继续第一个皇后放第二列,后面继续循环……

public class EightQueen {
	
	//一共有多少个皇后(此时设置为8皇后在8X8棋盘,可以修改此值来设置N皇后问题)
    int max = 8;
    //该数组保存结果,第一个皇后摆在array[0]列,第二个摆在array[1]列
    int[] array = new int[max];
    
    /*
     * n代表是第几个皇后,皇后n在第array[n]列
     */
	public void check(int n){
		//终止条件是最后一行已经摆完,因为每摆一行都会判断,所以只要最后一行摆完,说明得到了一个正确解
		if(n==max){
			print();
			return;
		}else{
			//从第一列开始摆,判断是否和本行本列本斜线有冲突,如果ok,就进入下一列
			for(int i=0;i
 
  经过两段代码的比较可发现,二者的相似程度是很高的,尤其是主循环部分,只是更改了少许代码。下面再看着色问题的代码。 
  

[cpp]  view plain  copy
  1. "code" class="cpp">bool placeOk(int *x,int k,int **c,int n)  
  2. {  
  3.     //自己可实现  
  4. }  
  5. bool m_colouring(int n,int m,int x[],int **c)  
  6. {  
  7.     //输入:n为顶点个数,m为颜色种类,x为解向量,c为邻接矩阵  
  8.     for(int i=0;i//初始化解向量  
  9.     int i = 0; bool flag = false;//初始化  
  10.     while(i>=0){  
  11.         while(x[i]<=m){  
  12.             if(placeOk(x,i,c,n)){//得到可行解  
  13.                 if(i==n-1) {flag=truebreak;}//得到最终可行解,退出  
  14.                 else{//得到部分可行解,搜索下一行  
  15.                     i++; x[i]=0;  
  16.                 }  
  17.             }  
  18.             else{//当前解不可行  
  19.                 x[i]++;  
  20.             }  
  21.         }  
  22.         if(flag) break;  
  23.         x[i]=0;i--;x[i]++;//回溯  
  24.     }  
  25.     if(flag)return true;  
  26.     else return false;  
  27.   
  28. }  

全排列代码:

全排列问题,输入一个字符串,输出字符串全部排列组合,可能有重复字符固定第一个字符,递归取得首位后面的各种字符串组合;再把第一个字符与后面每一个字符交换,并同样递归获得首位后面的字符串组合; 

递归的出口,就是只剩一个字符的时候,递归的循环过程,就是从每个子串的第二个字符开始依次与第一个字符交换,然后继续处理子串。

假如有重复值呢?

由于全排列就是从第一个数字起,每个数分别与它后面的数字交换,我们先尝试加个这样的判断——如果一个数与后面的数字相同那么这两个数就不交换了。例如abb,第一个数与后面两个数交换得bab,bba。然后abb中第二个数和第三个数相同,就不用交换了。但是对bab,第二个数和第三个数不 同,则需要交换,得到bba。 * 由于这里的bba和开始第一个数与第三个数交换的结果相同了,因此这个方法不行。 * 换种思维,对abb,第一个数a与第二个数b交换得到bab,然后考虑第一个数与第三个数交换,此时由于第三个数等于第二个数, * 所以第一个数就不再用与第三个数交换了。再考虑bab,它的第二个数与第三个数交换可以解决bba。此时全排列生成完毕!
public static ArrayList Permutation(String str) {
		 ArrayList list = new ArrayList<>();
		 if(str!=null && str.length()>0){
			 PermutationHelper(str.toCharArray(),0,list);
			 Collections.sort(list);
		 }
		 return list;
   }

	private static void PermutationHelper(char[] charArray, int i, ArrayList list) {
		if(i == charArray.length-1){
			list.add(String.valueOf(charArray));
		}else{
			for(int j=i;j

 
  里面的部分代码没有实现,这一部分也恰好是两个问题不同的部分,即判断当前的解是否是部分可行解。 
  由于篇幅问题,就不深究这两个例子了,关键希望大家能从中体会出回溯法的模式,具体实现还是要大家仔细琢磨的。 
   
  

最后总结:通过比较上面三段代码可发现,这几乎就是复制粘贴出来的。这说明回溯法是一种通用性很高的算法模型,这是因为我们回溯法面向的是一棵空间搜索树,这课树已经完成了从实际问题到数学表达的建模。而每棵树的特性都是相当一致的,所以我们的算法也具有高度的一致性。从这个角度看,一旦掌握了回溯法,那以后用起来是比较简单的,所以回溯法是一个很值得学习的算法。

你可能感兴趣的:(六大算法之二:回溯法)