1 回溯问题
回溯算法的本质就是穷举,回溯是为了更好地进行穷举,并且在必要的时候进行剪枝操作。
回溯算法适用于排列、组合、棋盘、分割、子集等排列组合问题。
并且回溯算法都可以看成一个树遍历问题,但由于事实上往往没有树这一数据结构进行辅助,需要注意回溯算法的终止条件。
理论上,我们是用for循环对同层节点进行push进路径,采用递归进行DFS。
其标准模板为
void backTrack(参数)
{
if(终止条件 且 不满足付标准)
return;
if(终止条件 且 满足标准)
{
本次路径 加入 结果集合;
return;
}
for(循环)
{
本层元素加入路径;
backtrack(下一层元素);
元素移出路径(回溯);
}
}
2 组合
LeetCode:组合
对于n取k的组合问题,首先关注终止条件,即组合元素达到了k,即可加入结果集,之后回溯并取下一个元素即可。
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backTrack(int n,int k,int startIndex)
{
if(path.size()==k)
{
result.push_back(path);
return;
}
for(int i=startIndex;i<=n;i++)
{
path.push_back(i);
backTrack(n,k,i+1);
path.pop_back();
}
}
vector<vector<int>> combine(int n, int k) {
backTrack(n,k,1);
return result;
}
};
另外,我们还可以注意到,是存在剪枝的可能性的。如果取了元素i组成了路径path后,如果剩余的元素全部加上,长度依然达不到k,那么可以提前进行剪枝处理。
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backTrack(int n,int k,int startIndex)
{
if(path.size()==k)
{
result.push_back(path);
return;
}
//此处i<=n-(k-path.size())+1进行了剪枝
for(int i=startIndex;i<=n-(k-path.size())+1;i++)
{
path.push_back(i);
backTrack(n,k,i+1);
path.pop_back();
}
}
vector<vector<int>> combine(int n, int k) {
backTrack(n,k,1);
return result;
}
};
3 组合总和III
LeetCode:组合总和III
相比上一题,只多出了一个和为target的要求,将这一要求转化为剩下数所需要的和进行递归即可。
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backTrack(int n, int k, int startIndex)
{
if(path.size()==k)
{
if(n==0)
{
result.push_back(path);
}
return;
}
for(int i=startIndex;i<=9-(k-path.size())+1 && n>0;++i)
{
path.push_back(i);
backTrack(n-i,k,i+1);
path.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backTrack(n,k,1);
return result;
}
};
4 电话号码的字母组合
LeetCode:电话号码的字母组合
本质上依然没有过多的区别,唯一需要注意的是此时需要取出新的集合进行新的元素选取。
class Solution {
public:
vector<string> result;
string path="";
const string map[8]={
"abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"
};
string getMap(char number)
{
int index=number-'2';
return map[index];
}
void backTrack(string& digits,int index)
{
int length=digits.size();
if(path.size()==length)
{
result.push_back(path);
return;
}
string letters=getMap(digits[index]);
for(int i=0;i<letters.size() && index<length;i++)
{
path+=letters[i];
backTrack(digits,index+1);
//回溯
path.pop_back();
}
}
vector<string> letterCombinations(string digits) {
if(digits.size()==0)return result;
backTrack(digits,0);
return result;
}
};
5 组合总和
LeetCode:组合总和
这道题最值得注意的是,阐明了一个重要的方法:排序+剪枝。
在数组有序之后,可以有效地判断之后的元素是否超出标准,从而轻松地剪枝减少重复无用的计算量,毕竟事实上快排的复杂度并不高。
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backTrack(vector<int>& candidates,int startIndex,int target)
{
if(target==0)
{
result.push_back(path);
}
for(int i=startIndex;i<candidates.size() && candidates[i]<=target;i++)
{
path.push_back(candidates[i]);
backTrack(candidates,i,target-candidates[i]);
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
//此处剪枝需要排序支持
sort(candidates.begin(),candidates.end());
backTrack(candidates,0,target);
return result;
}
};
6 组合总和II
LeetCode:组合总和II
这道题首次出现有重复元素集合中的去重要求,因此我们必须要在回溯的过程中进行去重。
从思路上讲,我们是允许使用了前面元素的情况下,重复使用后面相同元素的。
但不允许不使用了前面元素的情况下,重复使用后面相同元素。
这意味着我们允许某一路径在不同层次下使用相同值的元素,但不允许在同一层使用相同值的元素。
以排序后的集合进行讨论,如果[1A,1B,2,3,5,…],如果1B与之后的元素可以满足条件,意味着在回溯过程中1A配合类似的元素组成了结果,这时1B就没有独立价值,理应被去重。
在排序之后,去重思路大致有三种:
1 回溯后跳过所有一样的元素,直到出现不同元素为止。
2 利用记录列表used,记录目前路径下哪些元素已经被使用,在used[i-1]=false时,意味着这一层次i-1元素已经完成了回溯,如果i元素与i-1一致,就应该被去重。
3 顺承2的思路,利用startIndex,重复元素只有在startIndex内允许与i-1相同(这意味着这是在i-1已被选择的情况下进行的,即不同层次,否则会被上一层循环直接跳过),其余相同元素只有在首次出现才能被允许,其余都应该被去重。
思路1:回溯后跳过
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backTrack(vector<int>& candidates,int startIndex,int target)
{
if(target<0)
return;
if(target==0)
{
result.push_back(path);
return;
}
for(int i=startIndex;i<candidates.size() && target>0;i++)
{
path.push_back(candidates[i]);
backTrack(candidates,i+1,target-candidates[i]);
path.pop_back();
//去重应该在回溯之后进行,因为排序后前面已完成的结果,必然包括了后面重复元素的所有可能
//跳过所有同一层次下的重复,不同层次间的重复意味着不同的元素是可以允许的
while(i<(candidates.size()-1)&&candidates[i]==candidates[i+1])
{
++i;
}
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(),candidates.end());
backTrack(candidates,0,target);
return result;
}
};
思路2:记录选择情况
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backTrack(vector<int>& candidates,int startIndex,int target,vector<bool>& used)
{
if(target<0)
return;
if(target==0)
{
result.push_back(path);
return;
}
for(int i=startIndex;i<candidates.size() && target>0;i++)
{
//used[i-1]=false,代表这一次循环中candidates[i-1]没有被使用
//也就意味着与candidates[i]一致的candidates[i-1]已经完成了回溯
//那么candidates[i]自然无法再candidates[i-1]不参与的情况下,实现独一无二的价值,应该被去重
if(i>0 && used[i-1]==false &&candidates[i-1]==candidates[i])
continue;
path.push_back(candidates[i]);
used[i]=true;
backTrack(candidates,i+1,target-candidates[i],used);
path.pop_back();
used[i]=false;
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(),candidates.end());
vector<bool> used(candidates.size(),false);
backTrack(candidates,0,target,used);
return result;
}
};
思路3:利用startIndex判断层次
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backTrack(vector<int>& candidates,int startIndex,int target)
{
if(target<0)
return;
if(target==0)
{
result.push_back(path);
return;
}
for(int i=startIndex;i<candidates.size() && target>0;i++)
{
//在这一层循环中,candidates[i]再不是作为整个序列第一个出现的情况下
//还有之前元素数值一致,意味着没有独立价值,理应被去重
//包括[1,1,2,2,....]中的第二个2也应该被去重
if(i>startIndex && candidates[i-1]==candidates[i])
continue;
path.push_back(candidates[i]);
backTrack(candidates,i+1,target-candidates[i]);
path.pop_back();
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(),candidates.end());
backTrack(candidates,0,target);
return result;
}
};
7 总结
终于开始探索未知的领域——回溯算法了,感觉还是很快进入了状态,是因为和树有异曲同工之妙吗?明后天争取把回溯算法全部过了。
今天上了英语课,虽然全部是Listening和Speaking,还每周Dictation的课程对于我这种fw而言是一种很新很痛苦的折磨,但如果能借此机会提升自己也是好事。
不知道自己今晚还不能赶出GAN的博客,不行的话就摆了!
——2023.2.23