回溯算法是一種很重要的算法,有着通用算法的美稱,不管是leetcode也好還是考研、筆試也罷都會有大量回溯算法的題目出現。該文章首先會解決什麼叫做回溯算法,然後以leetcode題目《46. 全排列》、 leetcode題目《131. 分割回文串》作爲例題,來講解如何思考回溯算法、怎麼樣進行回溯,最後總結回溯模板。題目講解用僞代碼,是爲了讓JAVA、Python、C++等語言的靚仔、靚女搞明白,文末有具體實現的鏈接。
文章目錄
致敬一下大佬的文章:leetcode用戶liweiwei1419對題目《46. 全排列》的題解《從全排列問題開始理解“回溯搜索”算法(深度優先遍歷 + 狀態重置 + 剪枝》,網址:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/,給了許多啓發,也是通過這篇文章,我弄懂了這個回溯鬼東西是怎麼玩的。
1. 用leetcode題目《46.全排列》做例題,方便講解
所有的算法的提出都是爲了解決比較實際的問題,當然用工程方面的東西太複雜了,這裏用leetcode的題目。
leetcode題目《46. 全排列》,網址:https://leetcode-cn.com/problems/permutations/
2. 回溯算法——窮舉法的2.0版本
回溯算法又叫回溯搜索算法,核心思路是將一個問題中所有可能出現的情況(窮舉法)轉化爲解集樹,然後逐一剔除剪枝找到符合條件的解又或者是解集(窮舉的優化)。 “回溯”指的是“狀態重置”,也就是一條路走不通回到原點在走一次。這種“剔除”技巧叫做剪枝技巧。常見的應用是求解子情況。
回溯法思路的簡單描述是:把問題的解空間轉化成了圖或者樹的結構表示,然後使用深度優先搜索策略進行遍歷,遍歷的過程中記錄和尋找所有可行解或者最優解。
那麼爲了方便理解和構造回溯算法的模型,在這裏我將全排列問題中所有可能出現的情況以樹狀圖的形式列舉出來,(一條路徑一個解)
回溯,且剪去不符合條件的解之後。
2.1 等一下,這個東西好像在哪裏見過
其實深度優先搜索就用到了回溯算法的思想,都是在探索過程中搜索可能符合條件的解,一種大的情況探索完了,在換一種情況,此路不通換一條路。不太熟悉深度優先搜索的,可以看一下我自己的博客《深度優先搜索DFS(動畫解算法,內附C++/C、JAVA、Python的實現)》
不同點在於深度優先搜索是在探索一個比較明顯的圖(當然也有用在樹的遍歷),而回溯算法探索的是一個比較隱晦的解集樹。
深度優先搜索的往回走的過程:
2.2 爲什麼要回溯,頭鐵撞南牆不香嗎?
爲什麼要回溯?回溯是爲了不用走那麼多路,從而減少時間複雜度。
還走1 1 * 這條路,怕是直接把我頭按在電飯煲裏面去喲。又不是我方打野在野區刷微信步數。
2.3 同樣是探索可能出現的所有情況,爲什麼搜索的時候不用廣度優先搜索
常見的探索方法有廣度(BFS)和深度(DFS),爲什麼回溯算法只用深度優先搜索呢?
最主要是深度沒有這麼麻煩,不用設置隊列等亂七八糟的。
- 只有遍歷所有可能出現的情況,才能得到所有符合條件的解
- 在深度優先遍歷的時候,不同子情況之間(比如說 1 2 3 切換到 1 3 2)切換很容易,每兩個子情況之間的差別只有1處,因此往後走,直接對整個路徑的(比如說:1 1 * 這條路在1的時候就可以放棄了)放棄比較容易,全局用一份狀態變量就可以完成搜索。
- 廣度優先搜索要用到隊列還要編寫結點類別,比較麻煩。使用深度優先搜索,直接用了系統的棧,系統棧幫助我們保存了每一個結點的狀態信息。於是我們不用編寫結點類,不必手動編寫棧完成深度優先遍歷。簡單粗暴有效
- 如果使用廣度優先遍歷,從淺層轉到深層,狀態的變化就很大,此時我們不得不在每一個狀態都新建變量去保存它,從性能來說是不划算的;
參考了:leetcode用戶liweiwei1419對題目《46. 全排列》的題解《從全排列問題開始理解“回溯搜索”算法(深度優先遍歷 + 狀態重置 + 剪枝)》,網址:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/
我自己的博客:《廣度/寬度優先搜索 BFS (動畫解算法 附C++\C、JAVA、Python的代碼實現》
3. 以全排列爲例題,進行回溯算法的構建
3.1 首先用示例畫出可能發生的情況,以及最終解,方便觀察
3.2 然後通過觀察上面圖形,用四個問題整理思路,來構造遞歸樹
分支探索
1.問:分支是如何產生的?如何探索分支?
答:.就對那幾個數字進行查找,也就是在輸入數組內進行組合,因此對數組進行遍歷
10. for i to the length of the nums,i++ //進行路徑探索
......
17. end for
確定答案形式
2. 問:題目需要的解在哪裏?是在葉子結點、還是在非葉子結點、還是在從根結點到葉子結點的路徑?
答:題目的解產生在從根結點到葉結點的路徑,因此考慮用一個臨時鏈表list保留當前路徑,用一個鏈表answer保留已經探索過的路徑(也就是題目的解)。
1. Initialize the list “answer” to save the path,which have been explored
2. //初始化一個列表answer中用來保存已經搞完的路徑
........
10. for i to the length of the nums,i++ //進行路徑探索
........
14. add the nums[i] in the path //將nums[i]加到路徑中,用來移動,往下走
........
17. end for
18. }
........
21. ALL-SEQUENCE-PERMUTATION (nums){ //全排列問題
22. Initialize the list of “path” //初始化路徑
23. BACK-TRACING (nums,path) //回溯探索
24. return answer //返回答案
25. }
進行剪枝
3.問:哪些搜索是會產生不需要的解的?怎麼樣將這些不需要的解給除去?如果是在淺層就知道了這條路走不通(比如1 1 *),怎麼樣提前剪枝?
答:從圖裏面比較直觀的感覺,是路徑之前出現過的數字不要,與自己相同的不要。那麼好,在當前保留的路徑中檢查是否包含那個數字,如果包含,不要,往下走,同時進行判斷
想一下,自己肯定也包含在了路徑裏面,所以只要檢查下一個數字是否包含在路徑中就🆗了,不用檢查下個數字是否與自己相同
10. for i to the length of the nums,i++ //進行路徑探索
11. if the path contains the element nums[i] //在淺層就知道這條路走不通了,趕緊回頭換一條路
12. continue
13.
14. add the nums[i] in the path //將nums[i]加到路徑中,用來移動,往下走
15.
16.
17. end for
判斷探索到底
4. 問:什麼時候結束搜索,進行回溯換路?
答: 當前路徑長度與輸入數組長度一樣的時候結束搜索,進行回溯。
5. if the length of the nums == the length of path //如果長度相等表示探索完畢
6. add path in the answer //把path代表的單詞放入answer中
7. return ;
8. end if
3.3 完整的僞代碼,各種語言的實現在後面參考資料那裏
1. Initialize the list “answer” to save the path,which have been explored
2. //初始化一個列表answer中用來保存已經搞完的路徑
3.
4. BACK-TRACING (nums,path){
5. if the length of the nums == the length of path //如果長度相等表示探索完畢
6. add path in the answer //把path代表的單詞放入answer中
7. return ;
8. end if
9.
10. for i to the length of the nums,i++ //進行路徑探索
11. if the path contains the element nums[i] //在淺層就知道這條路走不通了,趕緊回頭換一條路
12. continue
13.
14. add the nums[i] in the path //將nums[i]加到路徑中,用來移動,往下走
15. BACK-TRACING(nums,path); //回溯
16. remove the last element of the path //當遞歸結果出來之後,刪除前一層的元素,實現往回走的步驟
17. end for
18. }
19.
20.
21. ALL-SEQUENCE-PERMUTATION (nums){ //全排列問題
22. Initialize the list of “path” //初始化路徑
23. BACK-TRACING (nums,path) //回溯探索
24. return answer //返回答案
25. }
3.4 可能還是哪裏有點懵逼,我用圖解一個小路徑
CSDN圖片上傳5m設置實在太難受了,應該要10m
4. 整個好活,做道題開心一下
leetcode題目《131. 分割回文串》,網址:https://leetcode-cn.com/problems/palindrome-partitioning/
4.1 畫出示例的各種情況
卡諾奇諾今猶在,不見當年倒茶人,順手上一支香。
4.2 按照四個問題,整理思路,構造遞歸樹
分支探索
1.問:分支是如何產生的?如何探索分支?
答:分支是切割的字符串和剩餘的字符串,探索方式是分段切割,看下面示意
確定答案形式
2. 問:題目需要的解在哪裏?是在葉子結點、還是在非葉子結點、還是在從根結點到葉子結點的路徑?
答:題目的解產生非葉子結點(AA一個結點,B一個結點),因此考慮用一個字符串的集合來存儲答案
進行剪枝
3.問:哪些搜索是會產生不需要的解的?怎麼樣將這些不需要的解給除去?如果是在淺層就知道了這條路走不通,怎麼樣提前剪枝?
答:從圖裏面比較直觀的感覺,子字符串不是迴文字符串的不要。因此對當前切割的子字符串進行檢查是否爲迴文子字符串,如果不是再往前面切一點,如果是則保留當前的切割的子字符串,再在剩餘的字符串裏面進行切割
判斷探索到底
4. 問:什麼時候結束搜索,進行回溯換路?
答:子字符串只有一個元素就可以進行大的換路回溯。
4.3 完整的僞代碼
1. Initialize vector<vector<string> answer to save the path,which have been explored
2. //創建一個字符串的集合去保存已經探索過的子字符串
3.
4. Check-Palindrome-string (substring,left,right) //對當前分割的子字符串進行檢查
5. while left < right
6. if substring[left] != substring[left]
7. return false
8. end if
9.
10. left++ //往中間移動
11. right--
12. end while
13. return true
14.
15.
16. BACK-TRACING (word,start,path)
17. if start == the length of the word //只剩下一個元素代表探索完畢
18. add the path in the answer //把元素放入到上面那裏去
19. return ;
20. end if
21.
22. for i = start to the length of the word //從分割後的位置開始劃分
23. if Check-Palindrome-string(word,i,path) != true //現在子段不是迴文,往前切
24. continue
25.
26. add the substring of word[start] to word[i-start+1] in the path //在解集樹往下走
27. BACK-TRACING (word,i+1,path)
28. delete the last element of the path //深層往回走
29. end for
30.
31.
32. PARTITION (string s) //進行劃分
33. if the length of the s is 0 //s是空玩個蛇皮
34. return answer
35.
36. Initialize the string 'path' //初始化路徑
37. BACK-TRACING (s,0,path) //從0開始探索
38. return answer
5 說了這麼多,來總結一下回溯算法的模板
畫圖觀察確定四個問題的答案,通過這四個答案來構造遞歸樹
1. 初始化一個answer來存儲遞歸到最下層時候的答案 --
2. //對應問題2:題目需要的解在哪裏?是在葉子結點、還是在非葉子結點、還是在從根結點到葉子結點的路徑?
3.
4. BACK-TACING (.....,path) //進行回溯,設立一個path保留臨時路徑
5. if 設立路徑探索完成條件 //對應問題4
6. 把探索完的路徑保存在答案中
7. for 如何產生分支,怎麼樣探索分支 //對應問題1:分支是如何產生的?如何探索分支?
8. if 剪枝 //對應問題3:哪些搜索是會產生不需要的解的?怎麼樣將這些不需要的解給除去?如果是在
9. continue //淺層就知道了這條路走不通,怎麼樣提前剪枝?
10.
11. 那麼好,符合條件了,將這個放在臨時路徑中 //用來移動節點,在解集樹中往下走
12. BACK-TACING (......,path) //進行回溯,問題1
13. delete the last element of the path //往回走,同時清理path爲下一次的調用
14. end for
15.
16. SOLUTION (input) //輸入一個東西
17. if 如果輸入的爲空 直接返回0 //鬼知道測試東西回不回爲空,小心一點好
18.
19. Initialize path //初始化路徑,用來保留當前探索過程中
20. BACK-TRACING (.....,path) //進行回溯
21. return answer
6. 回溯類型算法比較經典的例題
搞兩道題目,再多建議刷leetcode的探索專欄《算法面試題彙總》裏面的字符串那裏大部分都是用到了回溯算法。
6.1 括號生成問題,比較簡單容易上手
括號生成問題,比較簡單適合上手練一下。
leetcode題目《22. 括號生成》,網址:https://leetcode-cn.com/problems/generate-parentheses/
6.2 N皇后問題經典中的經典,難度較高
N皇后問題不用多說,經典中的經典:(這道題比較難,但是很經典,基本上大部分講解回溯算法的都搞這道題作爲例題)
leetcode題目《51. N皇后》,網址:https://leetcode-cn.com/problems/n-queens/
7. 參考資料
- CSDN博主littlelufisher的《回溯算法超通俗易懂詳盡分析和例題》,網址:https://blog.csdn.net/sinat_27908213/article/details/80599460
- CSDN博主GoRustNeverStop的《[回溯算法] 五大常用算法之回溯法》,網址:https://blog.csdn.net/weiyuefei/article/details/79316653
- leetcode用戶labuladong對題目《46. 全排列》的題解《回溯算法詳解》,網址:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-xiang-jie-by-labuladong-2/
- leetcode用戶liweiwei1419對題目《131. 分割回文串》的題解《回溯、優化(使用動態規劃預處理數組)》,網住:https://leetcode-cn.com/problems/palindrome-partitioning/solution/hui-su-you-hua-jia-liao-dong-tai-gui-hua-by-liweiw/
- leetcode用戶liweiwei1419對題目《46. 全排列》的題解《從全排列問題開始理解“回溯搜索”算法(深度優先遍歷 + 狀態重置 + 剪枝)
》,網址:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/ - leetcode題目《46. 全排列》,網址:https://leetcode-cn.com/problems/permutations/
- leetcode題目《131. 分割回文串》,網址:https://leetcode-cn.com/problems/palindrome-partitioning/
- leetcode題目《51. N皇后》,網址:https://leetcode-cn.com/problems/n-queens/
- 我自己的博客《深度優先搜索DFS(動畫解算法,內附C++/C、JAVA、Python的實現)》、《Dijksta算法詳解(動畫解算法,附C++\C、JAVA、Python的實現)》、《廣度/寬度優先搜索 BFS (動畫解算法 附C++\C、JAVA、Python的代碼實現)》