六大算法之二:回溯法

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

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

应用场景:回溯法的应用背景说大很大,说小很小。算法大都在“不得不”的状况 下才会使用,若是有别的算法,那它颇有可能比回溯法高效,别忘了,回溯法是基于穷举的。回溯法适用于解排列组合类问题,也就是说目标解是从一个候选空间中选择出来的。从数量级上考虑,设候选空间的大小为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步的可选值的个数。app

肯定了状态空间树,那下一步就是搜索了。这时候就体现出回溯法的优点了,前面不是说了嘛,回溯法的特色就是有规律、有组织的进行搜索,那下面就来看一下回溯法是如何进行搜索的:(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了,也就是说咱们获得了问题的一个可行解,这时候就要看题设要求了,若是只要求找到一个可行解,那此时算法就能够中止了。ide

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

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

代码实现:
url

用回溯法解题通常思想为:spa

  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<max;i++){
				array[n] = i;
				if(judge(n)){
					check(n+1);
				}
			}
		}
	}
    
    private boolean judge(int n) {
    	for(int i=0;i<n;i++){
    		/*
    		 * 判断是否在同一列,只须要看array这两个位置上的数字是否相同
    		 * 判断是否在同一斜线,将棋盘看作一个二维数组,由于不用的棋子在不一样行,因此纵坐标就是哪一个棋子,横坐标就是列,即array的数字
    		 * 若是两个棋子坐标为(a1,b1)(a2,b2),当|a2-a1|=|b2-b1|时,才能证实不在同一斜线
    		 */
    		if(array[i] == array[n] || Math.abs(n-i) == Math.abs(array[n]-array[i])){
    			return false;
    		}
    	}
    	return true;
	}

	private void print() {
    	for(int i=0;i<array.length;i++){
    		System.out.print(array[i]+1+" ");
    	}
    	System.out.println();
	}

	public static void main(String[] args) {
		new EightQueen().check(0);
	}
}
 
通过两段代码的比较可发现,两者的类似程度是很高的,尤为是主循环部分,只是更改了少量代码。下面再看着色问题的代码。 

[cpp]  view plain  copy
  1. <pre name="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<n;i++) x[i]=0;//初始化解向量  
  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<String> Permutation(String str) {
		 ArrayList<String> 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<String> list) {
		if(i == charArray.length-1){
			list.add(String.valueOf(charArray));
		}else{
			for(int j=i;j<charArray.length;++j){
				if(j==i || charArray[j]!=charArray[i]){
					swap(charArray,i,j);
					PermutationHelper(charArray, i+1, list);
					swap(charArray,i,j);
				}
			}
		}
	}

	private static void swap(char[] charArray, int i, int j) {
		char temp = charArray[i];
		charArray[i] = charArray[j];
		charArray[j] = temp;
	}

 
里面的部分代码没有实现,这一部分也刚好是两个问题不一样的部分,即判断当前的解是不是部分可行解。 
因为篇幅问题,就不深究这两个例子了,关键但愿你们能从中体会出回溯法的模式,具体实现仍是要你们仔细琢磨的。 
 

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