BACKTRACKING
backtracking(回溯法)是一類遞歸算法,通常用於解決某類問題:要求找出答案空間中符合某種特定要求的答案,比如eight queens puzzle(將國際象棋的八個皇后排布在8x8的棋盤中,使她們不能互相威脅)。回溯法會增量性地找尋答案,每次只構建答案的一部分,在構建的過程中如果意識到答案不符合要求,會立刻將這一部分答案及它的所有子答案拋棄,以提高效率。
回溯法的核心模型是一個決策樹,每個節點的子節點代表該節點的選項。從根節點出發,作出某種選擇達到節點A,隨后會面臨節點A的選項,重復這個過程直到達到葉節點。如果途中發現某節點B的狀態已經不符合要求,那么棄掉以B為根節點的子決策樹。
到達葉節點時,判斷其是否符合問題要求,然后根據情況作相應處理(如將符合要求的葉節點加入一個list)。葉節點沒有子節點,因此回溯到上一個訪問過的節點,以嘗試其他選擇。當我們最終回溯到根節點,且已經窮盡了根節點的所有選擇時,算法結束。

在上圖的決策樹中,用good和bad分別代表符合和不符合要求的葉節點。回溯法的遍歷過程是這樣的:
- 從Root開始,有A和B兩個選項。選擇A。
- 從A開始,有C和D兩個選項。選擇C。
- C不符合要求,回溯到A。
- A處的剩余選項為D。選擇D。
- D不符合要求,回溯到A。
- A的選項已窮盡,回溯到Root。
- Root處的剩余選項為B。選擇B。
- 從B開始,有E和F兩個選項。選擇E。
- E符合要求,將其加入某個list,回溯到B。
- B出的剩余選項為F。選擇F。
- F不符合要求。回溯到B。
- B的選項已窮盡,回溯到Root。
- Root的選項已窮盡。結束。
本文給出leetcode上數組排列組合問題的回溯法解答。這些問題大多為“返回某數組/字符串的所有排列/組合”類型,沒有提出明確的要求來篩選葉節點。看起來,似乎簡單地使用回溯法遍歷決策樹即可,但是在實現中會遇到一些棘手的小問題,比如每一個選項如何定義,如何記錄某一個節點上已經選擇過的選項等。學習回溯法時,可以先從這些問題開始熟悉回溯法的套路,熟練后再去解決更復雜的問題。
問題集
- subsets: 給出一個不含重復元素的數組,返回它的所有子集;
- subsets w/ duplicates: 給出一個含有重復元素的數組,返回它的所有子集,不准重復;
- permutations: 給出一個不含重復元素的數組,返回它的所有排列;
- permutations w/ duplicates:給出一個含有重復元素的數組,返回它的所有排列,不准重復;
- combination sum: 給出一個整數數組及整數target,返回和為target的所有組合,每個元素可以使用無窮多次;
- combination sum -- cannot use same element twice: 給出一個整數數組及整數target,返回和為target的所有組合,每個元素只能使用一次;
- palindrome partition: 給出一個String,返回它的所有分割,使分割后的每一個元素都是回文。
決策樹
決策樹的設計是回溯法的關鍵。對於排列組合問題,這一步的本質是將intuitional的想法映射到解題空間,變化為決策樹每一個節點的選項。
注意,決策樹並不是一個具體存在的數據結構。在回溯法中,決策樹代表方法遞歸調用的方式和順序,每一個節點的選項實際上編寫在代碼邏輯中,比如用一個for循環以某種邏輯遍歷數組,並在每次的iteration中遞歸調用方法,這個過程相當於對某一個選項作出了選擇。而退出到上一層方法則相當於回溯到決策樹的上一個節點。
subset問題(問題1、2)
以subset問題為例,給出一個數組[1,2,3,4],手動寫出所有子集的過程大家都會,那么如何將它抽象成一個具體算法並映射到決策樹?
可以這么想:[1,2,3,4]中有四個元素,其子集可以有0-4個元素。設計樹的每一個節點為一個子集,根為空集。在根的基礎上加一個元素,就成為了有1個元素的子集,那么根有4個選項。在第一層的節點基礎上再增加一個元素,就成為了有2個元素的子集,以此類推,直至第四層。
那么,第一層一定有4個選項,分別為1,2,3,4。從1向下,即在1的基礎上增加元素,則有12,13,14。從2向下,為了避免重復,只允許選擇2之后的元素,則有23和24,以此類推。

如果以塗滿紅色表示節點為滿足要求的結果,則整個樹的所有節點都為結果,因為每個節點都代表數組的一個子集。
如果明白了subset問題的決策樹設計,則subsets w/ duplicates問題與之大致類似,只是要避免重復的情況,即在subset問題的代碼基礎上加入一些條件判斷。
permutation問題(問題3、4)
permutation問題實際上更為簡單。對於數組[1,2,3],想象三個有順序的盒子,每個盒子可以放一個不同的數字。順着回溯法的增量找尋答案的思路,可以這么設計:樹的第零層(根節點)三個盒子全為空,第一層填入盒子1,第二層在盒子1填入某數的基礎上填入盒子2,第三層填入盒子3。

在這個決策樹中,所有葉節點為想要找尋的結果。
與問題2相似,permutations w/ duplicates也需要在permutation問題的基礎上加入判斷條件,防止計入重復的排列。
combination sum問題(問題5、6)
combination問題與subset同為組合性質的問題,所以解法類似。在樹的每一層增量選擇一個元素構成答案的一部分。不同的是,由於有sum == target這個要求,在枚舉過程中需要作出選擇,拋棄不符合要求的選項。

在上圖的決策樹中,Root為空,括號中表示還需要求和的數,如第一層中的第一個節點2表示在組合中加入2,則剩余8-2=6。由於允許將同一個元素選擇多次,2的選項中可以包含2自己。在3的選項中,為了防止重復,規定只能從3開始往后選擇。可在回溯前對數組進行排序,則當發現括號中的數小於選項本身時(如5(3)),則該節點沒有可行的選項,可以將該節點拋棄。
問題6在問題5的基礎上加入條件判斷。
palindrome partition(問題7)
與以上問題類似,用決策樹對分割進行增量選擇,在每一層對余下的字符串進行分割。當察覺到新的分割不是回文時可以立即舍棄。

看第一層: 對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:subsets
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 }
- 進入下面這個private的subsets方法相當於進入了一個節點。此時(在第8行)temp中的值代表該節點對應的子集。(對應決策樹圖)
- 由於每個節點都是答案,每次進入節點時先將temp加入result(第8行)。
- 每個節點的選項為在該節點代表的子集基礎上要增量增加的元素。前文中已經敘述到,為了避免子集重復,假設節點代表的子集為[1],則子節點只能增加1之后的元素。此處的start變量是用來標記可選元素的左邊界。
- 回溯時,只需將子節點增量增加的元素去掉即可。
題目2:有重復元素的數組的subsets
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 }
- 為了不引入重復子集,在回溯前先給數組排序,這樣相同的元素會被排在一起,增量增加元素時跳過相同的元素即可。
- 如上題,start表示某節點下選項可以增加的元素的左邊界。比如數組[1,2,2,3],下圖圈出的節點選擇的是index=1的2,則其子節點的start值為2,即選擇范圍為[1,2,2,3]。第一個加粗的2雖然跟前面的2重復,但由於下降了一層,這個2仍然需要考慮。於是有了第11行。

題目3:permutations
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 }
- 只有葉節點為答案,因此只有temp長度達到數組長度時將其加入。(9-10行)
- 判斷選項的合法性:用boolean數組used記錄已經加入的元素。(13行)
- 回溯方式:將加入選項時的行為(14-15行)做逆運算即可。(17-18行)
題目4:permutation w/ duplicates
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,而不能插入第四、第五個……這樣做是合理的。
這個小trick有點難想,最好直接記住。
題目5:combination sum
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 }
- 每次調用時更新target,target為負時直接返回。
- 選項的排布方式與問題1類似,由於可以無限次重用某一元素,所以16行未把i加1。
- 由上面幾題看到,start的用法千變萬化,死記是不科學的,要畫出決策樹充分理解題目的本質。(組合/排列? 可重用/不可重用?……)
題目6:combination sum -- cannot use same element
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高度類似。
題目7:palindrome partition
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 }
可以看到類似下圖的結果:

小結
本文敘述了如何通過一些基本的排列組合問題練習回溯法。用回溯法解決一個問題時的大概思路是先畫出某個輸入例子的決策樹,在樹中,考慮如下幾點:
- 每個節點有哪些選項,是否需要避免選項間的重復
- 如何從節點回溯到父節點
- 哪些節點屬於答案要求的范圍(葉節點/任意節點;滿足哪些要求的葉節點等)
然后,根據回溯法的普遍實現進行代碼實現。
附錄:問題1-7的決策樹
總結在此,讀者可做對比。


參考資料:
A general approach to backtracking questions in Java
