代码随想录第二十三天 | Leecode 39. 组合总和、40.组合总和II、131. 分割回文串

Leecode 39. 组合总和

题目描述

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

  • 示例 1:

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
23 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7
仅有这两种组合。

  • 示例 2:

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

  • 示例 3:

输入: candidates = [2], target = 1
输出: []

第一次错误尝试的思路

本题的一上来的思路并不算难,同时记录当前数组与当前数组的和作为当前状态,在递归函数中使用for循环来遍历所有下一个状态,并在递归后进行恢复回溯。主要难点就在于要确保不会重复。首先对于去重我想到的是使用set集合容器,将结果数组存放入集合中,就可以删去重复项。但此时又会遇到,当存入的vector中数相同但顺序不同时也算集合中的不同元素。因此为了解决这个问题我又在满足条件时的判断中加了一条对每一次符合条件的vector进行一次快排,得到代码如下:

class Solution {
public:
    void combHelper(vector<int>& candidates, set<vector<int>>& vecSet, vector<int>& curVec, int& curSum, int target){
        if(curSum == target){
            sort(curVec.begin(), curVec.end()); // 使用sort对vector排序,确保存入的vector不会有重复
            vecSet.insert(curVec); // 用set来存放结果,避免重复
            return;
        }
        if(curSum > target) return; // 如果当前和大于目标值,则直接返回

        for(int i = 0; i < candidates.size(); i++){
            curVec.push_back(candidates[i]); // 存入下一状态的值
            curSum += candidates[i]; // 更新下一状态的和
            combHelper(candidates, vecSet, curVec, curSum, target); // 递归下一状态
            curSum -= candidates[i];  // 回溯,恢复数组和
            curVec.pop_back(); // 回溯,恢复数组
        }
    }

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        set<vector<int>> vecSet; // 使用set来存放结果,避免重复
        vector<int> curVec; // 初始化初始数组,初始数组和
        int curSum = 0;

        combHelper(candidates, vecSet, curVec, curSum, target); // 递归调用查找所有符合条件的值

        vector<vector<int>> result(vecSet.begin(), vecSet.end()); // 将set转换为vec结果
        return result; // 返回
    }
};

上面这段代码乍一看感觉没什么问题,但是实际上运行时会有一些测试用例无法通过,例如:

输入:candidates = [2,3,5],target = 8
输出:[[2,2,2,2],[2,2,3],[2,3,3],[2,5],[3,5]]
预期结果:[[2,2,2,2],[2,3,3],[3,5]]

观察到其中会输出一个[2,2,3][2,5]的结果,这让我非常困惑,因为我的代码中明确了只有当curSum == target时才能进入存放结果的分支条件中,而此时这两个数组的求和显然应该是7而不是目标给出的8。这个问题让我百思不得其解,检查了很久都没想通。(如果有读者看到这里,也可以尝试一下阅读上面代码找一下问题所在)

为了找出这个问题,我甚至在Visual Studio中逐行deBug,最终终于发现了问题所在。

原来问题在于其中的sort。当我在if分支中使用sort对当前数组进行排序的时候,即:

  if(curSum == target){
      sort(curVec.begin(), curVec.end()); // 此时对curVec进行排序,会导致curVec的顺序改变,不再按照我存入时的顺序排列
      vecSet.insert(curVec); 
      return;
  }

而在使用了sort将顺序打乱变为和我存入时的顺序不同后,再我的for循环部分代码中:

      for(int i = 0; i < candidates.size(); i++){
          curVec.push_back(candidates[i]); // 存入下一状态的值
          curSum += candidates[i]; // 更新下一状态的和
          combHelper(candidates, vecSet, curVec, curSum, target); // 这一步的递归中,可能会因为sort而改变数组的顺序
          curSum -= candidates[i];  // 此时回溯减去的值和上一次加入的值相等
          curVec.pop_back(); // 但此时pop出vec的值,和上面存入的值可能因为sort而导致不是同一个数
      }

终于找出了问题所在,为此我们总结经验:

  • 在使用回溯递归的过程中,不要对传入的包含了上一步状态的变量进行修改。(例如此时传入的curVec其实包含了回溯退回的路径这个信息,如果对其进行sort,会导致回退到错误的状态)。

同时总结本题的思路,上面我们使用sort其实是为了去重而引入的,但是引入之后反而导致产生了错误。为此我们需要寻找其他的去重方法。思考昨天所做的回溯题目中,我们是通过设定startPoint来限定每一次for循环开始的值,从而规避了重复的情况,接下来我们尝试使用同样的方法来解决本题。

正确的回溯解法

上一节中展示了一种错误的去重方式,这里考虑给每次传入递归函数中的参数新增一个int类型的start变量,用于表示每次搜索下一状态时的起始搜索状态,从而避免重复。这样我们可以写出代码如下:

class Solution {
public:
    void combHelper(vector<int>& candidates, int target, int start){
        if(curSum == target){ // 终止条件
            result.push_back(curVec);
            return;
        }
        if(curSum > target) return; 

        for(int i = start; i < candidates.size(); i++){ 
            curVec.push_back(candidates[i]); // 存储当前状态
            curSum += candidates[i];
            combHelper(candidates, target, i); // 根据for循环中i的取值从不同的点开始递归
            curSum -= candidates[i];  // 回溯
            curVec.pop_back();
        }
    }

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        combHelper(candidates, target,0); 
        return result; 
    }
private:
    vector<vector<int>> result; // 在类中用全局变量来存储结果数组,以及表示当前状态的curVec和curSum
    vector<int> curVec;
    int curSum = 0;
};

上面的回溯可以看做是对一个多叉树的深度优先搜索,先搜索了全部i=0时的结果,随后随着i增加,再去搜索其他分支(这里可以保证搜索分支序号i具有一个单调不减的性质)。同时每一次的start都是从i开始,表示了可以和上一个节点相同,而如果要与上一个节点不同,即每个节点不能重复的话,每次调用传入的start就该变为i+1。

Leecode 40.组合总和II

题目描述

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。

  • 示例 1:

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

[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
  • 示例 2:

输入: candidates = [2,5,2,1,2], target = 5,
输出:

[
[1,2,2],
[5]
]

解题思路

首先需要充分理解本题,特别是本题和上一题以及昨天所做的216. 组合总和 III的区别。区别主要就在于,本题给定的向量中的数是有重复的,同时其中每一个数只能使用一次。同时还需要注意,本题输出的结果需要和此前做过的组合一样,最终结果都是不重复的。在这里我们可以举一个例子:

当输入 candidates = [1, 1, 2], target = 3
由于输入的candidates中有重复元素,而在检查到由第一个1组成的[1,2]后发现和为3进行存入,随后又遇到第二个1组成[1,2]会再次进行存入,此时就相当于在结果中存放了两个一模一样的[1,2]。这会导致最终结果会产生重复值。

为了解决上面提到的重复问题,即为了避免在输出的vec中同一个位置取到candidates中不同的数但却是相等的值,我们可以将原本的vector先进行一次排序。这样就可以使得所有相等的值都相邻,随后在此基础上再继续考虑去重。

在原本的candidates有序之后,如果要去重,当然还是可以考虑使用set容器(尽管在上一题中尝试使用set得到了错误的结果)。为此我们可以写出如下代码:

class Solution {
public:
    set<vector<int>> result;  // 使用set来存放进行去重
    vector<int> curVec;
    int curSum = 0;

    void combHelper(vector<int>& candidates, int target, int start){
        if(curSum == target){ 
            result.insert(curVec);
            return;
        }
        if(curSum > target) return;

        for(int i = start; i < candidates.size() && curSum + candidates[i] <= target; i++){
            curVec.push_back(candidates[i]);
            curSum += candidates[i];
            combHelper(candidates, target, i+1);
            curSum -= candidates[i];
            curVec.pop_back();
        }
    }

    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        combHelper(candidates, target, 0);
        vector<vector<int>> result1(result.begin(), result.end());
        return result1;
    }
};

上面代码确实没啥问题,测试算例也基本上都能跑过,但是在遇到测试算例:

输入:

candidates = [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
target = 30

会发生超时的情况,原因是当输入全为 1 的数组且目标值较大(如 30)时,你的代码会遍历所有可能的组合(如 C(100,30) 种),并通过 set 去重。尽管最终结果唯一,但遍历所有可能路径的时间复杂度达到指数级,导致超时。

因此为了避免不必要重复带来的时间复杂度,我们应该尝试使用其他的方法来去重。特别是需要避免同一层循环中出现相同值的情况。为此我们可以写出下面代码:

class Solution {
public:
    vector<vector<int>> result;
    vector<int> curVec;
    int curSum = 0;

    void combHelper(vector<int>& candidates, int target, int start) {
        if (curSum == target) {
            result.push_back(curVec);
            return;
        }
        if (curSum > target) return;

        for (int i = start; i < candidates.size(); i++) {
            if (i > start && candidates[i] == candidates[i - 1]) continue;  // 跳过同层级重复元素,从而进行去重操作,使用continue跳过
            if (curSum + candidates[i] > target) break; // 剪枝,如果当前值求和已经大于目标和,则没必要再试探后续的结果,直接break

            curVec.push_back(candidates[i]);
            curSum += candidates[i];
            combHelper(candidates, target, i+1);
            curSum -= candidates[i];
            curVec.pop_back();
        }
    }

    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end()); 
        combHelper(candidates, target, 0);
        return result;
    }
};

此时的回溯代码就可以正常通过测试了。

Leecode 131.分割回文串

题目描述

给你一个字符串 s,请你将 s 分割成一些 子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

  • 示例 1:

输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]

  • 示例 2:

输入:s = "a"
输出:[["a"]]

解题思路

这道题做起来感觉有点难度,首先一上手就不知道如何分割字符串,专门去查了一下字符串自带的函数,本题中主要用到:

  • substr(i,j),表示将字符串中从序号ij的左闭右闭区间组成一个新的字符串

再按照回溯的方法写出下面代码:

class Solution {
public:
    vector<vector<string>> result;
    vector<string> curVec;

    bool isPalindrome(string curStr){
        int n = curStr.size();
        for(int i = 0; i < n/2 ; i++){
            if(curStr[i] != curStr[n-1-i]) return false;
        }
        return true;
    }

    void partHelper(const string& s, int start){
        if(s.size() <= start){ // 如果start已经超出s中所有的数
            result.push_back(curVec); // 将当前向量存放
            return;
        }
        for(int i = start; i < s.size(); i++){ // 用[start,i]左闭右闭区间来表示当前字符串,遍历i取到之后所有长度
            string newStr = s.substr(start, i - start + 1); // 建立当前字符串
            if(isPalindrome(newStr)){ // 如果当前字符串是回文串
                curVec.push_back(newStr); // 存入curVec中
                partHelper(s, i+1); // 递归
                curVec.pop_back(); // 回溯
            }
        }
    }

    vector<vector<string>> partition(string s) {
        partHelper(s, 0);
        return result;
    }
};

上面代码中难点在于,使用start变量来表示字符串中的子串的起始位置,另外是每次递归回溯要先进行一个if判断,判断其是否为回文串。而对于之前几题组合的回溯代码中,并没有其他的判断而直接进行递归回溯。

今日总结

进一步学习了回溯算法,回溯中递归函数传入的start变量非常常见,通常在组合中可以用于控制去重、字符串中用于表示子字符串的起始值。
此外在回溯的for循环中,可以通过加上if判断语句来去重、剪枝(相应地在if内加上break或者continue),要根据具体的问题来讨论分析,灵活应对。

今天刷到85题了,再接再厉!

posted on 2025-04-18 12:24  JQ_Luke  阅读(406)  评论(0)    收藏  举报