代码随想录算法训练营 Day20 | 回溯算法 part02

39. 组合总和

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

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

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

cpp 复制代码
class Solution {
public:
    vector<int> path;
    vector<vector<int>> ans;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            ans.push_back(path);
            return;
        }
        // 横向遍历
        for (int i = startIndex; i < candidates.size(); i++) {
            // 剪枝优化:因为已经排序,如果当前值加 sum 已超标,
            // 后面的值更大,肯定也超标,所以可以直接 break (这里写 return 也可以,因为这是循环内的最后一层逻辑)
            // 注意:排序是使用 break 的前提!
            if (sum + candidates[i] > target) return; 
            path.push_back(candidates[i]);
            // 关键:传入 i,表示可以重复选取当前元素
            backtracking(candidates, target, sum + candidates[i], i);
            path.pop_back();
        }
    }
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end()); // 预处理:排序
        backtracking(candidates, target, 0, 0);
        return ans;
    }
};

总结

1. 排序的意义
  • 排序后 :数组变为升序(例如 [2, 3, 6, 7])。
  • 逻辑推导 :如果 sum + candidates[i] 已经大于 target,因为 candidates[i] < candidates[i+1] ...,那么 sum + candidates[i+1]肯定也大于 target
  • 结论:我们可以直接终止当前层循环,而不是仅仅跳过当前元素。
2. returnbreak 的细微差别

for 循环中:

  • 标准写法 :通常建议写 break。这表示"结束当前的 for 循环,回到上一层递归"。
  • 效果 :在这个特定的代码结构下,returnbreak 的效果是一样的(因为 return 后面的代码本来也不会执行)。但为了语义清晰,建议养成写 break的习惯,明确表示"因剪枝而终止循环"。
3. 复杂度
  • 无排序:最坏情况下需要遍历所有分支,且剪枝不彻底。
  • 有排序:剪枝非常"狠",一旦超标立刻切断整条分支。虽然排序本身需要 O(Nlog⁡N)O(NlogN),但在回溯过程中节省的时间往往远超排序的开销。

40. 组合总和 II

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

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

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

cpp 复制代码
class Solution {
public:
    vector<int> path;
    vector<vector<int>> ans;
    bool used[101] = {false}; // 标记数组,记录 candidates[i] 是否在当前路径中被使用
    // 回溯函数
    // candidates: 候选数组
    // target: 目标和
    // sum: 当前和
    // startIndex: 搜索起点
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            ans.push_back(path);
            return;
        }
        for (int i = startIndex; i < candidates.size(); i++) {
            // 核心去重逻辑:树层去重
            // 如果当前元素和前一个相同,且前一个元素没被使用过(说明是回溯弹出的状态)
            // 则跳过当前元素,避免在同一树层产生重复组合
            if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) continue;
            // 剪枝优化:排序后,如果当前和超标,后续更大元素也超标,直接终止
            if (sum + candidates[i] > target) return;
            used[i] = true;             // 标记为已使用
            path.push_back(candidates[i]);
            // 关键区别:传入 i+1,因为每个数字在每个组合中只能使用一次
            backtracking(candidates, target, sum + candidates[i], i + 1);
            path.pop_back();            // 回溯
            used[i] = false;            // 撤销标记
        }
    }
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end()); // 排序是去重的前提
        backtracking(candidates, target, 0, 0);
        return ans;
    }
};

总结

1. 为什么需要 used 数组?

因为数组中有重复元素(例如 [1, 1, 2]),简单的 startIndex 控制只能防止选取之前的索引,无法区分是"同一树层"的重复还是"同一树枝"的重复。

2. 去重条件

代码中的 if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) continue; 是理解本题的关键:

  • candidates[i] == candidates[i - 1]:说明两个元素值相同。

  • !used[i - 1]:说明前一个相同的元素 已经被回溯撤销了(即 used 变回了 false)。这意味着我们现在处于 同一树层 的遍历中。

    • 如果在同一树层遇到相同的值,后面的分支必然是前面分支的子集,会造成重复结果,所以要 跳过 (continue)。
    • 这叫 "树层去重"。
  • 如果是 used[i - 1] == true 呢?

    • 说明前一个相同元素正在当前路径中被使用(还没撤销),我们是顺着前一个元素向下递归的。
    • 这属于 "树枝" 上的重复,这是允许的(例如选取第二个1),不需要去重。
3. 对比上一题 (39. 组合总和)
  • 参数传递:上一题传 i(可重复选取),本题传 i + 1(不可重复选取)。
  • 去重逻辑:上一题无重复元素无需去重,本题必须通过排序 + used 数组去重。

131. 分割回文串

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

cpp 复制代码
class Solution {
public:
    vector<string> path;            // 当前分割路径
    vector<vector<string>> ans;     // 结果集
    // 辅助函数:判断字符串是否为回文串
    bool isHui(string s) {
        int l = 0, r = s.size() - 1;
        while (l <= r) {
            if (s[l++] != s[r--]) return false; // 不相等则不是回文
        }
        return true;
    }
    // 回溯函数
    // s: 原字符串
    // index: 当前分割的起始位置
    void backtracking(string s, int index) {
        // 1. 终止条件:起始位置已经超过了字符串长度,说明分割完毕
        if (index >= s.size()) {
            ans.push_back(path);
            return;
        }
        // 2. 单层搜索逻辑
        for (int i = index; i < s.size(); i++) {
            // 获取子串:[index, i] 闭区间
            string a = s.substr(index, i - index + 1);
            // 只有当子串是回文串时,才加入路径并继续递归
            if (isHui(a)) {
                path.push_back(a);          // 处理节点
                backtracking(s, i + 1);     // 递归:从 i+1 开始继续分割
                path.pop_back();            // 回溯:撤销处理结果
            }
            // 如果不是回文串,直接跳过(相当于剪枝)
        }
    }
    vector<vector<string>> partition(string s) {
        backtracking(s, 0);
        return ans;
    }
};

总结

1. 模型转化
  • 组合问题:从集合中选 k 个数。
  • 分割问题:将字符串切成 k 段。
    • index 代表当前这一刀切在哪里。
    • for 循环中的 i 代表尝试在 indexi 之间切一段出来。
    • path 存储的是切出来的每一段字符串。
2. 切割与递归的逻辑
  • 横向遍历:for 循环从 index 开始,尝试切出长度为 1、2、3... 的子串。
  • 纵向递归:一旦切出一段回文串 a,就将其加入 path,然后从 i + 1 开始下一层递归(切割剩下的字符串)。
3. 剪枝优化

隐式的剪枝:

cpp 复制代码
if (isHui(a)) {
    // 做操作
}

如果切出来的子串不是回文,就不会进入 if 块,直接进行下一次循环。这避免了进入无效的递归分支,条件剪枝。

4. 复杂度分析
  • 时间复杂度:O(N * 2^N)。
    • 最坏情况下(如全相同字符 "aaaa"),每个位置切或不切有 2^N 种方案。
    • 判断回文和生成子串需要 O(N) 时间。
  • 空间复杂度:O(N)。
    • 递归深度最大为 N。
相关推荐
YXXY3132 小时前
前缀和算法
算法
客卿1232 小时前
滑动窗口--模板
java·算法
_日拱一卒2 小时前
LeetCode:滑动窗口的最大值
数据结构·算法·leetcode
codeの诱惑3 小时前
推荐算法(一):数学基础回顾——勾股定理与欧氏距离
算法·机器学习·推荐算法
落樱弥城3 小时前
Vulkan Compute 详解
算法·ai·图形学
Book思议-3 小时前
【数据结构】字符串模式匹配:暴力算法与 KMP 算法实现与解析
数据结构·算法·kmp算法·bf算法
客卿1233 小时前
动态规划--模板--完全背包
算法·动态规划
L-影3 小时前
下篇:一棵树能长成多少种样子?——AI中决策树的类型与作用,以及它凭什么活了六十年还没过气
人工智能·算法·决策树·ai
mifengxing3 小时前
力扣HOT100——(1)两数之和
java·数据结构·算法·leetcode·hot100