讀完本文,你可以去力扣拿下如下題目:
46.全排列
51.N皇后
-----------
這篇文章是很久之前的一篇《回溯演算法詳解》的進階版,之前那篇不夠清楚,就不必看了,看這篇就行。把框架給你講清楚,你會發現回溯演算法問題都是一個套路。
廢話不多說,直接上回溯演算法框架。解決一個回溯問題,實際上就是一個決策樹的遍歷過程。你只需要思考 3 個問題:
1、路徑:也就是已經做出的選擇。
2、選擇列表:也就是你當前可以做的選擇。
3、結束條件:也就是到達決策樹底層,無法再做選擇的條件。
如果你不理解這三個詞語的解釋,沒關係,我們後面會用「全排列」和「N 皇后問題」這兩個經典的回溯演算法問題來幫你理解這些詞語是什麼意思,現在你先留著印象。
程式碼方面,回溯演算法的框架:
result = []def backtrack(路徑, 選擇列表): if 滿足結束條件: result.add(路徑) return for 選擇 in 選擇列表: 做選擇 backtrack(路徑, 選擇列表) 撤銷選擇
其核心就是 for 迴圈裡面的遞迴,在遞迴呼叫之前「做選擇」,在遞迴呼叫之後「撤銷選擇」,特別簡單。
什麼叫做選擇和撤銷選擇呢,這個框架的底層原理是什麼呢?下面我們就透過「全排列」這個問題來解開之前的疑惑,詳細探究一下其中的奧妙!
一、全排列問題我們在高中的時候就做過排列組合的數學題,我們也知道 n 個不重複的數,全排列共有 n! 個。
那麼我們當時是怎麼窮舉全排列的呢?比方說給三個數 [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!),因為窮舉整棵決策樹是無法避免的。這也是回溯演算法的一個特點,不像動態規劃存在重疊子問題可以最佳化,回溯演算法就是純暴力窮舉,複雜度一般都很高。
明白了全排列問題,就可以直接套回溯演算法框架了,下面簡單看看 N 皇后問題。
二、N 皇后問題這個問題很經典了,簡單解釋一下:給你一個 N×N 的棋盤,讓你放置 N 個皇后,使得它們不能互相攻擊。
PS:皇后可以攻擊同一行、同一列、左上左下右上右下四個方向的任意單位。
這個問題本質上跟全排列問題差不多,決策樹的每一層表示棋盤上的每一行;每個節點可以做出的選擇是,在該行的任意一列放置一個皇后。
直接套用框架:
vector<vector<string>> res;/* 輸入棋盤邊長 n,返回所有合法的放置 */vector<vector<string>> solveNQueens(int n) { // '.' 表示空,'Q' 表示皇后,初始化空棋盤。 vector<string> board(n, string(n, '.')); backtrack(board, 0); return res;}// 路徑:board 中小於 row 的那些行都已經成功放置了皇后// 選擇列表:第 row 行的所有列都是放置皇后的選擇// 結束條件:row 超過 board 的最後一行void backtrack(vector<string>& board, int row) { // 觸發結束條件 if (row == board.size()) { res.push_back(board); return; } int n = board[row].size(); for (int col = 0; col < n; col++) { // 排除不合法選擇 if (!isValid(board, row, col)) continue; // 做選擇 board[row][col] = 'Q'; // 進入下一行決策 backtrack(board, row + 1); // 撤銷選擇 board[row][col] = '.'; }}
這部分主要程式碼,其實跟全排列問題差不多,isValid 函式的實現也很簡單:
/* 是否可以在 board[row][col] 放置皇后? */bool isValid(vector<string>& board, int row, int col) { int n = board.size(); // 檢查列是否有皇后互相沖突 for (int i = 0; i < n; i++) { if (board[i][col] == 'Q') return false; } // 檢查右上方是否有皇后互相沖突 for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) { if (board[i][j] == 'Q') return false; } // 檢查左上方是否有皇后互相沖突 for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (board[i][j] == 'Q') return false; } return true;}
函式 backtrack 依然像個在決策樹上游走的指標,透過 row 和 col 就可以表示函式遍歷到的位置,透過 isValid 函式可以將不符合條件的情況剪枝:
如果直接給你這麼一大段解法程式碼,可能是懵逼的。但是現在明白了回溯演算法的框架套路,還有啥難理解的呢?無非是改改做選擇的方式,排除不合法選擇的方式而已,只要框架存於心,你面對的只剩下小問題了。
當 N = 8 時,就是八皇后問題,數學大佬高斯窮盡一生都沒有數清楚八皇后問題到底有幾種可能的放置方法,但是我們的演算法只需要一秒就可以算出來所有可能的結果。
不過真的不怪高斯。這個問題的複雜度確實非常高,看看我們的決策樹,雖然有 isValid 函式剪枝,但是最壞時間複雜度仍然是 O(N^(N+1)),而且無法最佳化。如果 N = 10 的時候,計算就已經很耗時了。
有的時候,我們並不想得到所有合法的答案,只想要一個答案,怎麼辦呢?比如解數獨的演算法,找所有解法複雜度太高,只要找到一種解法就可以。
其實特別簡單,只要稍微修改一下回溯演算法的程式碼即可:
// 函式找到一個答案後就返回 truebool backtrack(vector<string>& board, int row) { // 觸發結束條件 if (row == board.size()) { res.push_back(board); return true; } ... for (int col = 0; col < n; col++) { ... board[row][col] = 'Q'; if (backtrack(board, row + 1)) return true; board[row][col] = '.'; } return false;}
這樣修改後,只要找到一個答案,for 迴圈的後續遞迴窮舉都會被阻斷。也許你可以在 N 皇后問題的程式碼框架上,稍加修改,寫一個解數獨的演算法?
三、最後總結回溯演算法就是個多叉樹的遍歷問題,關鍵就是在前序遍歷和後序遍歷的位置做一些操作,演算法框架如下:
def backtrack(...): for 選擇 in 選擇列表: 做選擇 backtrack(...) 撤銷選擇
寫 backtrack 函式時,需要維護走過的「路徑」和當前可以做的「選擇列表」,當觸發「結束條件」時,將「路徑」記入結果集。
其實想想看,回溯演算法和動態規劃是不是有點像呢?我們在動態規劃系列文章中多次強調,動態規劃的三個需要明確的點就是「狀態」「選擇」和「base case」,是不是就對應著走過的「路徑」,當前的「選擇列表」和「結束條件」?
某種程度上說,動態規劃的暴力求解階段就是回溯演算法。只是有的問題具有重疊子問題性質,可以用 dp table 或者備忘錄最佳化,將遞迴樹大幅剪枝,這就變成了動態規劃。而今天的兩個問題,都沒有重疊子問題,也就是回溯演算法問題了,複雜度非常高是不可避免的。