1. 程式人生 > 實用技巧 >回溯DFS排列組合問題刷題總結

回溯DFS排列組合問題刷題總結

目錄

深度優先搜尋刷題總結

46. 全排列

給定一個 沒有重複 數字的序列,返回其所有可能的全排列。

輸入: [1,2,3]
輸出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

public List<List<Integer>> findSubsequences(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
	List<Integer> path = new ArrayList<>();
    boolean[] visited = new boolean[nums.length];
    dfs(res,path,nums,visited);
    return res;
}

void dfs(List<List<Integer>> res,List<Integer> path,int[] nums, boolean[] visited){
    //終止條件
    if(path.size() == nums.length) {/*終止條件成立*/
        /* 可以選擇進一步判斷這條路徑上的結果是否滿足題意 */
        res.add(new ArrayList<>(path));
        return;
    }
    //選取候選者【卻決於題目 幾個分叉】
    for(int i = 0; i < arr.length; i++){
        int curr = arr[i];
        if(!visited[i]){ //如果沒有走過
            visited[i] = true; // 表示走過了
            path.add(curr); //加入path
            dfs(res,path,nums,visited); //遞迴到下一層
            path.remove(path.size()-1); //還原現場
            visited[i] = false; 
        }
    }
}

我們可以看看回溯的這個過程:

 遞迴之前 => [1] 遞迴之前 => [1, 2] 遞迴之前 => [1, 2, 3]
 遞迴之後 => [1, 2] 遞迴之後 => [1] 
 遞迴之前 => [1, 3] 遞迴之前 => [1, 3, 2]
 遞迴之後 => [1, 3] 遞迴之後 => [1] 遞迴之後 => []
 遞迴之前 => [2] 遞迴之前 => [2, 1] 遞迴之前 => [2, 1, 3]
 遞迴之後 => [2, 1] 遞迴之後 => [2]
 遞迴之前 => [2, 3] 遞迴之前 => [2, 3, 1]
 遞迴之後 => [2, 3] 遞迴之後 => [2] 遞迴之後 => []
 遞迴之前 => [3] 遞迴之前 => [3, 1] 遞迴之前 => [3, 1, 2]
 遞迴之後 => [3, 1] 遞迴之後 => [3]
 遞迴之前 => [3, 2] 遞迴之前 => [3, 2, 1]
 遞迴之後 => [3, 2] 遞迴之後 => [3] 遞迴之後 => []

圖片來源:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/

終止條件:

終止條件滿足,該層遞迴就必須要返回,隨便舉幾個例子:

  • 假設n代表每次遍歷陣列的下標位置,每層遞迴都需要n+1,那麼終止條件就是n == nums.length,因為代表剛好越過陣列的最後一個元素,此時就需要返回。
  • 假設需要搜尋到葉子節點的路徑,那麼當root.left == null && root.right == null時,就需要返回。

另外,有些時候,完整的一條路徑並不能滿足題意,就需要進行篩選。

  • 假設我們需要從陣列m中每次選擇n個數,且和為s,一條路徑的終止條件時res.length == n,但此時的和不一定為s,對吧,這時就需要跳過這條路徑,不管他。

候選者:

就是總共有幾種選擇?如全排列,每一層遞迴都會減少一次選擇數的機會,因為上一層選過的數,我們可以使用一個visited[]陣列來標記。

路徑:

有時候路徑的表示是字串,有時候路徑的表示是一個數組,需要注意的一點是:還原現場,這也是回溯的體現。

假設是一個ArrayList,每一次遞迴,將當前的候選人加入列表,由於是變數地址值傳遞,我們需要注意在一層遞迴結束之後,保證回退到之前的狀態:【同樣的在陣列中加入,使用StringBuilder拼接】都需要同樣的操作。

!【重要】:我的理解是,只要保證每次遞迴之後的結果不要影響上一層遞迴就可以了,要不要回溯取決於在進入下一層遞迴的時候,是否使用的同一份:

  • 如果是,則回溯。
  • 如果是建立了一份新的,則不需要回溯。

當然,後者將會在每次遞迴的時候都建立一份,空間消耗比較大。

visited[i] = true; // 表示走過了
path.add(curr); //加入path
dfs(res,path,nums,visited); //遞迴到下一層
path.remove(path.size()-1); //還原現場
visited[i] = false; 

當然,我們必須注意到一個問題,就是最後一步的res.add(new ArrayList<>(path));如果改成res.add(path);,輸出結果將會是:[[],[],[],[],[],[]]

原因在於:path指向的列表在DFS的過程中只有一份,DFS之後,回到了根節點,成為了空列表,因此我們需要做一次拷貝。

剪枝:

47. 全排列 II

給定一個可包含重複數字的序列,返回所有不重複的全排列。

示例:

輸入: [1,1,2]
輸出:[[1,1,2],[1,2,1],[2,1,1]]

在全排列的基礎上,我們需要加入剪枝條件,具體的解釋可以看一下liweiwei老師的視訊講解:https://leetcode-cn.com/problems/permutations-ii/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liwe-2/

我們需要明確,在這個DFS的過程中,哪裡出現了重複?在這個圖中,已經十分明顯地看到:

  • 這次搜尋的起點和上次的起點相同 , visited[i - 1] == visited[i]。
  • 且上一次的數已經被撤銷,visited[i-1] ==false。

為保證i-1不越界,加上i>0的條件:

if (i > 0 && nums[i] == nums[i - 1] && !visited[i - 1]) {
    continue;
}
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> path = new ArrayList<>();
    boolean[] visited;
    public List<List<Integer>> permuteUnique(int[] nums) {
        visited = new boolean[nums.length];
        Arrays.sort(nums); //保證陣列的有序性
        dfs(nums);
        return res;
    }
    
    void dfs(int[]nums){
        if(path.size() == nums.length) {
            res.add(new ArrayList<>(path));
            return;
        }
        for(int i = 0; i < nums.length ; i++){
            if( visited[i]) continue;
            if( i > 0 && nums[i] == nums[i - 1]  && !visited[i - 1]) continue; //此處剪枝
            visited[i] = true;
            path.add(nums[i]);
            dfs(nums);
            visited[i] = false;
            path.remove(path.size()-1);
        }
    }

候選人的起點選擇:

39. 組合總和

給定一個無重複元素的陣列 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。

candidates 中的數字可以無限制重複被選取。

說明:

  • 所有數字(包括 target)都是正整數。
  • 解集不能包含重複的組合。

按照樹形圖的思路,我們每次選取一個數,t相應減去這個數,一旦t<=0,表示終止條件,且t == 0的時候,加入結果,但是這樣做會有一個問題,我們可以看到有四個符合條件的結果:[[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]]

因為第一次選了223之後,當選到23之後,理應不能再回過頭選2了,選了就重複了對吧。

我們又如何保證不重複呢?其實思路很好理解,首先將陣列排序,保證陣列的升序排序,每一次搜尋的時候設定下一輪搜尋的起點就可以了。

    public List<List<Integer>> combinationSum(int[] arr, int t) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        Arrays.sort(arr);//排序是剪枝的前提
        dfs(res,path,0,arr,t);
        return res;
    }
    
    void dfs(List<List<Integer>> res,List<Integer> path,int s,int[] arr, int t){
        if(t <= 0){
            if(t == 0){
                res.add(new ArrayList<>(path));
            }
            return;
        }
        for(int i = s; i < arr.length; i++){
            if(arr[i] > t){ //由於陣列已經有序,當前這個數應該小於等於剩餘數t
                break;
            }
            path.add(arr[i]);
            dfs(res,path,i,arr,t-arr[i]); //因為
            path.remove(path.size()-1);
        }  
    }

40. 組合總和 II

給定一個數組 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。

candidates 中的每個數字在每個組合中只能使用一次

說明:

  • 所有數字(包括目標數)都是正整數。
  • 解集不能包含重複的組合。

兩道題目的不同點在於:

  • 每個數字可以重複使用
  • 每個數字只能使用一次。

我們的思路大致是差不多的,先對陣列進行排序,然後每次讓下一層起點座標是下一位即可,dfs(res,path,i+1,arr,t-arr[i]); ,但這樣同樣也會出現重複的問題。

參考https://leetcode-cn.com/problems/combination-sum-ii/solution/hui-su-suan-fa-jian-zhi-python-dai-ma-java-dai-m-3/ 評論區Allen同學的題解:

避免重複的本質做法就是如何保證遞迴樹的同一個層級不出現相同的元素

按照之前的思想,我們在陣列有序的前提下,一旦arr[i - 1] == arr[i],就跳過該元素就可以,但是如果是這樣,那麼兩個相同元素在上下不同層的情況也會被忽略掉,比如這樣肯定是不行的。

我們需要額外的條件,如何不同層的元素被過濾呢?

我們可以設每一層的狀態是s,下一層的狀態是i,那麼每遞迴一層i = i +1 ,那麼只要 i > s,就表示不在同一層了,也就是下面程式碼的由來:if(i > s && arr[i] == arr[i - 1]) continue;

    public List<List<Integer>> combinationSum2(int[] arr, int t) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        Arrays.sort(arr);//排序是剪枝的前提
        dfs(res,path,0,arr,t);
        return res;
    }
	// s 可以看成層數, i可以看成這一層從第幾個開始
    void dfs(List<List<Integer>> res,List<Integer> path,int s,int[] arr, int t){
        if(t <= 0){
            if(t == 0){
                res.add(new ArrayList<>(path));
            }
            return;
        }
        for(int i = s; i < arr.length; i++){ 
            
            if(arr[i] > t){ //由於陣列已經有序,當前這個數應該小於等於剩餘數t
                break;
            }
            if(i > s && arr[i] == arr[i - 1]) continue;
            path.add(arr[i]);
            dfs(res,path,i+1,arr,t-arr[i]); 
            path.remove(path.size()-1);
        }  
    }

78. 子集

給定一組不含重複元素的整數陣列 nums,返回該陣列所有可能的子集(冪集)。

說明:解集不能包含重複的子集。

輸入: nums = [1,2,3]
輸出:
[[3],[1],[2],[1,2,3],[1,3],[2,3],[1,2],[]]

本題不包含重複元素,故不需要考慮同一層元素重複的問題。

    List<Integer> chain = new ArrayList<>();
    List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> subsets(int[] nums) {

        Arrays.sort(nums);
        dfs(nums,0);
        return res;
    }

	//s可以看成層數,i可以看成從哪個位置開始
    void dfs(int[] nums ,int s){
        res.add(new ArrayList<>(chain));
        for(int i = s; i < nums.length ; i++){
        chain.add(nums[i]);
            dfs(nums,i+1);
            chain.remove(chain.size()-1); 
        }
    }

返回的結果是:[[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]

90. 子集 II

給定一個可能包含重複元素的整數陣列 nums,返回該陣列所有可能的子集(冪集)。

說明:解集不能包含重複的子集。

示例:

輸入: [1,2,2]
輸出:
[[2],[1],[1,2,2],[2,2],[1,2],[]]

這題和上一題的區別在於,同一層的元素可能會重複,和上面40題的思路相同,我們需要排除同一層的元素相同的情況,也就是跳過:if(i > s && nums[i] == nums[i - 1]) continue;,當然這要建立在陣列排序的前提下。