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. return 与 break 的细微差别
在 for 循环中:
- 标准写法 :通常建议写
break。这表示"结束当前的for循环,回到上一层递归"。 - 效果 :在这个特定的代码结构下,
return和break的效果是一样的(因为return后面的代码本来也不会执行)。但为了语义清晰,建议养成写break的习惯,明确表示"因剪枝而终止循环"。
3. 复杂度
- 无排序:最坏情况下需要遍历所有分支,且剪枝不彻底。
- 有排序:剪枝非常"狠",一旦超标立刻切断整条分支。虽然排序本身需要 O(NlogN)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代表尝试在index到i之间切一段出来。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。