回溯算法詳解[力扣46:全排列]


解決一個回溯問題,實際上就是一個決策樹的遍歷過程。你只需要思考 3 個問題:

1、路徑:也就是已經做出的選擇。

2、選擇列表:也就是你當前可以做的選擇。

3、結束條件:也就是到達決策樹底層,無法再做選擇的條件。

如果你不理解這三個詞語的解釋,沒關系,我們后面會用「全排列」和「N 皇后問題」這兩個經典的回溯算法問題來幫你理解這些詞語是什么意思,現在你先留着印象。

代碼方面,回溯算法的框架:

result = []
def backtrack(路徑, 選擇列表):
    if 滿足結束條件:
        result.add(路徑)
        return
    
    for 選擇 in 選擇列表:
        做選擇
        backtrack(路徑, 選擇列表)
        撤銷選擇

其核心就是 for 循環里面的遞歸,在遞歸調用之前「做選擇」,在遞歸調用之后「撤銷選擇」,特別簡單。

什么叫做選擇和撤銷選擇呢,這個框架的底層原理是什么呢?下面我們就通過「全排列」這個問題來解開之前的疑惑,詳細探究一下其中的奧妙!

一、全排列問題
我們在高中的時候就做過排列組合的數學題,我們也知道 n 個不重復的數,全排列共有 n! 個。

PS:為了簡單清晰起見,我們這次討論的全排列問題不包含重復的數字。

那么我們當時是怎么窮舉全排列的呢?比方說給三個數 [1,2,3],你肯定不會無規律地亂窮舉,一般是這樣:

先固定第一位為 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位變成 3,第三位就只能是 2 了;然后就只能變化第一位,變成 2,然后再窮舉后兩位……

其實這就是回溯算法,我們高中無師自通就會用,或者有的同學直接畫出如下這棵回溯樹:

 

 

 

只要從根遍歷這棵樹,記錄路徑上的數字,其實就是所有的全排列。我們不妨把這棵樹稱為回溯算法的「決策樹」。

為啥說這是決策樹呢,因為你在每個節點上其實都在做決策。比如說你站在下圖的紅色節點上:

 

 

 

你現在就在做決策,可以選擇 1 那條樹枝,也可以選擇 3 那條樹枝。為啥只能在 1 和 3 之中選擇呢?因為 2 這個樹枝在你身后,這個選擇你之前做過了,而全排列是不允許重復使用數字的。

現在可以解答開頭的幾個名詞:[2] 就是「路徑」,記錄你已經做過的選擇;[1,3] 就是「選擇列表」,表示你當前可以做出的選擇;「結束條件」就是遍歷到樹的底層,在這里就是選擇列表為空的時候。

如果明白了這幾個名詞,可以把「路徑」和「選擇」列表作為決策樹上每個節點的屬性,比如下圖列出了幾個節點的屬性:

 

 

 

我們定義的 backtrack 函數其實就像一個指針,在這棵樹上游走,同時要正確維護每個節點的屬性,每當走到樹的底層,其「路徑」就是一個全排列。

再進一步,如何遍歷一棵樹?這個應該不難吧。回憶一下之前「學習數據結構的框架思維」寫過,各種搜索問題其實都是樹的遍歷問題,而多叉樹的遍歷框架就是這樣:

void traverse(TreeNode root) {
    for (TreeNode child : root.childern)
        // 前序遍歷需要的操作
        traverse(child);
        // 后序遍歷需要的操作
}

而所謂的前序遍歷和后序遍歷,他們只是兩個很有用的時間點,我給你畫張圖你就明白了:

 

 

 

前序遍歷的代碼在進入某一個節點之前的那個時間點執行,后序遍歷代碼在離開某個節點之后的那個時間點執行。

回想我們剛才說的,「路徑」和「選擇」是每個節點的屬性,函數在樹上游走要正確維護節點的屬性,那么就要在這兩個特殊時間點搞點動作:

 

 

 

現在,你是否理解了回溯算法的這段核心框架?

for 選擇 in 選擇列表:
    # 做選擇
    將該選擇從選擇列表移除
    路徑.add(選擇)
    backtrack(路徑, 選擇列表)
    # 撤銷選擇
    路徑.remove(選擇)
    將該選擇再加入選擇列表

我們只要在遞歸之前做出選擇,在遞歸之后撤銷剛才的選擇,就能正確得到每個節點的選擇列表和路徑。

下面,直接看全排列代碼:

List<List<Integer>> res = new LinkedList<>();

/* 主函數,輸入一組不重復的數字,返回它們的全排列 */
List<List<Integer>> permute(int[] nums) {
    // 記錄「路徑」
    LinkedList<Integer> track = new LinkedList<>();
    backtrack(nums, track);
    return res;
}

// 路徑:記錄在 track 中
// 選擇列表:nums 中不存在於 track 的那些元素
// 結束條件:nums 中的元素全都在 track 中出現
void backtrack(int[] nums, LinkedList<Integer> track) {
    // 觸發結束條件
    if (track.size() == nums.length) {
        res.add(new LinkedList(track));
        return;
    }
    
    for (int i = 0; i < nums.length; i++) {
        // 排除不合法的選擇
        if (track.contains(nums[i]))
            continue;
        // 做選擇
        track.add(nums[i]);
        // 進入下一層決策樹
        backtrack(nums, track);
        // 取消選擇
        track.removeLast();
    }
}

 

我們這里稍微做了些變通,沒有顯式記錄「選擇列表」,而是通過 nums 和 track 推導出當前的選擇列表:

 

 

 

至此,我們就通過全排列問題詳解了回溯算法的底層原理。當然,這個算法解決全排列不是很高效,應為對鏈表使用 contains 方法需要 O(N) 的時間復雜度。有更好的方法通過交換元素達到目的,但是難理解一些,這里就不寫了,有興趣可以自行搜索一下。

但是必須說明的是,不管怎么優化,都符合回溯框架,而且時間復雜度都不可能低於 O(N!),因為窮舉整棵決策樹是無法避免的。這也是回溯算法的一個特點,不像動態規划存在重疊子問題可以優化,回溯算法就是純暴力窮舉,復雜度一般都很高。


鏈接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-xiang-jie-by-labuladong-2/
來源:力扣(LeetCode)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM