回溯part02

YueHuai發表於2024-08-22

今天繼續學習了回溯:

  1. 組合求和的進階
    元素可以重複使用:backtracking(candidates, target, sum, i); // 不用i+1了,表示可以重複讀取當前的數
    陣列去重:首先陣列排序,然後使用used
  2. 分割回文子串問題,抽象為組合問題,注意如何判斷是否是迴文子串

5. 39 組合總和(元素可重複使用)

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

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

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

示例 :

  • 輸入:candidates = [2,3,6,7], target = 7,
  • 所求解集為: [ [7], [2,2,3] ]

碰到組合的問題,回溯的一種,可以首先畫樹狀圖看看:

39.組合總和
  1. 和之前組合的題目相比,可以重複取,但是這個重複,指的是當前的數可以再次用,而不是每個for迴圈都是從0到n。

    若是for(int i=0;i<size;i++),輸出的錯誤結果是:[[2,2,3],[2,3,2],[3,2,2],[7]],但是結果應該是[[2,2,3],[7]]

  2. 需要startIndex來控制for迴圈的起始位置,對於組合問題,什麼時候需要startIndex呢?

    如果是一個集合來求組合的話,就需要startIndex,例如:77.組合,216.組合總和III。

    如果是多個集合取組合,各個集合之間相互不影響,那麼就不用startIndex,例如:17.電話號碼的字母組合。

  3. 畫圖之後,可以明顯的看出葉子節點需要有=和>兩個if,寫少了就無限遞迴了。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum > target) {
            return;
        }
        if (sum == target) {
            result.push_back(path);
            return;
        }

        for (int i = startIndex; i < candidates.size(); i++) {
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i); // 不用i+1了,表示可以重複讀取當前的數
            sum -= candidates[i];
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

剪枝:

對於sum已經大於target的情況,其實是依然進入了下一層遞迴,只是下一層遞迴結束判斷的時候,會判斷sum > target的話就返回。其實如果已經知道下一層的sum會大於target,就沒有必要進入下一層遞迴了。

那麼可以改for迴圈的搜尋範圍。對總集合排序之後,如果下一層的sum(就是本層的 sum + candidates[i])已經大於target,就可以結束本輪for迴圈的遍歷。如圖所示:

39.組合總和1
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
  • 時間複雜度:O(S),其中 S 為所有可行解的長度之和。從分析給出的搜尋樹我們可以看出時間複雜度取決於搜尋樹所有葉子節點的深度之和,即所有可行解的長度之和。在這題中,我們很難給出一個比較緊的上界,我們知道 O(n×2 ^n)是一個比較松的上界,即在這份程式碼中,n 個位置每次考慮選或者不選,如果符合條件,就加入答案的時間代價。但是實際執行的時候,因為不可能所有的解都滿足條件,遞迴的時候我們還會用 target−candidates[idx]≥0 進行剪枝,所以實際執行情況是遠遠小於這個上界的。
  • 空間複雜度:O(target)。除答案陣列外,空間複雜度取決於遞迴的棧深度,在最差情況下需要遞迴 O(target) 層。

6. 40 組合總和Ⅱ(陣列去重)

題目:給定一個候選人編號的集合 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。candidates 中的每個數字在每個組合中只能使用 一次

注意:解集不能包含重複的組合。

輸入: candidates = [10,1,2,7,6,1,5], target = 8,
輸出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

和上一題39 組合總和的區別在於陣列去重。39中candidates中的數字無重複,而且每個數字允許使用多次。所以假如結果是[1,2,2],只能是2這個元素自己被使用了2次。所以不會有陣列重複的問題。

但是此題,candidates中允許有重複數字,可能出現相同的陣列,所以需要考慮陣列去重。

  1. 陣列去重問題,首先將candidates排序,方便比較是否元素相同
  2. 去重問題可以使用used,也可以不用

a. 使用used去重

要去重的是同一樹層上的“使用過”,同一樹枝上的都是一個組合裡的元素,不用去重。

  1. 遞迴函式引數

與39 組合總和 套路相同,此題還需要加一個bool型陣列used,用來記錄同一樹枝上的元素是否使用過。

這個集合去重的重任就是used來完成的。

  1. 遞迴終止條件

與39 組合總和 相同,終止條件為 sum > targetsum == target

  1. 單層搜尋的邏輯

這裡與39.組合總和 最大的不同就是要去重了。前面提到,,要去重的是“同一樹層上的使用過”,如何判斷同一樹層上元素(相同的元素)是否使用過了呢?

在candidates[i] == candidates[i - 1]相同的情況下:

  • used[i - 1] == true,說明同一樹枝candidates[i - 1]使用過。說明是進入下一層遞迴,去下一個數,所以是樹枝上。
  • used[i - 1] == false,說明同一樹層candidates[i - 1]使用過。因為同一樹層,used[i - 1] == false 才能表示,當前取的 candidates[i] 是從 candidates[i - 1] 回溯而來的。

如圖所示:

img
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
        if (sum == target) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            // used[i - 1] == true,說明同一樹枝candidates[i - 1]使用過
            // used[i - 1] == false,說明同一樹層candidates[i - 1]使用過
            // 要對同一樹層使用過的元素進行跳過
            if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
                continue;
            }
            sum += candidates[i];
            path.push_back(candidates[i]);
            used[i] = true;
            backtracking(candidates, target, sum, i + 1, used); // 和39.組合總和的區別1,這裡是i+1,每個數字在每個組合中只能使用一次
            used[i] = false;
            sum -= candidates[i];
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        vector<bool> used(candidates.size(), false);
        path.clear();
        result.clear();
        // 首先把給candidates排序,讓其相同的元素都挨在一起。
        sort(candidates.begin(), candidates.end());
        backtracking(candidates, target, 0, 0, used);
        return result;
    }
};
  • 時間複雜度: O(n * 2^n)
  • 空間複雜度: O(n)

b. 使用startIndex去重

也可以不用used,用startIndex去重。關鍵就是這一句:if(i > start_index && candidates[i] == candidates[i - 1])continue;

例如1 1 2 5 6 7 10,在剛進入遞迴第一個數是1 有組合1 2 5 = 8 ,對應下標(0 2 3) 。以i=0的遞迴完 ,下標i=1 值為1不能再選了,要把它拋棄掉,要不然還會選到跟以下標i=0開始的組合。如1 2 5 對應下標(1 2 3)組合就重複了。題目要求不能有重複的組合,所以當前這層橫向這個位置不能有相同的數。

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            // 要對同一樹層使用過的元素進行跳過
            if (i > startIndex && candidates[i] == candidates[i - 1]) {
                continue;
            }
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i + 1); // 和39.組合總和的區別1,這裡是i+1,每個數字在每個組合中只能使用一次
            sum -= candidates[i];
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        path.clear();
        result.clear();
        // 首先把給candidates排序,讓其相同的元素都挨在一起。
        sort(candidates.begin(), candidates.end());
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

7. 分割回文串

題目:給定一個字串 s,將 s 分割成一些子串,使每個子串都是迴文串。返回 s 所有可能的分割方案。

示例: 輸入: "aab" 輸出: [ ["aa","b"], ["a","a","b"] ]


a. 切割問題如何抽象為組合問題

可以自己畫圖看看,會發現切割掉開頭的a後,需要處理剩下的字串。這種方式和組合問題大同小異,既然如此,畫樹狀圖看看:

131.分割回文串

遞迴用來縱向遍歷,for迴圈用來橫向遍歷,切割線(就是圖中的紅線)切割到字串的結尾位置,說明找到了一個切割方法。

此時可以發現,切割問題的回溯搜尋的過程和組合問題的回溯搜尋的過程差不多。

b. 回溯三部曲

  1. 遞迴函式引數

    全域性變數陣列path存放切割後迴文的子串,二維陣列result存放結果集。

    如何模擬那些切割線?其實就是指定下一個切割的起始位置,使用startIndex即可。

  2. 遞迴函式終止條件

    從樹形結構的圖中可以看出:切割線切到了字串最後面,說明找到了一種切割方法,此時就是本層遞迴的終止條件。

    那麼在程式碼裡什麼是切割線呢?startIndex,表示下一輪遞迴遍歷的起始位置,就是切割線。所以終止條件是if (startIndex >= s.size())

  3. 單層搜尋的邏輯

    • for迴圈中如何寫?取決於每個節點有多少孩子。可以發現需要從startIndex開始遍歷到字串的最後,所以for (int i = startIndex; i < s.size(); i++)
    • 進入了每個for迴圈之後,需要處理節點,其實就是判斷這個分割是不是構成了新的迴文子串,是的話放到path中,不是就跳出去讓i++。
    • 然後接著遞迴。
    • 接著回溯,將這個迴文子串從path中彈出去。

c. 判斷是否是迴文子串

可以使用雙指標法,一個指標從前向後,一個指標從後向前,如果前後指標所指向的元素是相等的,就是迴文字串了。

 bool isPalindrome(const string& s, int start, int end) {
     for (int i = start, j = end; i < j; i++, j--) {
         if (s[i] != s[j]) {
             return false;
         }
     }
     return true;
 }

也可使用動態規劃。見之後的筆記。

  1. 分割回文子串問題,可以抽象為回溯中的組合問題
  2. 迴文子串判斷方法

今日古詩

微雨夜行
白居易〔唐代〕

漠漠秋雲起,稍稍夜寒生。
但覺衣裳溼,無點亦無聲。

相關文章