第23天,回溯part02,回溯两个题型组合,切割(ง •_•)ง💪
目录
39.组合总和
40.组合总和II
131.分割回文串
总结
39.组合总和
文档讲解:代码随想录组合总和
视频讲解:手撕组合总和
题目:
学习:
本题是在一个数组中抽取数加起来的和为target,与昨天的k个数的题目不同,昨天的题目中数的个数k限制了递归的层数,本题则是target限制了递归的层数。
同时要注意本题允许同一个数字无限制重复取用,因此每一层循环的范围要包含该节点,将本题的回溯逻辑转化为一张树形图为:
代码:确定回溯三部曲
//时间复杂度O(n*2^n)
//空间复杂度O(target)
class Solution {
public:
vector<vector<int>> result; //答案数组
vector<int> path; //保存路径
//确定返回值和参数,本题有保存路径和答案数组因此不需要返回值,参数需要原数组,目标值target,一个集合范围下标startindex,一个求和sum
void backtracking(vector<int>& candidates, int target, int startindex, int sum) {
//确定终止条件
if(sum > target) return;
if(sum == target) {
result.push_back(path);
return;
}
//确定单层递归逻辑
for(int i = startindex; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, i, sum);
//回溯
sum -= candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return result;
}
剪枝优化:本题显然能够针对sum进行优化,但优化前还需要注意要先对数组candidates进行排序,便于进行剪枝。
class Solution {
public:
vector<vector<int>> result; //答案数组
vector<int> path; //保存路径
//确定返回值和参数,本题有保存路径和答案数组因此不需要返回值,参数需要原数组,目标值target,一个集合范围下标startindex,一个求和sum
void backtracking(vector<int>& candidates, int target, int startindex, int sum) {
//确定终止条件
if(sum == target) {
result.push_back(path);
return;
}
//确定单层递归逻辑
//剪枝处理,缩小for循环范围
for(int i = startindex; i < candidates.size() && sum + candidates[i] <= target; i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, i, sum);
//回溯
sum -= candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
//进行排序,进行剪枝
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
40.组合总和II
文档讲解:代码随想录组合总和II
视频讲解:手撕组合总和II
题目:
学习:
本题实际上和我们之前所学的三数之和,四数之和有些相似,那两题能够通过有限的循环找到答案。本题没有确定数的个数,因此本题不能直接通过有限个循环求解,需要采用回溯的方法。
本题的难点在于遍历过程中我们还需要进行去重处理,但这可以参考我们在三数之和里面进行的去重。关键在于当前遍历的数和前一个数相等时,当前的遍历就可以跳过,因为从当前往后遍历的所有可能的情况,前一个数都已经遍历过了,为了避免重复就可以跳过当前的循环。(要注意去重之前还需要对数组进行排序!)
上述的去重方式可以理解为树层去重,同一层相同的数可以进行去重,而同一个树枝上的组合里的元素不需要去重,因为一个组合是允许有相同的树的。
本题的剪枝和上一题相同,本题采用对target作减法的方式找到答案组合,因此当target小于0时就可以进行返回了,缩减for循环范围:
代码:确定回溯三部曲
//时间复杂度O(n*2^n)
//空间复杂度O(n)
class Solution {
public:
//本题和三数之和十分相似,但三数之和规定了三个数,本题没有规定数的个数限制,无法确定循环层数,因此需要使用回溯
vector<vector<int>> result; //答案数组
vector<int> path; //保存路径
//确定返回值和参数,本题直接在答案数组中进行操作,因此不需要返回值,参数需要给的数组,目标值,和一个指示范围的值
//这里我们采用使用target作减法的方式,来进行和的求解
void backtracking(vector<int>& candidates, int target, int startindex) {
//确定终止条件
if(target == 0) {
result.push_back(path);
}
//确定单层递归逻辑
//对范围进行剪枝
for(int i = startindex; i < candidates.size() && target - candidates[i] >= 0; i++){
//对结果去重,如果后面遍历的数有和前面的相同,则跳过它,因为前面相同的数已经把后面的结果遍历完了
if(i > startindex && candidates[i] == candidates[i - 1]) continue;
path.push_back(candidates[i]);
target -= candidates[i];
backtracking(candidates, target, i + 1);
path.pop_back();
target += candidates[i];
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
//排序便于进行去重
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0);
return result;
}
};
131.分割回文串
文档讲解:代码随想录分割回文串
视频讲解:手撕分割回文串
题目:
学习:
本题是回溯算法中切割的第一道题,题干虽然很短,但实际难度很大。我们可以将问题分为几个步骤:1.如何切割字符串;2.如何遍历不同的切割方式;3.如何判断字符串是否回文。
针对以上三个问题,前两个实际上可以看作是一个组合的问题,即如何分开不同的数和如何不重复的遍历所有的情况。而第三个问题则是在循环过程中需要进行判断的,因此本题的回溯逻辑用树形结构可以写为:
可以看出切割的回溯搜索的过程实际上和组合问题的回溯搜索过程是差不多的。本题重点需要关注的是如何确定切割线以及如何截取子串。
- 如何确定切割线:由图中我们可以看出,实际上每一层的切割线都是由startindex确定的,例如循环的第一层startindex=0,切割线排在最前面,因此每次都至少包含第一个字母a。而第二层对于最左边的节点来说startindex=1,切割线在第二个字母,前面的字符串已经确定了,之后的每次切割都包含第二个字母(由于ab不是回文所以不进行循环)。
- 如何截取子串:确定了切割线的位置,子串的终点实际就是我们遍历过程中的i,i的变化决定了子串的长度,例如第一层来说,i从0到3,切割出来的子串就是a,aa,aab。子串就可以表示为字符串s中[startindex, i]区间包含的元素。
最后判断回文串,可以通过一个函数进行, 使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。最后的代码如下:
代码:确定回溯三部曲
//时间复杂度O(n*n^2)
//空间复杂度O(n^2)
class Solution {
public:
vector<vector<string>> result; //返回数组
vector<string> path; //保存路径
//确定返回值和参数,本题同样不需要返回值,参数中除了字符串s以外,还需要一个起始下标startindex
//这里十分要注意startindex就是切割线
void backtracking(string s, int startindex) {
//确定终止条件,当切割线等于字符串长度,意味着最后也切割完成了
if(startindex == s.size()) {
result.push_back(path);
return;
}
//确定单层递归逻辑
for(int i = startindex; i < s.size(); i++) {
//以startindex为切割线一直到i为字符串长度
//如果是回文串的话,假如path当中
if(Palindrome(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 Palindrome(string s, int start, int end) {
for(int i = start, j = end; i < j; i++, j--) {
if(s[i] != s[j]) return false;
}
return true;
}
vector<vector<string>> partition(string s) {
backtracking(s, 0);
return result;
}
};
总结:本题的难点主要有:切割问题可以抽象为组合问题、如何模拟那些切割线、切割问题中递归如何终止、在递归循环中如何截取子串、如何判断回文。理解这几点本题也就能够解出来了。
总结
回溯算法是一个暴力搜索的方法,因此我们重点要理解每一道题暴力搜索的逻辑过程,才能够写出正确的回溯算法代码,多加练习💪。