一、理论基础
回溯法作为一种常见的算法思想,其概念为:一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
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,来判断该列是否存在皇后。
在解决八皇后问题的过程中,回溯思想体现在放置皇后位置的可撤销性上。比如在 i 行 j 列放置了皇后,但是在i+1行放置元素时,通过判断得知,i+1 行的八个位置都不能放置皇后,此时就只能将 i 行 j 列的皇后撤销,继续尝试 i 行 j 列后面的元素。
基于上面的知识铺垫,我们就可以推导出用递归方法解决八皇后问题的步骤:
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