39. 组合总和
题目链接:39. 组合总和
原文链接:39. 组合总和
视频链接:39. 组合总和
本题和 77.组合 ,216.组合总和III 的区别是:本题没有数量要求,可以无限重复,但是有总和的限制。
树形结构:
回溯三部曲:
① 确定递归函数参数及返回值:
这里依然是定义两个全局变量,二维数组 res 存放结果集,数组 path 存放符合条件的结果。
首先是题目中给出的参数,集合 candidates, 和目标值 target。
sum 用于统计单一结果 path 里的总和。
本题还需要 startIndex 来控制 for 循环的起始位置。
对于组合问题,什么时候需要 startIndex 呢?
如果是一个集合来求组合的话,就需要 startIndex,例如:77. 组合,216. 组合总和III。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用 startIndex,例如:17. 电话号码的字母组合。
vector<int> path;
vector<vector<int>> res;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex)
② 递归终止条件:
从叶子节点可以清晰看到,终止只有两种情况,sum 大于 target 和 sum 等于 target。
sum 等于 target 的时候,需要收集结果,代码如下:
if (sum > target) {
return;
}
if (sum == target) {
res.push_back(path);
return;
}
③ 单层搜索的逻辑
单层 for 循环依然是从 startIndex 开始,搜索 candidates 集合。
注意本题和 77. 组合、216. 组合总和III 的一个区别是:本题元素为可重复选取的,体现在递归中。
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i); // 关键点: i,表示可以重复读取当前的数
sum -= candidates[i]; // 回溯
path.pop_back(); // 回溯
}
完整代码:
class Solution {
private:
vector<int> path;
vector<vector<int>> res;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum > target) {
return;
}
if (sum == target) {
res.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);
path.pop_back();
sum -= candidates[i];
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return res;
}
};
剪枝:
对于 sum 已经大于 target 的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断 sum > target 的话就返回。
其实如果已经知道下一层的 sum 会大于 target,就没有必要进入下一层递归了。
那么可以在 for 循环的搜索范围上做文章了。
对 总集合排序 之后,如果下一层的 sum(就是本层的 sum + candidates[i])已经大于 target,就可以结束本轮 for 循环的遍历。
在求和问题中,排序之后加剪枝是常见的套路!
class Solution {
private:
vector<int> path;
vector<vector<int>> res;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) {
res.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
path.push_back(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i);
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return res;
}
};
40. 组合总和II
题目链接:40. 组合总和II
原文链接:40. 组合总和II
视频链接:40. 组合总和II
这道题目和 39. 组合总和 如下区别:
(1) 本题 candidates 中的每个数字在每个组合中只能使用一次。
(2) 本题数组 candidates 的元素是有重复的,而 39.组合总和 是无重复元素的数组 candidates。
本题的难点在于区别 2 中:集合(数组 candidates)有重复元素,但还不能有重复的组合。
所谓去重,其实就是使用过的元素不能重复选取。
组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。
元素在同一个组合内是可以重复的,但两个组合不能相同。
所以要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
回溯三部曲:
① 递归函数参数:
与 39. 组合总和 套路相同,此时还需要加一个 bool 型数组 used,用来记录同一树枝上的元素是否使用过。这个集合去重的重任就是 used 来完成的。
vector<int> path;
vector<vector<int>> res;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
② 递归终止条件
与 39. 组合总和 相同,终止条件为 sum > target 和 sum == target。
if (sum > target) { // 这个条件其实可以省略
return;
}
if (sum == target) {
res.push_back(path);
return;
}
③ 单层搜索的逻辑
这里与 39. 组合总和 最大的不同就是要去重了。
前面我们提到:要去重的是 “同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果 candidates[i] == candidates[i - 1] 并且 used[i - 1] == false,就说明:前一个树枝,使用了 candidates[i - 1],也就是说同一树层使用过 candidates[i - 1]。
此时 for 循环里就应该做 continue 的操作。
注意 sum + candidates[i] <= target 为剪枝操作(注意要对数组进行排序)。
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();
}
完整代码:
class Solution {
private:
vector<int> path;
vector<vector<int>> res;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
if (sum > target) {
return;
}
if (sum == target) {
res.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
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);
used[i] = false;
path.pop_back();
sum -= candidates[i];
}
}
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(), false);
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0, used);
return res;
}
};
131. 分割回文串
题目链接:131. 分割回文串
原文链接:131. 分割回文串
视频链接:131. 分割回文串
切割问题类似组合问题。
例如对于字符串 abcdef:
组合问题:选取一个 a 之后,在 bcdef 中再去选取第二个,选取 b 之后在 cdef 中再选取第三个…。
切割问题:切割一个 a 之后,在 bcdef 中再去切割第二段,切割 b 之后在 cdef 中再切割第三段…。
树形结构:
① 确定回溯函数参数与返回值
数组 path 存放切割后回文的子串,二维数组 res 存放结果集。
本题递归函数参数还需要 startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
vector<string> path;
vector<vector<string>> res;
void backtracking (const string& s, int startIndex) {
② 递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
在处理组合问题的时候,递归参数需要传入 startIndex,表示下一轮递归遍历的起始位置,这个 startIndex 就是切割线。
所以终止条件代码如下:
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于 s 的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
res.push_back(path);
return;
}
}
③ 单层递归逻辑
在 for (int i = startIndex; i < s.size(); i++) 循环中,我们 定义了起始位置 startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在 vector<string> path 中,path 用来记录切割过的回文子串。
代码如下:
for (int i = startIndex; i < s.size(); i++) {
if (dp[startIndex][i]) {
path.push_back(s.substr(startIndex, i - startIndex + 1));
} else {
continue;
}
backtracking(s, i + 1);
path.pop_back();
}
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为 i + 1。
判断回文子串(动态规划):
(1) 确定 dp 数组及其下标的含义:
布尔类型的 dp[i][j] :表示区间范围 [i, j] (注意是左闭右闭)的子串是否是回文子串,如果是 dp[i][j] 为 true,否则为 false。
(2) 确定递推公式:
在确定递推公式时,就要分析如下几种情况。
整体上是两种,就是 s[i] 与 s[j] 相等,s[i] 与 s[j] 不相等这两种。
1️⃣ 当 s[i] 与 s[j] 不相等,dp[i][j] 一定是 false。
2️⃣ 当 s[i] 与 s[j] 相等时,这就复杂一些了,有如下三种情况:
❶ 下标 i 与 j 相同,同一个字符例如 a,当然是回文子串;
❷ 下标 i 与 j 相差为 1,例如 aa,也是回文子串;
❸ 下标 i 与 j 相差大于 1 的时候,例如 cabac,此时 s[i] 与 s[j] 已经相同了,我们看 i 到 j 区间是不是回文子串就看 aba 是不是回文就可以了,那么 aba 的区间就是 i + 1 与 j - 1 区间,这个区间是不是回文就看 dp[i + 1][j - 1] 是否为 true。
以上三种情况分析完了,那么递归公式如下:
if (s[i] == s[j]) {
if (j - i <= 1) {
dp[i][j] = true;
} else {
if (dp[i + 1][j - 1]) {
dp[i][j] = true;
}
}
}
(3) 初始化:
dp[i][j] 初始化为 false。
(4) 确定遍历顺序:
从下到上,从左到右。
(5) 举例推导 dp 数组:
void getDp(const string& s) {
dp.resize(s.size(), vector<bool>(s.size(), false));
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i; j < s.size(); j++) {
if (s[i] == s[j]) {
if (j - i <= 1) {
dp[i][j] = true;
} else {
if (dp[i + 1][j - 1]) {
dp[i][j] = true;
}
}
}
}
}
}
完整代码如下:
class Solution {
private:
vector<string> path;
vector<vector<string>> res;
vector<vector<bool>> dp;
void backtracking(const string& s, int startIndex) {
if (startIndex >= s.size()) {
res.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (dp[startIndex][i]) {
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else {
continue;
}
backtracking(s, i + 1);
path.pop_back();
}
}
void getDp(const string& s) {
dp.resize(s.size(), vector<bool>(s.size(), false));
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i; j < s.size(); j++) {
if (s[i] == s[j]) {
if (j - i <= 1) {
dp[i][j] = true;
} else {
if (dp[i + 1][j - 1] == true) {
dp[i][j] = true;
}
}
}
}
}
}
public:
vector<vector<string>> partition(string s) {
getDp(s);
backtracking(s, 0);
return res;
}
};