目錄
1. 概述
1.1 回溯思想
回溯算法(Backtrack)是一種試錯思想,本質上是深度優先搜索。即:從問題的某一種狀態出發,依次嘗試現有狀態可以做出的選擇從而進入下一個狀態。遞歸這個過程,如果在某個狀態無法做更多選擇,或者已經找到目標答案時,則回退一步甚至多步重新嘗試,直到最終所有選擇都嘗試過。
整個過程就像走迷宮一樣,當我們遇到一個分叉口時,可以選擇從一個方向進去嘗試。如果走到死衚衕則返回上一個分叉口,選擇另外一條方向繼續嘗試,直到發現沒有出口,或者找到出口。
1.2 回溯的三要素
理解了回溯算法的思想,下面我們來分析回溯的關鍵點。在回溯算法中,需要考慮三個要素:路徑、選擇列表、結束條件,以走迷宮爲例:
- 1. 路徑:已經做出的選擇
- 2. 選擇列表:當前狀態可以做出的選擇
- 3. 結束條件:選擇列表爲空,或者找到目標
要走出這個迷宮,我們需要重複做一件事:選擇從一個方向進去嘗試。如果走到死衚衕則返回上一個分叉口,選擇另外一條方向繼續嘗試。用程序實現出來,這個重複做的事就是遞歸函數,實際中我們可以遵循一個解題模板 & 思路:
fun backtrack(){
1\. 判斷當前狀態是否滿足終止條件
if(終止條件){
return solution
}
2\. 否則遍歷選擇列表
for(選擇 in 選擇列表){
3\. 做出選擇
solution.push(選擇)
4\. 遞歸
backtrack()
5\. 回溯(撤銷選擇)
stack.pop(選擇)
}
}
需要注意的是,解題框架 & 思路不是死板的,應根據具體問題具體分析。例如:回溯(撤銷選擇)並不是必須的,第 3.2 節第 k 個排序、第 5 節島嶼數量問題中,它們在深層函數返回後沒有必要回溯。
1.3 回溯剪枝
由於回溯算法的時間複雜度非常高,當我們遇到一個分支時,如果“有先見之明”,能夠知道某個選擇最終一定無法找到答案,那麼就不應該去嘗試走這條路徑,這個步驟叫作剪枝。
那麼,怎麼找到這個“先見之明”呢?經過我的總結,大概有以下幾種情況:
- 重複狀態
例如:47. Permutations II 全排列 II(用重複數字) 這道題給定一個可包含重複數字的序列,要求返回所有不重複的全排列,例如輸入[1,1,2]
預期的輸出爲[1,1,2]、[1,2,1]、[2,1,1]
。用我們前面介紹的解題模板,這道題並不難:
class Solution {
fun permute(nums: IntArray): List<List<Int>> {
val result = ArrayList<List<Int>>()
// 選擇列表
val useds = BooleanArray(nums.size) { false }
// 路徑
val track = LinkedList<Int>()
fun backtrack() {
// 終止條件
if (track.size == nums.size) {
result.add(ArrayList<Int>(track))
return
}
for ((index, used) in useds.withIndex()) {
if (!used) {
// 做出選擇
track.push(nums[index])
useds[index] = true
backtrack()
// 回溯
track.pop()
useds[index] = false
}
}
}
backtrack()
return result
}
}
複製代碼
然而,因爲原數組中有兩個 1 ,所以結果中會存在一些重複排列,怎麼去重呢?一種方法是在得到排列之後,檢查結果集合中是否已經有相同的排列,這是一個<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>O</mi><mo stretchy="false">(</mo><msup><mi>n</mi><mn>2</mn></msup><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">O(n^2)</annotation></semantics></math>O(n2)的比較,顯然是不明智的。另一種方法就是尋找重複狀態,打從一開始就“有先見之明”地避開一些選擇。
我們先說說什麼是重複狀態?還記得我們說的回溯三要素嗎:路徑、選擇列表、結束條件。通常來說,在這三個要素裏面結束條件是固定的,而路徑和選擇列表會隨着每次選擇 & 回溯而變化。
也就是說,當我們發現兩個狀態的路徑和選擇列表完全相同時,說明這兩個狀態是完全重複的。以兩個重複狀態爲起點進行遞歸,最終得到的結果也一定是重複的。在這道題裏,我們先對輸入執行 快速排序,在之後的每次選擇之前,先判斷當前狀態是否與上一個選擇重複,是則直接跳過。
class Solution {
fun permuteUnique(nums: IntArray): List<List<Int>> {
val result = ArrayList<LinkedList<Int>>()
if(nums.size <= 0){
result.add(LinkedList<Int>())
return result
}
// 排序
Arrays.sort(nums)
// 選擇列表
val used = BooleanArray(nums.size){false}
// 路徑
val track = LinkedList<Int>()
fun backtrack(){
// 終止條件
if(track.size >= nums.size){
result.add(LinkedList<Int>(track))
return
}
for((index,num) in nums.withIndex()){
if(used[index]){
continue
}
if(index > 0 && !used[index - 1] && nums[index] == nums[index - 1]){
continue
}
// 做出選擇
used[index] = true
track.push(num)
// 遞歸
backtrack()
// 回溯
used[index] = false
track.pop()
}
}
backtrack()
return result
}
}
- 最終解確定
當我們在一個選擇的分支中確定了最終解後,就沒必要去嘗試其他的選擇了。例如在 79. Word Search 單詞搜索 中,當確定單詞存在時,就沒必要繼續搜索了,在 第 4 節 會專門分析這道題。
fun backtrack(...){
for (選擇 in 選擇列表) {
1\. 做出選擇
2\. 遞歸
val match = backtrack(...)
3\. 回溯
if (match) {
return true
}
}
}
- 無效選擇
當我們可以根據已知條件判斷某個選擇無法找到最終解時,就沒必要去嘗試這個選擇了。例如:39. Combination Sum 組合總和、60. Permutation Sequence 第 k 個排列
3. 排列 & 組合 & 子集問題
3.1 排列 & 組合問題
排列(permutation)& 組合(combination)& 子集(subset)可以說是回溯算法裏最常規的問題了。其中,子集問題本質上也是組合問題。我們先通過一個簡單的問題帶大家體會 排列 & 組合 的區別:
- 排列問題:
有 3 個不同的小球 A,B,C,從中取出 2 個放稱一排,有幾種方法?
- 組合問題:
有 3 個不同的小球 A,B,C,從中取出 2 個放成一堆,有幾種方法?
一排和一堆的區別是什麼呢?很明顯,一排是有順序的,而一堆是無順序的。例如 [A B C] 和 [A C B] 是不同的,而 {A B C} 和 {A C B} 是相同的。
從上面這張圖裏,其實可以清楚地看到,組合問題是在排列問題的基礎上去除重複集合;子集問題是合併了多個不同規模的組合問題。
那麼,如何排除元素相同,順序不同的集合呢?這裏提一個很好理解的方法,相信一說出來很多同學都會煥然大悟:“我的初中數學老師就是這麼教我的。”
可以看到,只要避免組合之前的元素,就可以避免重複。例如在選擇 {B, }之後,就不要組合之前的 A 元素了,否則會造成重複。因爲在 {A, } 的分支裏,已經存在過 {A, B} 的組合了。因此,我們可以使用一個 from 變量來標示當前狀態允許的選擇列表範圍。
下面給出從 n 個數中取 k 的數的排列 & 組合代碼,由於遞歸解法代碼的可解釋性更強,讀者應優先保證可以熟練地寫出遞歸解法:
複雜度分析:
3.2 字典序排列問題
在排列問題中,還有一個字典序排列(Dictionary order)的概念,即:基於字母順序排列,就像單詞在字典中的排列順序一樣,例如 [A B C] 排在 [A C B] 之前。要得到字典序的全排列,遞歸解法和非遞歸解法都可以實現。
使用遞歸解法,只需要保證選擇列表按照字母排序,最終得到的全排列就是字典序排序,實現起來也比較簡單直觀,在 第 1.4 節 已經給出答案了。
使用非遞歸解法的基本思想是:從當前字符串出發,直接找出字典序下一個排列,例如從 [A B C] 直接找到下一個排列 [A C B]。怎麼找呢,可以先簡單思考下這個問題:
- 下一個排列
31. Next Permutation 下一個排列 將給定數字序列重新排列成字典序中下一個更大的排列。
理解了下一個排列的算法之後,寫出全排列的算法就不難了。只需要從第一個排列開始依次輸出下一個排列,最終就得到了字典序全排列。有興趣可以到上一節的題解中查看,這裏就不貼出來了。
- 第 k 個排列
除了求下一個排列外,求第 k 個排列也是很常見了。例如:60. Permutation Sequence 第 k 個排列、440. K-th Smallest in Lexicographical Order 字典序的第K小數字。基本思想是:通過計算階乘,提前知道這一個分支的葉子節點個數,如果 k 不在這個分支上則剪枝:
4. 二維平面搜索問題
文章開頭提到的走迷宮問題,其實就是在二維平面上的回溯算法問題,終止條件時當前位置爲終點。既然是回溯算法,怎麼都逃不出我們反覆強調的三要素:路徑 & 選擇列表 & 終止條件:
4.1 路徑 - 二維數組 useds
在全排列問題中,我們使用了一維布爾數組 used,用來標記已經走過的路徑。同樣地,在二維平面裏,我們需要使用二維布爾數組,來標記已經走過的路徑。
1\. 一維數組 int[] useds
2\. 二維數組 int[][] useds
4.2 選擇列表 - 偏移量數組
在二維平面上搜索時,一個點的選擇列表就是它的上下左右方向(除了來的方向),爲了方便編碼,可以使用一個偏移量數組,偏移量數組內的 4 個偏移的順序是無關緊要;
int[][] direction = {{-1, 0}, {0, -1}, {0, 1}, {1, 0}};
4.3 檢查邊界
二維平面通常是有邊界的,因此可以寫一個函數判斷當前輸入位置是否越界:
private boolean check(int row, int column) {
return row >= 0 && row < m && column >= 0 && column < n;
}
有了前面的鋪墊,我們來看這道題:79. Word Search 單詞搜索 就比較好理解了。
5. Flood Fill 問題
Flood Fill(泛洪填充,或稱 Seed Fill 種子填充),是上一節二維平面搜索問題的進階版本。即:在二維平面上找出滿足條件的相連節點。
所謂相連節點,指的是兩個節點上下左右 4 個方向存在相連的節點。有的題目會擴展到 8 個方向(斜對角的 4 個方向),不過只是多了幾個方向而已,沒有很大區別。例如,下面這幅圖將中心節點相連的白色方格上色:
整個解題框架與上一節的 二維平面搜索問題 大體相同,這裏着重講解另一種解法:並查集,在這篇文章裏,我們已經詳細講解過並查集的使用場景與解題技巧:《數據結構面試題 | 並查集 & 聯合 - 查找算法》
簡單來說:並查集適用於處理不相交集合的連通性問題,這正適用於解決 Flood Fill 的連通問題。我們可以將與中心節點相連的節點合併到同一個分量中,全部處理完成後,通過查詢並查集便可判斷兩個節點是否處於相連:
提示: 同時使用路徑壓縮和按秩合併,查詢的時間複雜度接近<math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>O</mi><mo stretchy="false">(</mo><mn>1</mn><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">O(1)</annotation></semantics></math>O(1)
6. 總結
回溯算法的思想並不複雜,但是確實高頻考點;熟練掌握回溯算法,對於理解動態規劃算法也很有幫助,學習優先級較高。
本文在開源項目:https://github.com/xieyuliang/Note-Android 中已收錄,裏面包含不同方向的自學編程路線、面試題集合/面經、及系列技術文章等,資源持續更新中...
這次就分享到這裏吧,下篇見。
參考資料
- 《回溯法》 —— 維基百科
- 《Flood Fill》 —— 維基百科
- 《回溯算法入門級詳解 + 練習(全排列 I)》 —— liweiwei 著
- 《回溯搜索 + 剪枝(全排列 II)》 —— liweiwei 著
- 《在二維平面上使用回溯法(單詞搜索 I)》 —— liweiwei 著
- 《回溯算法》 —— liweiwei 著
- 《回溯算法詳解》 —— labuladong 著
- 《FloodFill算法詳解及應用》 —— labuladong 著
- 《數據結構與算法之美》 —— 王爭 著,極客時間 出品
- 《300分鐘搞定算法面試》 —— 力扣&拉勾網 出品
作者:彭醜醜
原文地址:https://juejin.cn/post/6882928981268496398