回溯法及常见例子

一、理论基础

  回溯法作为一种常见的算法思想,其概念为:一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。

1.1 基本策略

  回溯法的策略是:在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。

1.2 适用场景

  因为回溯法的本质与类似穷举(但回溯法和穷举法的思想相近,不同在于穷举法是将所有的情况都列举出来以后再进行筛选,而回溯法在列举过程如果发现当前情况根本不可能存在,就返回上一步进行新的尝试),所以效率较低,最常用到的场景是搜索。

1.3 使用步骤

  使用回溯法的基本步骤:
   1>定义问题的解空间。
   2>确定易于搜索的解空间结构。
   3>以深度优先搜索的策略搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

1.4 经典例子

  常见例子如下:
   1>八皇后问题
   2>装载问题
   3>批处理作业调度问题
   4>背包问题
   5>最大团问题
   6>连续邮资问题
   7>符号三角形问题
  接下来将对这些例子进行实现,来探究回溯法的具体使用方法。

1.5 提高效率的方法

  提高回溯法效率的方法有两种:
   1>约束函数:减去不满足约束的子树。
   2>限界函数:剪去得不到最优解的子树。

二、常见例子

2.1 八皇后问题

  该问题的描述是:在8×8格的国际象棋上摆放8个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。该问题有多种解法,递归法是最简单的一种,本文使用递归法,来借这个问题来介绍回溯法。
  如果是初次接触该问题,一看到这个题目可能会觉得手足无措。可以尝试着将题目转换为:在一个8x8的二维数组上,每次在每一行放置一个元素,使得这8行的元素互相不在同一行、同一列或同一斜线上。将八皇后问题转换成这样的描述后,问题的难点就转换成了如何“判断要放置的元素和之前放置过的元素互相不在同一行、同一列或同一斜线上?”在解决该难点问题上,可以用判断同一列上是否存在皇后来举个例子:

		/*判断同一列中是否存在1,即皇后*/
		for(i=0;i<8;i++){
			if(chess[i][col]==1)
				return false;
		}

  假如8x8棋盘原始的元素都是0,在某行某列放置皇后后,该位置的元素改为1。所以可以通过该列的8行是否存在元素1,来判断该列是否存在皇后。
  在解决八皇后问题的过程中,回溯思想体现在放置皇后位置的可撤销性上。比如在 ij 列放置了皇后,但是在i+1行放置元素时,通过判断得知,i+1 行的八个位置都不能放置皇后,此时就只能将 ij 列的皇后撤销,继续尝试 ij 列后面的元素。
  基于上面的知识铺垫,我们就可以推导出用递归方法解决八皇后问题的步骤:
   1>搜索。从下标为0的行开始,尝试在该行的某个列放置皇后,然后继续进行下一行的搜索,也就是进行递归的过程。
   2>输出,当搜索的行下标为8时,代表该次搜索已经完成,可以输出结果,总的可能性+1。
   3>判断。这是八皇后算法的核心,判断某行某列的位置上是否可以放置皇后。
  示例代码如下:

package Recall;

/*
 * 在8×8格的国际象棋上摆放8个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。
 */
public class EightQueen {
	
	/*总的可能数,初始化为0,每输出一次结果,自增1*/
	private static int count=0;
	/*创建一个8x8的棋盘,默认初始化元素为0,代表未放置皇后*/
	private static int[][] chess=new int[8][8];
	
	public static void main(String[] args) {
		/*传参0,代表从第一行开始遍历,寻找放置皇后的位置*/
		eightQueen(0);
		System.out.println("八皇后问题总共有"+count+"种结果");
	}
	
	private static void eightQueen(int row){
		/*如果遍历完八行都找到放置皇后的位置则打印*/
		if(row>7){                       
			printQueen();                       
			count++;
			return;
		}
		/*在每一行放置皇后,即遍历某行中的每一列*/
		for(int col=0;col<8;col++){
			/*判断是否可以放置皇后*/
			if(isExistQueen(row,col)){
				/*该位置放置皇后*/
				chess[row][col]=1;
				/*然后继续在下一行进行判断*/
				eightQueen(row+1);
				/*清零,这也是回溯法要注意的地方,一种方法尝试后,需要将之前做的尝试回退,以免影响到下一次尝试*/
				chess[row][col]=0;           
			}
		}
	}
	
	private static void printQueen(){
		System.out.println("第 "+(count+1)+"种结果:");
		for(int row=0;row<8;row++){
			for(int col=0;col<8;col++){
				/*放置皇后*/
				if(chess[row][col]==1){
					System.out.print("q ");
				/*放置士兵*/
				}else{
					System.out.print("s ");
				}
			}
			System.out.println();
		}
		System.out.println();
	}
	
	private static boolean isExistQueen(int row,int col){
		int i,k;
		/*判断同一列中是否存在1,即皇后*/
		for(i=0;i<8;i++){
			if(chess[i][col]==1)
				return false;
		}
		/*判断左对角线位置上是否存在1,即皇后*/ 
		for(i=row,k=col;i>=0&&k>=0;i--,k--){
			if(chess[i][k]==1)
				return false;
		}
		/*判断右对角线位置上是否存在1,即皇后*/ 
		for(i=row,k=col;i>=0&&k<8;i--,k++){
			if(chess[i][k]==1)
				return false;
		}
		return true;
	}
}

  部分测试结果如下:

第 92种结果:
s s s s s s s q
s s s q s s s s
q s s s s s s s
s s q s s s s s
s s s s s q s s
s q s s s s s s
s s s s s s q s
s s s s q s s s
八皇后问题总共有92种结果

2.2 装载问题

  该问题的描述是:一批集装箱共n个,要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为Wi且W1+W2+……+Wn<=c1+c2;试确定一个合理的装载方案使这n个集装箱装上这两艘轮船。
  解决该问题的思路是:先确定是否有解?当W1+W2+……+Wn<=c1+c2时,就代表该问题有解。然后在有解的情况下,进行拆解该问题,可分为两步:
   1>首先将第一艘轮船尽可能装满(在这步中体现了回溯的思想)。
   2>然后将剩余的集装箱装在第二艘轮船上。
  与八皇后问题的解题思路是一样的,不同之处在于解题过程中有这更多的变量。先展示一下解该问题需要的变量:

	/*货箱重量数组*/
	static int[] weights={20,30,60,40,40};
	/*货箱数目*/
	static int boxNum=weights.length;
	/*第一艘船的最大承载量*/
	static int oneShipCapcity=100;
	/*第二艘船的最大承载量*/
	static int twoShipCapcity=100;
	/*当前装载的重量*/
	static int currentWeight=0;
	/*目前最优装载的重量*/
	static int bestWeight=0;
	/*当前解,记录每箱是否装得下的数组,用1和0表示*/
	static int[] currentAnswer=new int[boxNum];;
	/*最优解,记录每箱是否装得下的数组,用1和0表示*/
	static int[] bestAnswer=new int[boxNum];
	/*剩余货箱的重量,即总的重量-装上第一艘船的重量*/
	static int leftWeight;

  接下来,一个一个看一下这些变量。weights数组用来存储每个货箱的重量,也就是需要装到两艘船的元素;boxNum代表货箱数量;oneShipCapcity代表第一艘船的最大载重量,用来衡量哪些货箱可以装到第一艘船;twoShipCapcity代表第一艘船的最大载重量,用来衡量剩下的货箱能否完全装到第二艘船上;currentWeight代表该次尝试装载过程中装载到第一艘船上的总重量,也就是解集树上的一个子树;bestWeight代表第一艘船上的最优装载量,其实就是满足currentWeight<=oneShipCapcity条件时的currentWeight;currentAnswer代表当次子集,用0和1表示是否能将某货箱装到第一艘船上;bestAnswer同理,是满足currentWeight<=oneShipCapcity条件时currentAnswer;leftWeight是将某些货箱装到第一艘船上后剩余重量,leftWeight初始值为所有货箱的总重量,如下:

		//初始化leftWeight,即剩余最大重量
		for(int i=0;i<boxNum;i++) {
			leftWeight+=weights[i];
		}

  基于上面的知识铺垫,我们就可以推导出用递归方法解决装载问题的步骤:
   1>使用回溯法装第一艘船。从下标为0的货箱数组开始装载,在第一艘船上的最重量<=第一艘船总装载量的情况下,继续装载,也就是进行递归的过程。
   2>当装到最后一箱货物时,代表已经装载完第一艘船,接下来计算在第二艘上能否装载完剩余货箱。
   3>在第二艘上能否装载完剩余货箱的情况下,装载完货物,输出具体装载情况。
  示例代码如下:

package Recall;

/*
 * 1.具体问题
 *	  一批集装箱共n个要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为Wi且
 *	 W1+W2+……+Wn<=c1+c2;试确定一个合理的装载方案使这n个集装箱装上这两艘轮船。
 * 2.问题分析
 *   如果一个装载问题有解,则采用下面的策略可以得到最优装载方案:
 *     1)首先将第一艘轮船尽可能装满;
 *     2)然后将剩余的集装箱装在第二艘轮船上。
 */
public class MaxLoading {
    
	/*货箱重量数组*/
	static int[] weights={20,30,60,40,40};
	/*货箱数目*/
	static int boxNum=weights.length;
	/*第一艘船的最大承载量*/
	static int oneShipCapcity=100;
	/*第二艘船的最大承载量*/
	static int twoShipCapcity=100;
	/*当前装载的重量*/
	static int currentWeight=0;
	/*目前最优装载的重量*/
	static int bestWeight=0;
	/*当前解,记录每箱是否装得下的数组,用1和0表示*/
	static int[] currentAnswer=new int[boxNum];;
	/*最优解,记录每箱是否装得下的数组,用1和0表示*/
	static int[] bestAnswer=new int[boxNum];
	/*剩余货箱的重量,即总的重量-装上第一艘船的重量*/
	static int leftWeight;
	
	public static void main(String[] args) {
		//初始化leftWeight,即剩余最大重量
		for(int i=0;i<boxNum;i++) {
			leftWeight+=weights[i];
		}
		//计算最优载重量
		backtrack(0);
		
		/*第二艘船可能要装的重量*/
		int weight2 = 0;
		for(int i=0;i<weights.length;i++){
			/*1-bestAnswer[i],可以将bestAnswer数组中的值进行0-1
			 * 反转,即将不装上第一艘船的箱子全都装上第二艘船
			 */
			weight2 += weights[i]*(1-bestAnswer[i]);
		}
		if(weight2>twoShipCapcity){
			System.out.println("无法载满货物");
		}else{
			System.out.println("第一艘船装载货物的重量: " + bestWeight);
			System.out.println("第二艘船装载货物的重量: " + weight2);
			for(int i=0;i<weights.length;i++){
				//第一艘船的装载情况
				if(bestAnswer[i]==1){
					System.out.println("第"+(i+1)+"件货物装入第一艘船");
				//第二艘船的装载情况
				}else{
					System.out.println("第"+(i+1)+"件货物装入第二艘船");
				}
			}
		}
	}
	
	/*利用回溯思想尽量将第一艘船装满*/
	public static void backtrack(int num){
		/*已经尝试了装载最后一个元素*/
		if(num==boxNum){
			/*最后时刻的装载量,可以更新为最优装载量*/
			if(currentWeight>bestWeight){
				for(int i=0;i<boxNum;i++){
					bestAnswer[i] = currentAnswer[i];
				}
				bestWeight = currentWeight;
			}
			return;
		}
		
		/*如果没尝试装载完最后一箱货物,继续装第num+1箱*/
		leftWeight -= weights[num];
		/*第一艘船能装下第t+1箱货物*/
		if(currentWeight + weights[num] <= oneShipCapcity){
			/*currentAnswer数组用来标识某一箱货物是否装的下,1代表装的下,0代表装不下*/
			currentAnswer[num] = 1;
			currentWeight += weights[num];
			backtrack(num+1);
			/*回溯*/
			currentWeight -= weights[num];
		}
		
		/*第一艘船装不下第num+1箱货物*/
		if(currentWeight + leftWeight>bestWeight){
			/*不装第num+1箱,继续装下一箱*/
			currentAnswer[num] = 0;
			backtrack(num+1);
		}
		/*因为装不下第num+1箱,所以在leftWeight中恢复该数值*/
		leftWeight += weights[num];
	}
}

  测试结果如下:

第一艘船装载货物的重量: 100
第二艘船装载货物的重量: 90
第1件货物装入第一艘船
第2件货物装入第二艘船
第3件货物装入第二艘船
第4件货物装入第一艘船
第5件货物装入第一艘船

2.3 批量作业调度问题

  该问题的描述是:给定n个作业的集合J=(J1,J2,… ,Jn)。每一个作业Ji都有两项任务分别在2台机器上完成。每个作业必须先由机器1处理,然后再由机器2处理。作业Ji需要机器j的处理时间为tji。对于一个确定的作业调度,设Fji是作业i在机器j上完成处理时间。则所有作业在机器2上完成处理时间和f=F2i,称为该作业调度的完成时间和。对于给定的n个作业,指定最佳作业调度方案,使其完成时间和达到最小。
  从题目描述可以看出,该问题和装载问题是有些类似的,不过该问题中,计算时间的过程需要简单说下:
  假设有以下任务,在机器一和机器二上所花费的时间分别如下:

任务 在机器一上所花时间 在机器二上所花时间
任务一 2 1
任务二 3 1
任务三 2 3

  假设调度方案为(1,2,3),那么,所花费的时间情况如下:
   1>作业1在机器1上完成的时间是2,在机器2上完成的时间是3
   2>作业2在机器1上完成的时间是5,在机器2上完成的时间是6
   3>作业3在机器1上完成的时间是7,在机器2上完成的时间是10
  所以,作业调度的完成时间和= 3 + 6 + 10。
  解决该问题的思路是:遍历出所有调度方案的总时间,然后选最小时间的调度方案即可。解该问题需要的变量:

	/*默认作业在第一台机器、第二台机器上所花费的时间*/
	static int[][] mission={{2,1},{3,1},{2,3}};
	/*作业数*/
	static int missionNum=mission.length;
	/*默认最短时间*/
	static int bestTime=100;
	/*默认调度策略*/
	static int[] currentSchedule={0,1,2};
	/*最佳调度策略*/
	static int[] bestSchedule=new int[missionNum];
	/*每个任务的结束时间,即在第一台、第二台机器上都完成任务的时间*/
	static int[] currentOneAndTwoTime=new int[missionNum];
	/*某个任务在第一台机器上花费的时间*/
	static int currentOneTime=0;
	/*总时间*/
	static int totaltime;

  这些变量中,mission是一个二维数组,用来存储每个任务在机器一和机器二上作业所花费的时间;missionNum是总的作业数,等于mission的数量;bestTime是默认最短时间,因为该题是求最小时间,所以默认的时间只要给个大概的、大于所以任务调度的总时间的值即可;currentSchedule是默认调度策略,因为回溯算法,在计算的过程中,有回退操作,所以此处的默认策略并不是很重要,只要给个任务的一种排列就行;bestSchedule是最佳调度策略,记录每次比较后,花费总时间最小的调度策略;currentOneAndTwoTime是当前任务在机器一和机器二上完成所花费的时间;currentOneTime表示某任务在第一台机器上花费的时间,即之前阶段的任务时间和加上当前任务在机器一上所花费的时间;totaltime为所有任务都遍历后所花费的总时间。
  解决该问题的过程中,有两个点需要着重说明下:
   1>每种排列结束后,更新一下最优时间。示例代码如下:

		/*当搜索到叶子节点后,当前调度策略就是最佳调度策略*/
		if(num>missionNum-1){
			bestTime=totaltime;
			for(int i=0;i<missionNum;i++)
				bestSchedule[i]=currentSchedule[i];
			return;
		}

   2>在求每种任务调度的可能时,中间有剪枝操作,即任务还没排列完,用时就已经超过之前的总用时了。示例代码如下:

			if(totaltime<bestTime){
				//把选择出的原来在i位置上的任务序号调到当前执行的位置num
				swap(currentSchedule,num,i);	
				BackTrack(num+1);
				//进行回溯,还原,执行该层的下一个任务。
				swap(currentSchedule,num,i);
			}
			/*如果该作业处理完之后,总时间已经超过最优时间,就直接回溯*/
			currentOneTime=currentOneTime-mission[currentSchedule[i]][0];
			totaltime=totaltime-currentOneAndTwoTime[num];

  基于上面的知识铺垫,我们就可以推导出用递归方法解决该问题的步骤:先按默认1、2、3的顺序计算总用时,然后再遍历其他调度方案的总用时,计算出最少用时。示例代码如下:

package Recall;

/*
 * 给定n个作业的集合J=(J1,J2,... ,Jn)。每一个作业Ji都有两项任务分别在2台机器上完成。每个作业必须先由
 * 机器1处理,然后再由机器2处理。作业Ji需要机器j的处理时间为tji。对于一个确定的作业调度,设Fji是作业i在机器j
 * 上完成处理时间。则所有作业在机器2上完成处理时间和f=F2i,称为该作业调度的完成时间和。对于给定的n个作业,指定
 * 最佳作业调度方案,使其完成时间和达到最小。
 */
 
public class BatchWork {
	/*默认作业在第一台机器、第二台机器上所花费的时间*/
	static int[][] mission={{2,1},{3,1},{2,3}};
	/*作业数*/
	static int missionNum=mission.length;
	/*默认最短时间*/
	static int bestTime=100;
	/*默认调度策略*/
	static int[] currentSchedule={0,1,2};
	/*最佳调度策略*/
	static int[] bestSchedule=new int[missionNum];
	/*每个任务的结束时间,即在第一台、第二台机器上都完成任务的时间*/
	static int[] currentOneAndTwoTime=new int[missionNum];
	/*某个任务在第一台机器上花费的时间*/
	static int currentOneTime=0;
	/*总时间*/
	static int totaltime;

	public static void main(String[] args){
		BackTrack(0);
		System.out.println("最佳调度方案为:");
		for(int i=0;i<missionNum;i++)
			System.out.print((bestSchedule[i]+1)+"  ");
		System.out.print("\n其完成时间为"+bestTime);
	}
	
	public static void BackTrack(int num){
		/*当搜索到叶子节点后,当前调度策略就是最佳调度策略*/
		if(num>missionNum-1){
			bestTime=totaltime;
			for(int i=0;i<missionNum;i++)
				bestSchedule[i]=currentSchedule[i];
			return;
		}
 
		for(int i=num;i<missionNum;i++){	
			/*在第一台机器上花费的时间,即二维数组的第一列值*/
			currentOneTime+=mission[currentSchedule[i]][0];
			if(num==0){
				/*第一个任务的话,直接在两台机器上花费的时间相加就行*/
				currentOneAndTwoTime[num]=currentOneTime+mission[currentSchedule[i]][1];
			}else{
				/*不是第一个任务的话,当前任务在第一台机器上所花费的时间,要选择下面两个时间的较大值:
				 * 1、前一个阶段的任务完成在机器一、机器二上所花费的总时间
				 * 2、当前阶段的任务在机器一上所花费的时间
				 */
				if(currentOneAndTwoTime[num-1]>currentOneTime){
					currentOneAndTwoTime[num]=currentOneAndTwoTime[num-1]+mission[currentSchedule[i]][1];
				}else{
					currentOneAndTwoTime[num]=currentOneTime+mission[currentSchedule[i]][1];
				}
			}
			/*总时间就等于+该任务在第一二台机器上花费的时间*/
			totaltime=totaltime+currentOneAndTwoTime[num];
			if(totaltime<bestTime){
				//把选择出的原来在i位置上的任务序号调到当前执行的位置num
				swap(currentSchedule,num,i);	
				BackTrack(num+1);
				//进行回溯,还原,执行该层的下一个任务。
				swap(currentSchedule,num,i);
			}
			/*如果该作业处理完之后,总时间已经超过最优时间,就直接回溯*/
			currentOneTime=currentOneTime-mission[currentSchedule[i]][0];
			totaltime=totaltime-currentOneAndTwoTime[num];
		}
	}	
	
	public static void swap(int[] arr,int i,int j){
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}
}

  测试结果如下:

最佳调度方案为:
1 3 2
其完成时间为18

猜你喜欢

转载自blog.csdn.net/m0_37741420/article/details/107444661