《演算法》系列—大白話聊分治、回溯,手撕八皇后

陽光剛好丶發表於2021-01-28

1.分治

分治就是分而治之,即把一個問題分解成很多個子問題,且這些問題與原問題結構相似,然後遞迴解決子問題,最後合併子問題的結果,得到原來問題的結果。

分治解題三個步驟:

分解:將問題分解成與原問題結構相似的子問題

解決:遞迴求解各個子問題,當子問題足夠小,直接返回結果(問題分解到足夠小,分解終止條件)

合併:將子問題的結果合併,得到原問題的結果

《演算法》系列—大白話聊分治、回溯,手撕八皇后

分治演算法也是一種遞迴,之前有寫到過遞迴的模板,這裡也貼一下分治演算法的模板。

《演算法》系列—大白話聊分治、回溯,手撕八皇后

接下來看個問題,深入瞭解一下分治演算法,並套用一下模板。

Pow(x,n)

《演算法》系列—大白話聊分治、回溯,手撕八皇后

首先按照分治解題的三個步驟分析一下這個問題:

分解:x的n次冪可以分解為子問題:x^n/2 ……

解決:問題還沒有分解最小時,就進行遞迴,當問題分解到足夠小時,如n=0時,結果為1;n=1時,結果為x;n=-1時,結果為 1/x。

合併:將得到的結果合併

當n%2==0時,x^n = (x^n/2)*(x^n/2)

當n%2==1時,x^n = x* (x^n/2)*(x^n/2)

下面來看一下程式碼:

public double myPow(double x, int n) {
    //當結果足夠小時,直接返回結果
    if (n == 0) { return 1; }
    if (n == 1) { return x; }
    if (n == -1) { return 1 / x; }
    //解決子問題,遞迴呼叫
    double half = myPow(x, n / 2);
    //這裡取模進行呼叫(考慮到n可能為負數)
    double rest = myPow(x, n % 2);
    //合併,返回結果
    return rest * half * half;
}

這就是一個典型分治演算法的題目,先對原問題進行分解,在求解子問題(遞迴求解,當問題足夠小的時候,返回結果),然後對子問題的結果進行合併。

2.回溯

回溯利用了試錯的思想,它嘗試分步的去解決問題。當分步計算的時候,發現答案錯誤或者無效時,它會取消上一步,甚至上幾步的操作,然後再通過其他的分步解答去找到正確答案。

八皇后

來看看經典的八皇后問題

《演算法》系列—大白話聊分治、回溯,手撕八皇后

來說說解題思路,把這個題目放在這篇文章裡,當然就是用遞迴回溯法解決了...好了,接下來就該手撕八皇后了~

首先題目中要求每個皇后都不同行,不同列,也不在對角線上(其實就是題目給我們的過濾條件),不同行,不同列好理解,也好解決,我們就來看看不在對角線上(丿斜對角線、捺斜對角線)

丿對角線有個特點:行下標和列下標之和相等。(圖來自LeetCode)

《演算法》系列—大白話聊分治、回溯,手撕八皇后

捺斜對角線有個特點:行下標和列下標之差相等。(圖來自LeetCode)

《演算法》系列—大白話聊分治、回溯,手撕八皇后

好了,題目給我們的過濾條件就羅列出來了,我們需要去窮舉皇后出現的位置(不能被攻擊到),並記錄當前皇后位置可以攻擊到的位置。

很多分析都寫到程式碼的註釋裡了,直接看程式碼吧~

/**
 * N皇后問題
 * @param n n
 * @return result
 */
public List<List<String>> solveNQueens(int n) {
    //存放結果集
    List<List<String>> solutions = new ArrayList<List<String>>();
    //記錄皇后的位置
    int[] queens = new int[n];
    //賦予預設值
    Arrays.fill(queens, -1);
    //記錄皇后攻擊的列
    Set<Integer> columns = new HashSet<Integer>();
    //記錄皇后攻擊的丿斜線
    Set<Integer> pie = new HashSet<Integer>();
    //記錄皇后攻擊的捺斜線
    Set<Integer> na = new HashSet<Integer>();
    //遞迴呼叫
    backtrack(solutions, queens, n, 0, columns, pie, na);
    //返回結果
    return solutions;
}

/**
 * 遞迴函式,窮舉皇后的位置
 * @param solutions 結果集
 * @param queens 皇后可以放置的列(陣列記錄)
 * @param n n個皇后
 * @param row 當前遞迴是第 row 行
 * @param columns 已有的皇后能攻擊到的列
 * @param pie 已有的皇后能攻擊到的丿斜線
 * @param na 已有的皇后能攻擊到的捺斜線
 */
public void backtrack(List<List<String>> solutions, int[] queens, int n, int row, Set<Integer> columns, Set<Integer> pie, Set<Integer> na) {
    if (row == n) {
        //當行 等於 n 時,說明已經窮舉完,記錄結果集
        List<String> board = generateBoard(queens, n);
        solutions.add(board);
    } else {
        //遍歷當前行的所有列
        for (int i = 0; i < n; i++) {
            //被攻擊的列包含了當前列,跳過當前列,不進行後面的邏輯
            if (columns.contains(i)) {
                continue;
            }
            //被攻擊的丿斜線包含了當前位置,跳過當前位置
            if (pie.contains(row + i)) {
                continue;
            }
            //被攻擊的捺斜線包含了當前位置,跳過當前位置
            if (na.contains(row - i)) {
                continue;
            }
            //記錄當前皇后存放的列
            queens[row] = i;
            //記錄當前皇后攻擊到的列
            columns.add(i);
            //記錄當前皇后攻擊到的丿斜線
            pie.add(row+i);
            //記錄當前皇后攻擊到的捺斜線
            na.add(row-i);
            //遞迴呼叫,窮舉下一行的皇后位置
            backtrack(solutions, queens, n, row + 1, columns, pie, na);
            /**
             * 經過上面的遞迴,自頂向下遞迴完成時就已經窮舉出了一種皇后所有可能出現的位置
             * 然後這裡需要把之前修改的資料進行清理,窮舉另一種皇后所有可能出現的位置,這裡是自底向上清理資料(即回溯)
             */
            //清理皇后存放的列
            queens[row] = -1;
            //清理皇后攻擊的列
            columns.remove(i);
            //清理皇后攻擊的丿斜線
            pie.remove(row+i);
            //清理皇后攻擊的捺斜線
            na.remove(row-i);
        }
    }
}

/**
 *
 * @param queens 皇后位置
 * @param n n皇后
 * @return 所有皇后的位置(用.和Q表示)
 */
public List<String> generateBoard(int[] queens, int n) {
    List<String> board = new ArrayList<String>();
    for (int i = 0; i < n; i++) {
        char[] row = new char[n];
        Arrays.fill(row, '.');
        row[queens[i]] = 'Q';
        board.add(new String(row));
    }
    return board;
}

看完程式碼你學廢了嗎?八皇后問題這裡主要是想來說明一下遞迴中的回溯,當我們列舉一種可能,到最後發現不正確時,需要清理之前的資料,即取消上一步,甚至上好多步的操作。遞迴函式呼叫(自頂向下)完成後去清理資料(自底向上),多理解一下這種歸去來兮的感覺。

總結

分治:遞迴呼叫後,去合併遞迴得到的結果。

回溯:遞迴呼叫後,去取消上一步,上好多步的操作。

分治和回溯本質上都是遞迴的一種,具體的問題,我們要具體分析,是需要分解後合併結果,還是不斷窮舉試錯,然後回溯。

歡迎關注微信公眾號「山主」,公眾號文章首發,解鎖更多幹貨~

相關文章