backtracking(回溯法)是一類遞歸算法,一般用於解決某類問題:要求找出答案空間中符合某種特定要求的答案,好比eight queens puzzle(將國際象棋的八個皇后排布在8x8的棋盤中,使她們不能互相威脅)。回溯法會增量性地找尋答案,每次只構建答案的一部分,在構建的過程當中若是意識到答案不符合要求,會馬上將這一部分答案及它的全部子答案拋棄,以提升效率。html
回溯法的核心模型是一個決策樹,每一個節點的子節點表明該節點的選項。從根節點出發,做出某種選擇達到節點A,隨後會面臨節點A的選項,重複這個過程直到達到葉節點。若是途中發現某節點B的狀態已經不符合要求,那麼棄掉以B爲根節點的子決策樹。node
到達葉節點時,判斷其是否符合問題要求,而後根據狀況做相應處理(如將符合要求的葉節點加入一個list)。葉節點沒有子節點,所以回溯到上一個訪問過的節點,以嘗試其餘選擇。當咱們最終回溯到根節點,且已經窮盡了根節點的全部選擇時,算法結束。算法
在上圖的決策樹中,用good和bad分別表明符合和不符合要求的葉節點。回溯法的遍歷過程是這樣的:數組
本文給出leetcode上數組排列組合問題的回溯法解答。這些問題大多爲「返回某數組/字符串的全部排列/組合」類型,沒有提出明確的要求來篩選葉節點。看起來,彷佛簡單地使用回溯法遍歷決策樹便可,可是在實現中會遇到一些棘手的小問題,好比每個選項如何定義,如何記錄某一個節點上已經選擇過的選項等。學習回溯法時,能夠先從這些問題開始熟悉回溯法的套路,熟練後再去解決更復雜的問題。數據結構
決策樹的設計是回溯法的關鍵。對於排列組合問題,這一步的本質是將intuitional的想法映射到解題空間,變化爲決策樹每個節點的選項。app
注意,決策樹並非一個具體存在的數據結構。在回溯法中,決策樹表明方法遞歸調用的方式和順序,每個節點的選項實際上編寫在代碼邏輯中,好比用一個for循環以某種邏輯遍歷數組,並在每次的iteration中遞歸調用方法,這個過程至關於對某一個選項做出了選擇。而退出到上一層方法則至關於回溯到決策樹的上一個節點。函數
以subset問題爲例,給出一個數組[1,2,3,4],手動寫出全部子集的過程你們都會,那麼如何將它抽象成一個具體算法並映射到決策樹?學習
能夠這麼想:[1,2,3,4]中有四個元素,其子集能夠有0-4個元素。設計樹的每個節點爲一個子集,根爲空集。在根的基礎上加一個元素,就成爲了有1個元素的子集,那麼根有4個選項。在第一層的節點基礎上再增長一個元素,就成爲了有2個元素的子集,以此類推,直至第四層。ui
那麼,第一層必定有4個選項,分別爲1,2,3,4。從1向下,即在1的基礎上增長元素,則有12,13,14。從2向下,爲了不重複,只容許選擇2以後的元素,則有23和24,以此類推。spa
若是以塗滿紅色表示節點爲知足要求的結果,則整個樹的全部節點都爲結果,由於每一個節點都表明數組的一個子集。
若是明白了subset問題的決策樹設計,則subsets w/ duplicates問題與之大體相似,只是要避免重複的狀況,即在subset問題的代碼基礎上加入一些條件判斷。
permutation問題實際上更爲簡單。對於數組[1,2,3],想象三個有順序的盒子,每一個盒子能夠放一個不一樣的數字。順着回溯法的增量找尋答案的思路,能夠這麼設計:樹的第零層(根節點)三個盒子全爲空,第一層填入盒子1,第二層在盒子1填入某數的基礎上填入盒子2,第三層填入盒子3。
在這個決策樹中,全部葉節點爲想要找尋的結果。
與問題2類似,permutations w/ duplicates也須要在permutation問題的基礎上加入判斷條件,防止計入重複的排列。
combination問題與subset同爲組合性質的問題,因此解法相似。在樹的每一層增量選擇一個元素構成答案的一部分。不一樣的是,因爲有sum == target這個要求,在枚舉過程當中須要做出選擇,拋棄不符合要求的選項。
在上圖的決策樹中,Root爲空,括號中表示還須要求和的數,如第一層中的第一個節點2表示在組合中加入2,則剩餘8-2=6。因爲容許將同一個元素選擇屢次,2的選項中能夠包含2本身。在3的選項中,爲了防止重複,規定只能從3開始日後選擇。可在回溯前對數組進行排序,則當發現括號中的數小於選項自己時(如5(3)),則該節點沒有可行的選項,能夠將該節點拋棄。
問題6在問題5的基礎上加入條件判斷。
與以上問題相似,用決策樹對分割進行增量選擇,在每一層對餘下的字符串進行分割。當察覺到新的分割不是迴文時能夠當即捨棄。
看第一層: 對String "ababa",先從頭分割出一塊,這一塊的長度能夠是1-5,因而咱們有了圖中第一層的5個節點,括號中表示餘下的字符串。ab和abab都不是迴文,於是能夠當即捨去。ababa已是一個徹底分割了,於是能夠加入答案集。再對其它節點括號中的元素進行分割,再也不贅述。
本文附錄中總結了問題1-7的決策樹,以供參考。
首先記住回溯法的通常廣泛實現,對於不一樣的問題只要往這個框框上套便可。
1 boolean solve(Node n) { 2 if n is a leaf node { 3 if the leaf is a goal node, return true 4 else return false 5 } else { 6 for each child c of n { 7 if solve(c) succeeds, return true 8 } 9 return false 10 } 11 }
以上代碼描述了回溯法所使用的遞歸函數的大體樣子,其實很簡單。記住這個大體的樣子是爲了更熟練地寫回溯法的代碼。上圖中的返回類型是boolean,表示以n爲根節點的子樹中是否存在答案;另外,默認只有葉節點可能成爲答案。這些細節不必定是固定的,在不一樣問題中均可以靈活修改。
1 public List<List<Integer>> subsets(int[] nums){ 2 List<List<Integer>> result = new LinkedList<List<Integer>>(); 3 subsets(nums, 0, new ArrayList<Integer>(), result); 4 return result; 5 } 6 7 private void subsets(int[] nums, int start, List<Integer> temp, List<List<Integer>> result){ 8 result.add(new ArrayList<Integer>(temp)); 9 for(int i = start; i < nums.length; i++){ 10 temp.add(nums[i]); 11 subsets(nums, i + 1, temp, result); 12 temp.remove(temp.size() - 1); 13 } 14 }
1 public List<List<Integer>> subsetsWithDup(int[] nums){ 2 List<List<Integer>> result = new LinkedList<List<Integer>>(); 3 Arrays.sort(nums); 4 subsetsWithDup(nums, 0, new ArrayList<Integer>(), result); 5 return result; 6 } 7 8 private void subsetsWithDup(int[] nums, int start, List<Integer> temp, List<List<Integer>> result){ 9 result.add(new ArrayList<Integer>(temp)); 10 for(int i = start; i < nums.length; i++){ 11 if(i != start && nums[i] == nums[i-1]) continue; 12 temp.add(nums[i]); 13 subsetsWithDup(nums, i + 1, temp, result); 14 temp.remove(temp.size() - 1); 15 } 16 }
1 public List<List<Integer>> permute(int[] nums) { 2 boolean[] used = new boolean[nums.length]; 3 List<List<Integer>> result = new LinkedList<List<Integer>>(); 4 permute(nums, used, new ArrayList<Integer>(), result); 5 return result; 6 } 7 8 private void permute(int[] nums, boolean[] used, List<Integer> temp, List<List<Integer>> result){ 9 if(temp.size() == nums.length) 10 result.add(new ArrayList<>(temp)); 11 else{ 12 for(int i = 0; i < nums.length; i++){ 13 if(used[i]) continue; 14 used[i] = true; 15 temp.add(nums[i]); 16 permute(nums, used, temp, result); 17 temp.remove(temp.size() - 1); 18 used[i] = false; 19 } 20 } 21 }
1 public List<List<Integer>> permuteUnique(int[] nums) { 2 Arrays.sort(nums); 3 boolean[] used = new boolean[nums.length]; 4 List<List<Integer>> result = new LinkedList<List<Integer>>(); 5 permuteUnique(nums, used, new ArrayList<Integer>(), result); 6 return result; 7 } 8 9 private void permuteUnique(int[] nums, boolean[] used, 10 List<Integer> temp, List<List<Integer>> result){ 11 if(temp.size() == nums.length) 12 result.add(new ArrayList<>(temp)); 13 else{ 14 for(int i = 0; i < nums.length; i++){ 15 if(used[i] || (i > 0 && nums[i] == nums[i-1] && !used[i-1])) 16 continue; 17 used[i] = true; 18 temp.add(nums[i]); 19 permuteUnique(nums, used, temp, result); 20 used[i] = false; 21 temp.remove(temp.size() - 1); 22 } 23 } 24 }
這一題是全部問題中比較難的一題,難在如何排除重複排列。其實,與第2題同樣,掌握一個祕籍便可:重複的元素能夠出如今決策樹的不一樣層,但不能出如今同一層。
因爲已經出如今上面某層的元素會被used[]數組記錄爲true,所以只要查一下前面的一樣元素是否出如今上面某層。若是出現過,說明當前元素能夠出如今當前這層。(數組固然已是排序的)
假設數組中有幾個連着的2,如今進行到決策樹的某一層,used[]數組代表前兩個2已經出現過,按照代碼第15行在當前層能夠插入第三個2,而不能插入第4、第五個……這樣作是合理的。
這個小trick有點難想,最好直接記住。
1 public List<List<Integer>> combinationSum(int[] candidates, int target) { 2 List<List<Integer>> result = new LinkedList<List<Integer>>(); 3 combinationSum(candidates, 0, target, result, new ArrayList<Integer>()); 4 return result; 5 } 6 7 private void combinationSum(int[] candidates, int start, int target, 8 List<List<Integer>> result, List<Integer> temp) { 9 if(target == 0) 10 result.add(new ArrayList<>(temp)); 11 else if(target < 0) 12 return; 13 else{ 14 for(int i = start; i < candidates.length; i++){ 15 temp.add(candidates[i]); 16 combinationSum(candidates, i, target - candidates[i], result, temp); 17 temp.remove(temp.size() - 1); 18 } 19 } 20 }
1 public List<List<Integer>> combinationSum2(int[] candidates, int target) { 2 List<List<Integer>> result = new LinkedList<List<Integer>>(); 3 Arrays.sort(candidates); 4 combinationSum2(candidates, 0, target, result, new ArrayList<Integer>()); 5 return result; 6 } 7 8 private void combinationSum2(int[] candidates, int start, int target, 9 List<List<Integer>> result, List<Integer> temp) { 10 if(target == 0) 11 result.add(new ArrayList<>(temp)); 12 else if(target < 0) 13 return; 14 else{ 15 for(int i = start; i < candidates.length; i++){ 16 if(i != start && candidates[i] == candidates[i-1]) 17 continue; 18 temp.add(candidates[i]); 19 combinationSum2(candidates, i + 1, target - candidates[i], result, temp); 20 temp.remove(temp.size() - 1); 21 } 22 } 23 }
與題目2高度相似。
1 public List<List<String>> partition(String s) { 2 List<List<String>> result = new LinkedList<List<String>>(); 3 partition(s, new ArrayList<>(), result); 4 return result; 5 } 6 7 private void partition(String s, List<String> temp, List<List<String>> result){ 8 if(s.length() == 0) 9 result.add(new ArrayList<>(temp)); 10 else{ 11 for(int i = 1; i <= s.length(); i++){ 12 String partialString = s.substring(0, i); 13 if(!isPalindrome(partialString)) continue; 14 temp.add(partialString); 15 partition(s.substring(i, s.length()), temp, result); 16 temp.remove(temp.size() - 1); 17 } 18 } 19 } 20 21 private boolean isPalindrome(String s){ 22 int begin = 0, end = s.length() - 1; 23 while(begin < end) 24 if(s.charAt(begin++) != s.charAt(end--)) 25 return false; 26 return true; 27 }
思路與上面幾題相似。要注意加粗的部分,因爲substring的函數定義的關係,須要寫成<=。
遞歸類的函數自己不是十分好理解,有一個小技巧,即把函數的調用用縮進的方式打印出來。
能夠先準備一個Backtracking基類
1 public class Backtracking { 2 static String indent = ""; 3 4 static void enter() { //進入方法 5 System.out.println(indent + "Entering backtrack()"); 6 indent = indent + "| "; 7 } 8 9 static void leave(){ //退出方法 10 indent = indent.substring(3); 11 System.out.println(indent + "Leaving backtrack()"); 12 } 13 14 static void print(String s){ //在方法內打印 15 System.out.println(indent + s); 16 } 17 18 }
而後,讓要調試的類繼承Backtracking,調用對應的方法
1 public class Leetcode131 extends Backtracking{ 2 public List<List<String>> partition(String s) { 3 ...//call backtrack() 4 } 5 6 private void backtrack(String s, List<String> temp, List<List<String>> result){ 7 enter(s); 8 print("temp:"+temp); 9 ...//method body (Recursive) 10 leave(); 11 } 12 }
能夠看到相似下圖的結果:
本文敘述瞭如何經過一些基本的排列組合問題練習回溯法。用回溯法解決一個問題時的大概思路是先畫出某個輸入例子的決策樹,在樹中,考慮以下幾點:
而後,根據回溯法的廣泛實現進行代碼實現。
總結在此,讀者可作對比。
參考資料:
A general approach to backtracking questions in Java