算法——实验 4《回溯法实验》

实验 4《回溯法实验》

一、实验目的

  1. 掌握回溯算法思想
  2. 掌握回溯递归原理
  3. 了解回溯法典型问题

二、实验内容

  1. 编写一个简单的程序,解决 8 皇后问题。

  2. 批处理作业调度问题
    [问题描述]给定 n 个作业的集合 J= (J1, J2, … , Jn)。每一个作业 Ji 都有两项任务 需要分别在 2 台机器上完成。每一个作业必须先由机器 1 处理,然后再由机器 2 处理。作业j需要机器 i 的处理时间为 tji,i=1,2, … ,n; j=1,2。
    对于一个确定的作业调度,设 Fji 是作业 i 在机器 j上完成处理的时间。则所有作业在机器 2 上完成处理的时间和成为该作业调度的完成时间和。

    批处理作业调度问题要求对于给定的 n 个作业,制定一个最佳的作业调度方案,使其完成时间和达到最小。
    要求输入:
    1)作业数 2)每个作业完成时间表:

     完成时间   	   机器 1	  机器 2
     作业 1	         2         	1
     作业 2	         3          1
     作业 3	         2         	3
    

    要求输出: 1)最佳完成时间 2)最佳调度方案
    提示:算法复杂度为 O(n !),建议在测试的时候 n 值不要太大,可以考虑不要超过 12。

  3. 数字全排列问题
    任意给出从 1 到 N 的 N 个连续的自然数,求出这 N 个自然数的各种全排列。如 N=3 时, 共有以下 6 种排列方式:123,132,213,231,312,321。
    注意:数字不能重复,N 由键盘输入(N<=9)。

三、算法思想分析:

回溯算法:

带优化的穷举法,具有限界函数的深度优先搜索算法。
优化是指在搜索过程中用剪枝函数避免无效搜索。
常用剪枝函数有两种(不一定两种同时存在,但有可能两种同时存在):
  1. 约束函数,剪去不满足约束条件的左子树;
  2. 限界函数,剪去得不到最优解的右子树

可以解决的问题:

  1. 组合问题
  2. 切割问题:给一个字符串及约束条件,问有几种切割方式
  3. 排列问题:与组合的区别在于排列强调顺序
  4. 子集问题
  5. 棋盘问题:如N皇后问题

回溯算法的理解:

  1. 递归是有终止的,回溯与递归相辅相成,可以抽象为一棵n叉树。树的宽度为处理的集合大小,常用for循环遍历;树的深度即为递归的深度。
  2. 通常处理逻辑:
	void backtrack(){
		if(终止条件){
			收集结果;
			return;
`		}
		for(集合元素){
			处理结点;
			递归函数;
			回溯操作;//撤销处理结果
		}
	}
  1. 通过递归控制有多少层for循环,递归的每一层相当于一个for循环。for循环横向遍历,递归纵向遍历,回溯不断调整结果集。

  2. 八皇后问题:约束条件(没有限界函数)——同一列/斜线上是否已经放了一个皇后(行不用管,因为就是在找当前行能不能放皇后,一行一行地找,若在该行遍历完n之前能够找到位置放置皇后,则向下一行递归,若没有位置可放,则向上回溯)

  3. 批处理作业调度问题:剪枝函数——total < bestT(当前用时比已找到的bestT小才往下遍历)

  4. 排列问题:约束条件——当前数字是否已经被排列

四、实验过程分析

1) 遇到的问题及解决

n皇后问题

  1. 与类似组合问题的情况不同,不能像组合问题那样在函数最开始判断i>n时就output,因为n皇后问题中并不是走到最深层就是一个解了,还要判断在该层上能不能找到一个符合要求的位置来摆放第n个皇后。而在组合问题中只要深度符合要求了就已经是一个解了,没有其他要求。n皇后问题的输出需要在摆放好皇后后进行判断,看本次放的是不是最后一个皇后,只有当放的是最后一个皇后时才是可行解。
  2. 刚开始我将当前处理的是第几行作为类的静态变量处理,结果最终只输出了一种解决方案。后来将改为将处理第几行作为参数传入backtrack函数后就输出了所有结果。
    这个参数不能作为类的静态成员变量,因为若将i设为静态变量,它在所有实例之间是共享的,在一次递归中递增i,然后在另一次递归中回溯i时,这个修改会影响到所有的递归调用,每次递归调用都在同一个i上进行操作,导致程序只能得到一个解!以后要注意,类似情况中,与递归深度相关的变量不能作为类的成员变量(能否作为类的成员变量处理需要谨慎考虑),否则在某次递归中对其进行修改就会影响所有递归的深度,会出问题的。
  3. 回溯的时机要找准,刚开始误将回溯放在了递归调用backtrack的那个else分支中,结果只输出了一个解。因为当i = n-1时,即当找到一个解后,就没有回溯了,相当于这个程序只是用来找出一个解的,而不是找出所有解。这个问题的根本原因跟将i作为静态成员的情况一样,都是用来找出一个可行解的,而不是所有可行解。

批处理作业调度问题

  1. 刚开始我一直在思考这道题是不是需要构造两棵解空间数,一个是机器1的,一个是机器2的,但是机器2的调度是有要求的,需要先被机器1处理完才能被2调度,那么我是不是需要再用一个数据结构来保存机器1的处理顺序呢?机器2的解空间树依赖于机器1的调度顺序,所以其约束函数是此时该作业是否在机器1上执行完成?这样一来感觉问题变得比较复杂,不太清楚到底该怎么下手了。
    到网上搜了一下,发现他们都说易证当两台机器的调度顺序一致时用时最小,这怎么就易证了呢,我还觉得应该用抢占式调度完成时间才比较短呢,FCFS不一定最短吧,搜了半天也没搜出个所以然来,暂时就默认这样吧------好吧,画出图后好像确实易证调度顺序一致时用时最短?
    数字全排列问题
  2. 递归终止条件写错了,误写成了i == n-1时结束,其实应该是i > n – 1(或者i == n)时,因为n-1处也是需要排数字的,如果i == n-1时结束则最后一位还没有排,故输出时最后一位全为0

2) 实验体会

回溯算法比较灵活,我觉得是比动态规划要难的,动规掌握原理后难点弄清楚那道题的动规五部曲,弄清楚后思路是很清晰的,但是回溯算法的剪枝函数非常灵活,要分析其约束条件或限界函数或者两个可能同时存在,这个问题就需要一定熟练度,要对题目较为敏感,反正对我来说还是很有难度的,特别是批处理作业调度那道题,可能学了操作系统后我反而将题目考虑复杂了,总觉得完成时间不只是简单的将时间相加,应该还要比较值。还有就是什么时候需要回溯,回溯的时机要能够准确找出。

五、算法源代码及用户屏幕

1.八皇后问题:

源代码:
public class NQueens {
private static int n = 8;//n个皇后
private static int count = 0;//统计解决方法的数量
private static boolean[]col = new boolean[n];//某列是否有皇后
private static boolean[]left = new boolean[2n];//左斜线是否有皇后
private static boolean[]right = new boolean[2
n];//右斜线是否有皇后
private static int[][]chessBoard = new int[n][n];//棋盘,若未放皇后,则用0表示

public static void main(String[] args) {
    backtrack(0);
    System.out.println("共计" + count + "种摆放方法");
}
private static void print(){//打印棋盘
    for(int i = 0; i < n; i++){
        for(int j = 0; j < n; j++){
            System.out.print(chessBoard[i][j] + "	");
        }
        System.out.println();//换行
    }
}
private static void backtrack(int i){//第i行
    for(int j = 0; j < n; j++){//第i行有没有可以放皇后的位置
        if(!col[j] && !left[i + j] && !right[i - j + n]){
            //放入皇后
            chessBoard[i][j] = i + 1;//表示这是第几个放入的皇后
            //修改其所在列、斜线的标志
            col[j] = left[i + j] = right[i - j + n] = true;
            if(i == n - 1){
                count++;
                print();
                System.out.println();
            }else {
                //处理下一行
                backtrack(i + 1);
            }
            //回溯
            {
                chessBoard[i][j] = 0;//拿走皇后
                //修改相关标志
                col[j] = left[i + j] = right[i - j + n] = false;
            }
        }
    }
}

}

用户屏幕:
在这里插入图片描述

2.批处理作业调度问题:

源代码:

import java.util.Scanner;

public class JobScheduling_backtrack {
    private static int n;//总作业数
    private static int[][] needTime;//每个作业在两台机器上各自所需用时
    private static int time1;//作业在机器1上完成处理的时间
    private static int[]time2;//机器2完成处理时间
    private static int total;//总计用时,即前i个作业在机器2上完成处理的时间和
    private static int bestT = Integer.MAX_VALUE;//最佳完成时间
    private static int[]path;//当前调度方案
    private static int[]bestPath;//当前最佳调度方案

    public static void main(String[] args) {
        Scanner input = new Scanner(System.in);
        System.out.print("请输入总作业数:");
        n = input.nextInt();
        needTime =  new int[n][2];
        time2 = new int [n];
        path  = new int[n];
        //初始化path
        for(int i = 0; i < n; i++){
            path[i] = i;
        }
        bestPath = new int[n];
        System.out.println("请分别输入每个作业需要在两台机器上的运行时间,如2 1,表示需要在第一台机器上运行2s,需要在第二台机器上运行1s");
        for(int i = 0; i < n; i++){
            needTime[i][0] = input.nextInt();
            needTime[i][1] = input.nextInt();
        }
//        for(int i = 0; i < n; i++){
//            System.out.println(needTime[i][0] + "	" + needTime[i][1]);
//        }
        backtrack(0);
        for(int i = 0; i < n; i++){
            System.out.print(bestPath[i] + "	");
        }
        System.out.println("
共计用时" + bestT);
    }
    private static void backtrack(int i){//第i次调度哪个作业
        if(i > n-1){//一种调度方案
            //不用再比较了,因为total < bestT是用来剪枝的,当遍历到叶子结点时,这个就是最优解
//            if(total < bestT){//若比当前最优方案用时少,则更新方案
                bestT = total;
                System.arraycopy(path, 0 , bestPath, 0, path.length);
        }else {
            for(int j = i; j < n; j++){
                time1 += needTime[path[j]][0];//机器一的完成时间
                if(i > 0){//如果选的不是第一个调度,则要判断当前机器一和机器二谁后执行完,用后执行完的时间(即更长的那个时间)加上这个作业要在机器二上运行的时间
                    time2[i] = ((time2[i-1] > time1) ? time2[i-1] : time1) + needTime[path[j]][1];
                }else {//如果选的是第一个调度谁,则time2就等于该作业需要的两个机器运行时间和
                    time2[i] = time1 + needTime[path[j]][1];
                }
                total += time2[i];
                if(total < bestT){
                    swap(i, j);
                    backtrack(i + 1);
                    swap(i, j);
                }
                //回溯
                time1 -= needTime[path[j]][0];
                total -= time2[i];
            }
        }
    }

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

用户屏幕:
在这里插入图片描述
在这里插入图片描述

3.数字全排列问题:

源代码:

import java.util.Scanner;

public class Permutation {
    private static int n;
    private static int[]path;//保存当前排列
    private static int count;//共计多少种排列方法
    private static boolean[]isP;//是否已经被排列
    public static void main(String[] args) {
        Scanner input = new Scanner(System.in);
        System.out.print("请输入n: ");
        n = input.nextInt();
        path = new int[n];
        isP = new boolean[n];
        backtrack(0);
        System.out.println("共计" + count + "种排列方式");
    }
    private static void backtrack(int i){
        if(i == n){
            print();
            count++;
            return;
        }
        for(int j = 0; j < n; j++){
            if(!isP[j]){//约束条件:不能重复出现
                isP[j] = true;
                path[i] = j + 1;//第i+1个位置排自然数j+1
                backtrack(i + 1);
                isP[j] = false;//回溯
            }
        }
    }
    private static void print(){
        for(int i = 0; i < n; i++){
            System.out.print(path[i]);
        }
        System.out.println();
    }
}

用户屏幕:
在这里插入图片描述
在这里插入图片描述