算法学习记录~2023.X.XX~章节DayX~题目号.题目标题 & 题目号.题目标题
39. 组合总和
题目链接
思路
和216.组合总和III的区别是本题元素为可重复选取的,其他思路基本一致。
需要注意的点仍旧是for循环里的取值,必须为i而不是index,这样才能保证后续回溯中不会重复出现前面的元素,而只可能重复当前或之后的元素,最终生成不重复的结果。
代码
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking (vector<int>& candidates, int target, int sum, int index){
if (sum > target)
return ;
if (sum == target){
result.push_back(path);
return ;
}
for (int i = index; i < candidates.size(); i++){
path.push_back(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i); //注意这里是i而不是index,能防止再重复之前遍历过的数
path.pop_back();
sum -= candidates[i];
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return result;
}
};
代码2:剪枝
对于上面的版本,在sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
如果知道下一层的sum会大于target,那就没必要进入下一层递归了,因此可以在for循环的范围上进行修改。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。同时也可以省略 if (sum > target) 处的代码了
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
同时在主函数进入回溯函数前要先将原数据排序,这样才能在一个值求出来不符合要求后后面的所有元素都不再继续处理。
sort(candidates.begin(), candidates.end()); // 需要排序
backtracking(candidates, target, 0, 0);
return result;
其他均一致
总结
40.组合总和II
题目链接
思路
本题难点在于数组candidates中有重复元素,但解集中不能包含重复的组合。
如果把所有组合求出来再用set或map去重,很容易超时,因此需要在搜索过程中就去掉重复组合。元素不能重复选取,但是元素可有相同的,这样去重思路就很难想。
想象一下会出现重复的情况有哪些,其实只有两种
- 在同一数层上的重复
- 在同一树枝上的重复
需要被去重的是同一树层上使用过的,同一树枝上的因为肯定是不同元素,所以不用去重
**强调一下,树层去重的话,需要对数组排序!**这样数值相同的才能排在一起
代码1:利用bool型数组used来记录同一树枝上的元素是否使用过
去重就是靠used
对于单层搜索的逻辑,主要是如何判断同一树层上的元素(相同的元素)是否使用过。
如果candidates[i] == candidates[i - 1] 并且 used[i - 1] == false,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。
此时for循环里就应该做continue的操作。
为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上。
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;
}
};
代码2:直接用index来去重
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
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;
path.push_back(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i +1); //不可重复所以 i + 1
path.pop_back();
sum -= candidates[i];
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
if (candidates.size() == 0)
return result;
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
代码3:使用set去重
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking (vector<int>& candidates, int target, int sum, int startIndex){
if (sum == target) {
result.push_back(path);
return;
}
unordered_set<int> set;
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target ; i++){
if (set.find(candidates[i]) != set.end()) //跳过同一树层的重复元素
continue;
path.push_back(candidates[i]);
set.insert(candidates[i]); //记录元素
sum += candidates[i];
backtracking(candidates, target, sum, i +1); //不可重复所以 i + 1
path.pop_back();
sum -= candidates[i];
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
if (candidates.size() == 0)
return result;
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
总结
131.分割回文串
题目链接
思路
主要是两个问题:
- 如何切割
- 如何判断回文
对于第一个问题,其实切割问题和组合问题类似,同样可以抽象为树形结构。递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
对于每一个回溯函数的开始位置,从当前位置出发,进行for循环,直到s.size(),分别是本次切割的终止位置。接着对于切割出来的这个子串进行回文判断,如果符合要求则再从这个的结束位置+1继续回溯函数找后续符合要求的切割方式。如果已经不符合回文,则不需要从此处之后再找下一个切割,因为由于当前不符合要求则后面都可以不再考虑,此时则开始继续找下一个endIndex也就是本层的下个切割方式,继续上面的判断。
最终能找到以startIndex为开始节点的所有后续切割方式。
自然,终止条件为startIndex超过s.size(),但是由于一直是+1的递归,因此判断条件选择开始坐标等于s.size()即可,此时就可退出,不会走到大于
代码
class Solution {
public:
vector<string> path;
vector<vector<string>> result;
void backtracking (string s, int startIndex){
if (startIndex == s.size()){ //搜到了头说明找到了一组符合条件的分割方式
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++){
if (isPalindrome (s, startIndex, i)){ //子串回文则加入路径
//获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
}
else
continue; //继续找本层下一个可能的切割位置
//如果本层的切割符合要求的话,则从下一个坐标开始递归回溯
backtracking(s, i + 1);
path.pop_back(); //回溯本层新切割的子串
}
}
bool isPalindrome (string s, int startIndex, int endIndex){ //双指针判断是否回文
for (int i = startIndex, j = endIndex; i < j; i++, j--){
if (s[i] != s[j])
return false;
}
return true;
}
vector<vector<string>> partition(string s) {
backtracking(s, 0);
return result;
}
};
总结
难点主要有:
- 切割问题可以抽象为组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
93.复原IP地址
题目链接
思路
本题类似131.分割回文串,通过回溯把所有可能的切割方式都找到,同时判断该分割方式下的各部分是否符合题目要求。
采用直接在原字符串中添加 ‘.’ 的方式来分段和记录,这样回溯时也不会影响到原字符串。需要注意在这种情况下插入 ‘.’ 的下标以及递归后续时的下标。
关于是否符合ip地址要求也需要考虑清楚,是否为0开头,是否有特殊字符以及是否在0-255之间。
代码
class Solution {
public:
vector<string> result;
void backtracking(string s, int startIndex, int count){
if (count == 4){ //到第4段了,判断最后一段是否符合要求
if (isInvalid(s, startIndex, s.size() - 1)){
result.push_back(s);
}
return ;
}
for (int i = startIndex; i < s.size(); i++){
if (isInvalid(s, startIndex, i)){
count++; //计数+1
s.insert(s.begin() + i + 1, '.'); //符合要求的字符串后插入'.',接着查找后续的
backtracking(s, i + 2, count);
count--; //回溯
s.erase(s.begin() + i + 1); //回溯删掉'.'
}
else //发现不符合要求的直接退出本树枝循环开始下一层
return;
}
return;
}
bool isInvalid(string s, int start, int end){
if (start > end)
return false;
if (s[start] == '0' && start != end){ //开头为0的不合法
return false;
}
int sum = 0; //用于计算子串的数值大小
for (int i = start; i <= end; i++){
if (s[i] < '0' || s[i] > '9') //判断是否有0-9之外的无效字符
return false;
sum = (sum * 10) + (s[i] - '0');
if (sum > 255) //大于255的不合法
return false;
}
return true;
}
vector<string> restoreIpAddresses(string s) {
int count = 1; //记录到了第几个数
backtracking(s, 0, count);
return result;
}
};
总结
本题下次还需要仔细看一遍,关于回溯函数里终止条件和递归过程中的具体数值模拟可以脑子里再自己想一下。
第一遍时终止条件没有想太清楚,关于每一小段的ip地址的合法性验证也没有考虑全,再刷时需要靠自己注意一下。
78.子集
题目链接
思路
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合
代码
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking (vector<int>& nums, int startIndex){
result.push_back(path); //收集子集,要放在终止添加的上面,否则会漏掉自己
if (startIndex >= nums.size())
return;
for (int i = startIndex; i < nums.size(); i++){
path.push_back(nums[i]);
backtracking(nums, i + 1);
path.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
backtracking(nums, 0);
return result;
}
};
总结
一开始想成了分别设置不同的节点范围,以这个范围大小一次次遍历找到所有符合的子集,然后再更改这个节点范围知道最终等于整个数组大小,这样就很复杂。
按照上面的思路就很好想了,但是其实关于收集所有节点数据的代码很容易有遗漏,比如存数据应该放在终止条件前,以免记录不到本节点以及空集。实际上写起来还是比较容易有遗漏的,二刷时候先自己直接写写看。