神奇的回溯算法

通俗點描述,回溯是基於遞歸的基礎上使得某一步在迴歸到上一步時能夠改變上一步的策略而產生一種新的遞歸方案,其本質是一種枚舉。所以回溯並不算是一種非常高效的算法,但是是一種很有效的算法,因爲其時間複雜度很高,如果以暴力枚舉爲O(n^n)的時間複雜度,回溯的時間複雜度一般也到底O(n!)。回溯之所以比暴力枚舉的效率高,很多情況下都是剪枝的功勞
下面舉幾個例子來細品回溯的巧妙。
迷宮問題
問題簡述:在一個由兩位數組所構成的迷宮中(迷宮的最外圍是牆,迷宮裏面有檔板)一個小球從任意位置出發,設計一種算法使得小球能夠順利走到迷宮最右下的位置。
思路分析:從起點準備出發,首先我們先指定一個行走策略(這裏定製順序爲下,右,上,左),然後我們通過遞歸的手段按照已制定的策略來判斷此點是否可行(先默認可行,然後通過回溯來確定並修改此默認的判斷),最後小球到達終點,記錄下路徑。
(在這裏,我們只訪問沒有訪問過的點,實質上就是一種剪枝手段)
代碼實現:

public class MiGong {
    public static void main(String[] args) {
        //創建一個二維數組,模擬迷宮
        int[][] map = new int[8][7];
        //使用1表示牆
        for (int i = 0; i < 7; i++) {
            map[0][i] = 1;
            map[7][i] = 1;
        }
        for (int i = 0; i < 8; i++) {
            map[i][0] = 1;
            map[i][6] = 1;
        }
        //設置擋板,1表示
        map[3][1] = 1;
        map[3][2] = 1;
//        map[4][3] = 1;
//        map[5][4] = 1;
//        map[5][5] = 1;
        setWay(map, 1, 3);
        System.out.println("小球走過並標識過的地圖的情況");
        for (int i = 0; i < 8; i++) {
            for (int j = 0; j < 7; j++) {
                System.out.print(map[i][j] + " ");
            }
            System.out.println();
        }
    }

    //說明
    //1.map表示地圖
    //2.i,j表示從地圖的哪個位置開始出發
    //3.如果小球能夠到map[6][5],位置,則說明通路找到
    //4.約定:0表示該點沒有走過,1表示牆,2表示通路可以走,3表示該點已經走過但是走不通
    //5.在走之前需要確定一個策略(方法)下,右,上,左,如果該點走不通,再回溯

    /**
     * @param map 表示地圖
     * @param i   從哪個位置開始找
     * @param j
     * @return 如果找到通路,就返回true,否則返回false
     */
    private static boolean setWay(int[][] map, int i, int j) {
        if (map[6][5] == 2) {
            return true;
        } else {
            if (map[i][j] == 0) {
                map[i][j] = 2;//先假設此處可以通走
                if (setWay(map, i + 1, j)) {
                    return true;
                } else if (setWay(map, i, j + 1)) {
                    return true;
                } else if (setWay(map, i - 1, j)) {
                    return true;
                } else if (setWay(map, i, j - 1)) {
                    return true;
                } else {
                    map[i][j] = 3;//發現此路不通,修改之前的判斷值
                    return false;
                }
            } else {
                return false;
            }
        }
    }
}

結果如下:
在這裏插入圖片描述
此結果如最開始的分析判斷,小球順利的達到了終點,但是似乎並未體現回溯的過程。
新添三塊擋板,也就是放開上述註釋的那三行代碼,新結果如下:
加粗樣式
由於新添了三塊擋板,使得小球已經無法再到達目的地,所以從第一處不通的點開始回溯,最後使得那塊被擋板和牆所包圍的密閉空間的所有點都變成了不可通行的點。

八皇后
問題描述:在8x8格的國際象棋上擺放八個皇后,使其不能互相攻擊,即任意兩個皇后都不能處於同一行、同一列或同一斜線上。
思路分析:國際象棋棋盤是一個二維數組,但是對於記錄八個皇后的位置,我們只需用一個一位數組即可(由於每個皇后間不能在同一行,所以可以默認一維數組的下標爲行數)。先確定第一個皇后的位置(默認在第一列的位置),然後在第二行中依次放置第二個皇后,成功後放置第三個皇后,以此類推,知道有一個皇后放不下或者是最後一個皇后以及放置成功時開始從最後一步進行回溯,通過改變上一步的策略(這裏是指繼續嘗試未嘗試過的位置)來得到正解甚至是更多的解。
函數解析:在這裏面需要定義兩個函數(需要打印各種放置方法還需額外再定義一個函數),一個函數用於判斷當前位置的皇后是否與之前已放置的任意一個皇后發生衝突,還有一個函數用於回溯並記錄下正解。
代碼如下:

public class Queen8 {
    //定義一個max表示共有多少個皇后
    int max = 8;
    //定義數組array,保存皇后被放置位置的結果
    int[] array = new int[max];

    static int count = 0;

    static int judgeCount = 0;

    public static void main(String[] args) {
        Queen8 queen8 = new Queen8();
        queen8.check(0);
        System.out.println("一共有"+(count)+"種解法");
        System.out.println("一共判斷"+(judgeCount)+"次衝突");
    }

    //編寫一個方法,放置第n個皇后
    //特別注意:check的每一層遞歸時都有一個for循環,進而產生了回溯
    private void check(int n) {
        if (n == max) {
            print();
            count++;
            return;
        }
        for (int i = 0; i < max; i++) {
            array[n] = i;
            if (judge(n)) {
                check(n + 1);
            }
        }
    }
    //檢驗該皇后是否和前面已經擺放的皇后發生衝突

    /**
     * @param n 表示第n個皇后
     * @return
     */
    private boolean judge(int n) {
        judgeCount++;
        for (int i = 0; i < n; i++) {
            //一維數組,不可能在同一行
            //判斷是否在同一行或者同一列
            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] + " ");
        }
        System.out.println();
    }
}

結果如下:
在這裏插入圖片描述
(由於方式總數太多,這裏就不列舉其結果了)92代表總共有92种放置方式,15720代表回溯了一萬五千多次,說明回溯的時間複雜度的確挺高的。

組合總和
問題描述:給定一個無重複元素的數組 candidates 和一個目標數 target ,找出candidates 中所有可以使數字和爲 target 的組合。
candidates 中的數字可以無限制重複被選取。
思路分析:首先我們先對給定的數組進行排序(方便後序剪枝),然後從給定數組的第一個元素開始求和判斷是否等於目標數,大了直接終止遞歸開始回溯,小了繼續遞歸,正好相等直接記錄下次決策並開始回溯到上一階段繼續尋找下一個滿足要求的決策。
遞歸函數分析:此函數需要有5個參數,給定數組nums,目標數target,記錄滿足條件的數組元素的List集合,當前List集合的數值總和sum,以及當前給定數組的下標start。
代碼如下:

List<List<Integer>> lists = new ArrayList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        Arrays.sort(candidates);
        backtrack(candidates, target, new ArrayList<>(), 0, 0);
        return lists;
    }

    private void backtrack(int[] nums, int target, ArrayList<Integer> list, int sum, int start) {
        if (sum == target) {
            lists.add(new ArrayList<>(list));//實現深拷貝
            return;
        }
        for (int i = start; i < nums.length; i++) {
            //sum + nums[i] > target作爲循環終止的判斷條件可以優化回溯深度
            if (sum + nums[i] > target) {
                break;
            }
            sum += nums[i];
            list.add(nums[i]);
            backtrack(nums, target, list, sum, i);
            sum -= nums[i];
            list.remove(list.size() - 1);
        }
    }

這裏的sum + nums[i] > target就是剪枝手段,由於使用的都是同一個List集合,所以在每次回溯時都需要把上一個元素在集合List中移除掉。

組合總和 II
問題描述:給定一個數組 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和爲 target 的組合。candidates 中的每個數字在每個組合中只能使用一次。
思路分析:整體思路和上題差不多,但是由於此題要求數字不能重複使用,所以此題還需額外添加一個visited數組來記錄candidates 數組的每個數字是否被訪問過,並剪枝去掉因重複與元素而產生的相同分支。
代碼如下:

List<List<Integer>> lists = new ArrayList<>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        int[] visited = new int[candidates.length];
        Arrays.sort(candidates);
        backtrack(candidates, target, new ArrayList<>(), 0, 0, visited);
        return lists;
    }

    private void backtrack(int[] nums, int target, ArrayList<Integer> list, int sum, int start, int[] visited) {
        if (sum == target) {
            lists.add(new ArrayList<>(list));
            return;
        }
        for (int i = start; i < nums.length; i++) {
            if (sum + nums[i] > target) {
                break;
            }
            if (visited[i] == 0) {
                //去除掉因重複元素而產生的相同分支
                if (i > 0 && visited[i - 1] == 0 && nums[i] == nums[i - 1]) {
                    continue;
                }
                visited[i] = 1;
                sum += nums[i];
                list.add(nums[i]);
                backtrack(nums, target, list, sum, i, visited);
                visited[i] = 0;
                sum -= nums[i];
                list.remove(list.size() - 1);
            }
        }
    }

組合總和 III
問題描述:找出所有相加之和爲 n 的 k 個數的組合。組合中只允許含有 1 - 9 的正整數,並且每種組合中不存在重複的數字。
思路分析:這題的整體思路和上上題的思路也差不多,只不過在這裏多了兩個要素,一個是list.size() > length時的遞歸終止條件,還有一個是回溯的循環條件i是從start+1開始的,這樣就不必再使用visited數組來記錄節點的訪問情況。
代碼如下:

private List<List<Integer>> lists = new ArrayList<>();

    public List<List<Integer>> combinationSum3(int k, int n) {
        backtrack(n, new ArrayList<>(), k, 0, 0);
        return lists;
    }

    private void backtrack(int target, ArrayList<Integer> list, int length, int sum, int start) {
        if (list.size() > length) {
            return;
        }
        if (list.size() == length && sum == target) {
            lists.add(new ArrayList<>(list));
            return;
        }
        //由於i = start + 1,所以不必考慮節點是否被使用過的情況
        for (int i = start + 1; i <= 9; i++) {
            if (sum + i > target) {
                break;
            }
            sum += i;
            list.add(i);
            backtrack(target, list, length, sum, i);
            sum -= i;
            list.remove(list.size() - 1);
        }
    }

火車進站
問題描述:給定一個正整數N代表火車數量,0<N<10,接下來輸入火車入站的序列,一共N輛火車,每輛火車以數字1-9編號。 要求以字典序排序輸出火車出站的序列號。
這個題通常的做法是先求出棧序列號的全排列,然後再去除掉不滿足條件的情況。
這裏我給出一種通過回溯模擬棧的方法(特別是邊進邊出的方式)
思路分析:此題的主要目的是模擬火車進站並輸出可能出棧的順序,所以我們需要定義火車所在的三種狀態,remain表示還未進站的火車,in表示在棧內的火車,out表示已經出棧的火車,當所以火車都已經進棧即remain內已經沒有火車時遞歸終止,記錄下in和out內的火車序列號。一方面我們需要注意當in內沒有火車時只能讓remain內的火車進入,另一方面我們通過for循環遍歷當前in內的火車來回溯從而可以達到一種邊進邊出的出棧效果。當記錄下所有的回溯結果後,最後一步只需要重寫集合的排序標準即可得到所要的答案。
代碼如下:

 static List<String> list = new ArrayList<>();

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        String str = sc.nextLine();
        String in = "";
        String out = "";
        function(str, in, out);
        list.sort(Comparator.comparingInt(Integer::valueOf));//按照字符串的數值大小重寫排序標準
        for (String s : list) {
            System.out.println(s);
        }
    }

    private static void function(String remain, String in, String out) {
        if (remain.length() == 0) {
            list.add(out + in);
        } else {
            if (in.length() == 0) {//此時只能把remain內的火車進入一個到in裏面
                function(remain.substring(1, remain.length()), in + remain.charAt(0), out);
            } else {
                for (int i = 0; i <= in.length(); i++) {//模擬邊入邊出的場景
                    //i = 0保證remain的火車全部進入in內
                    //把remain內的火車進入一個到in裏面,並且讓in裏面的火車出i個到out內
                    function(remain.substring(1, remain.length()), remain.charAt(0) + in.substring(i, in.length()), out + in.substring(0, i));
                }
            }
        }
    }

測試結果:
在這裏插入圖片描述

通過學習以上六個例題,相信大家應該對回溯有了一些基本的認識。
最後再舉一個生活中常見的回溯例子,回溯算法很像我們日常下棋時的算棋策略,我們在算棋時每多算一步其實就是多遞歸一次,而每一步棋會有很多種選擇就好比回溯時能夠改變遞歸策略的不同方案,而當我們計算出這步棋不合適就會嘗試退回到上一步看看上一步是否有更好的棋招可選,選擇好另外一步棋繼續執行算棋(遞歸),這也就是回溯!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章