數組排列組合問題——BACKTRACKING

BACKTRACKING

backtracking(回溯法)是一類遞歸算法,一般用於解決某類問題:要求找出答案空間中符合某種特定要求的答案,好比eight queens puzzle(將國際象棋的八個皇后排布在8x8的棋盤中,使她們不能互相威脅)。回溯法會增量性地找尋答案,每次只構建答案的一部分,在構建的過程當中若是意識到答案不符合要求,會馬上將這一部分答案及它的全部子答案拋棄,以提升效率。html

回溯法的核心模型是一個決策樹,每一個節點的子節點表明該節點的選項。從根節點出發,做出某種選擇達到節點A,隨後會面臨節點A的選項,重複這個過程直到達到葉節點。若是途中發現某節點B的狀態已經不符合要求,那麼棄掉以B爲根節點的子決策樹。node

到達葉節點時,判斷其是否符合問題要求,而後根據狀況做相應處理(如將符合要求的葉節點加入一個list)。葉節點沒有子節點,所以回溯到上一個訪問過的節點,以嘗試其餘選擇。當咱們最終回溯到根節點,且已經窮盡了根節點的全部選擇時,算法結束。算法

在上圖的決策樹中,用good和bad分別表明符合和不符合要求的葉節點。回溯法的遍歷過程是這樣的:數組

  1. 從Root開始,有A和B兩個選項。選擇A。
  2. 從A開始,有C和D兩個選項。選擇C。
  3. C不符合要求,回溯到A。
  4. A處的剩餘選項爲D。選擇D。
  5. D不符合要求,回溯到A。
  6. A的選項已窮盡,回溯到Root。
  7. Root處的剩餘選項爲B。選擇B。
  8. 從B開始,有E和F兩個選項。選擇E。
  9. E符合要求,將其加入某個list,回溯到B。
  10. B出的剩餘選項爲F。選擇F。
  11. F不符合要求。回溯到B。
  12. B的選項已窮盡,回溯到Root。
  13. Root的選項已窮盡。結束。

本文給出leetcode上數組排列組合問題的回溯法解答。這些問題大多爲「返回某數組/字符串的全部排列/組合」類型,沒有提出明確的要求來篩選葉節點。看起來,彷佛簡單地使用回溯法遍歷決策樹便可,可是在實現中會遇到一些棘手的小問題,好比每個選項如何定義,如何記錄某一個節點上已經選擇過的選項等。學習回溯法時,能夠先從這些問題開始熟悉回溯法的套路,熟練後再去解決更復雜的問題。數據結構

問題集

  1. subsets: 給出一個不含重複元素的數組,返回它的全部子集;
  2. subsets w/ duplicates: 給出一個含有重複元素的數組,返回它的全部子集,不許重複;
  3. permutations給出一個不含重複元素的數組,返回它的全部排列;
  4. permutations w/ duplicates:給出一個含有重複元素的數組,返回它的全部排列,不許重複;
  5. combination sum: 給出一個整數數組及整數target,返回和爲target的全部組合,每一個元素可使用無窮屢次
  6. combination sum -- cannot use same element twice: 給出一個整數數組及整數target,返回和爲target的全部組合,每一個元素只能使用一次
  7. palindrome partition: 給出一個String,返回它的全部分割,使分割後的每個元素都是迴文。

決策樹

決策樹的設計是回溯法的關鍵。對於排列組合問題,這一步的本質是將intuitional的想法映射到解題空間,變化爲決策樹每個節點的選項。app

注意,決策樹並非一個具體存在的數據結構。在回溯法中,決策樹表明方法遞歸調用的方式和順序,每個節點的選項實際上編寫在代碼邏輯中,好比用一個for循環以某種邏輯遍歷數組,並在每次的iteration中遞歸調用方法,這個過程至關於對某一個選項做出了選擇。而退出到上一層方法則至關於回溯到決策樹的上一個節點。函數

subset問題(問題一、2)

以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問題(問題三、4)

permutation問題實際上更爲簡單。對於數組[1,2,3],想象三個有順序的盒子,每一個盒子能夠放一個不一樣的數字。順着回溯法的增量找尋答案的思路,能夠這麼設計:樹的第零層(根節點)三個盒子全爲空,第一層填入盒子1,第二層在盒子1填入某數的基礎上填入盒子2,第三層填入盒子3。

在這個決策樹中,全部葉節點爲想要找尋的結果。

與問題2類似,permutations w/ duplicates也須要在permutation問題的基礎上加入判斷條件,防止計入重複的排列。

combination sum問題(問題五、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     }
  1. 進入下面這個private的subsets方法至關於進入了一個節點。此時(在第8行)temp中的值表明該節點對應的子集。(對應決策樹圖)
  2. 因爲每一個節點都是答案,每次進入節點時先將temp加入result(第8行)。
  3. 每一個節點的選項爲在該節點表明的子集基礎上要增量增長的元素。前文中已經敘述到,爲了不子集重複,假設節點表明的子集爲[1],則子節點只能增長1以後的元素。此處的start變量是用來標記可選元素的左邊界。
  4. 回溯時,只需將子節點增量增長的元素去掉便可。

題目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     }
  1. 爲了避免引入重複子集,在回溯前先給數組排序,這樣相同的元素會被排在一塊兒,增量增長元素時跳過相同的元素便可。
  2. 如上題,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     }
  1. 只有葉節點爲答案,所以只有temp長度達到數組長度時將其加入。(9-10行)
  2. 判斷選項的合法性:用boolean數組used記錄已經加入的元素。(13行)
  3. 回溯方式:將加入選項時的行爲(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,而不能插入第4、第五個……這樣作是合理的。

這個小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     }
  1. 每次調用時更新target,target爲負時直接返回。
  2. 選項的排布方式與問題1相似,因爲能夠無限次重用某一元素,因此16行未把i加1。
  3. 由上面幾題看到,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

backtracking by David Matuszek

backtracking--wikipedia

Backtracking, Memoization & Dynamic Programming!

相關文章
相關標籤/搜索