回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就 “回溯” 返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
(来源:https://leetcode-cn.com/tag/backtracking/)
参考内容:
https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-xiang-jie-by-labuladong-2/
目录
40. 组合总和 II (改变起始点,约束同层重复元素进行剪枝)
回溯算法模板
回溯算法的三个要素:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
(作者:labuladong链接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-xiang-jie-by-labuladong-2/)
回溯算法的框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
作者:labuladong
链接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-xiang-jie-by-labuladong-2/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
for 选择 in 选择列表:
# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入选择列表
作者:labuladong
链接:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-xiang-jie-by-labuladong-2/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
具体的细节内容请查看labuladong大佬的题解或者公众号
回溯算法典型题目
46. 全排列(回溯法模板)
https://leetcode-cn.com/problems/permutations/
本题可以说是回溯法的模板问题了,列举出所有的可能,我们会发现,整个排列的可能性成树装,看图:
首先在1,2,3中进行选择,然后在剩下的没有被选的内容中,进行选择
这个描述过程本身就是有剪枝的性质在,我们要选择路径中不存在的内容,路径中重复的内容,我们不选择。
跨层剪枝技巧一:
出现在路径中的元素,我们不选择,规避重复选择。
此技巧非常有针对性,原数组必须没有重复元素,非常方法直接失效,比如全排列II
if(find(track.begin(),track.end(),nums[i])==track.end())//原数组无重复元素,列举全部排列组合的剪枝方法
下面我们来看完整版的程序
class Solution {
public:
vector<vector<int>> res;
vector<vector<int>> permute(vector<int>& nums) {
vector<int> track;
backtrack(track,nums);
return res;
}
void backtrack(vector<int>& track,vector<int>& nums)
{
//结束条件——何时完成选择
if(track.size() == nums.size())//路径满足排列的要求
{
res.push_back(track);
return;
}
//回溯的核心,选择与撤回
for(int i = 0;i<nums.size();++i)
{
if(find(track.begin(),track.end(),nums[i])==track.end())//没有找到,那么就可以选择
{
track.push_back(nums[i]);
backtrack(track,nums);
track.pop_back();
}
}
}
};
从本题可以看出,回溯法的精髓,在于“选择”和“撤销选择”
但是有时候决定命运的不是方向,是细节,二分查找被称为玄学算法,就是边界收缩的细节千变万化,模板套路远不及题目灵活
回溯法也有异曲同工之处,那就是剪枝,如何剪枝,如何避免重复的答案,非常重要,下面我们就看看几道经典的剪枝题目:
39. 组合总和(改变起始点进行剪枝)
https://leetcode-cn.com/problems/combination-sum/
如果我们直接写,程序如下:
class Solution {
public:
vector<vector<int>> Res;
unordered_map<int,int>M;
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<int>track;
backtrack(track,candidates,target);
return Res;
}
void backtrack(vector<int>& track,vector<int>& candidates, int target)
{
if(target == 0)
{
Res.push_back(track);
return;
}
for(int i = 0;i<candidates.size();++i)
{
int newtarget = target-candidates[i];
if(newtarget>=0)
{
track.push_back(candidates[i]);
backtrack(track,candidates,newtarget);
track.pop_back();
}
}
}
};
显然,有很大重复排列的结果,我们在过程中应该如何剪枝呢?
这道题目要求,可以重复选择元素,那么我们使用不选择路径中已经有数值的办法就失效了
那么我们该如何减去重复的组合呢?先要看看重复的组合是怎么来的:
图中蓝色为可提供的选择,黑色为目前已经选择的数值
可以看到,在不加任何限制的情况下,每次递归都有同样的选择,2,3,6。
解决办法就是:规定起始点位置。每次只能选择大于或等于上次选择元素序号的内容,看代码更好理解
代码实现:
for(int i = begin;i<candidates.size();++i)//(1)
{
int newtarget = target-candidates[i];
if(newtarget>=0)
{
track.push_back(candidates[i]);
backtrack(track,candidates,newtarget,i);//(2)
track.pop_back();
}
}
本次选择从begin开始,那么下次选择也是从begin开始,如果begin为1(1代表元素索引),那么下次选择只能选择索引大于或等于1的内容,及能选择重复内容(符合题意),也能剪枝,我们来看看这么写之后,决策树有什么变化:
显而易见,我们成功完成了剪枝的工作,改变每次选取值的起点,既然要求可以重复选择,那么我们让下次的起点,从我们已经选择了的数字开始,而不是从头开始。
class Solution {
public:
vector<vector<int>> Res;
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<int>track;
backtrack(track,candidates,target,0);
return Res;
}
void backtrack(vector<int>& track,vector<int>& candidates, int target,int begin)
{
if(target == 0)
{
Res.push_back(track);
return;
}
for(int i = begin;i<candidates.size();++i)
{
int newtarget = target-candidates[i];
if(newtarget>=0)
{
track.push_back(candidates[i]);
backtrack(track,candidates,newtarget,i);
track.pop_back();
}
}
}
};
40. 组合总和 II (改变起始点,约束同层重复元素进行剪枝)
https://leetcode-cn.com/problems/combination-sum-ii/
本题相较上一题,提出了“每个数字在每个组合只能使用一次” 的要求,但是数组中有重复元素的。
那么策略也很简单,综合全排列和组合总和的剪枝方法,因为有数值中有重复元素,不能使用全排列的方法,此时要求每个数字只能使用一次,那么我们完全可以从每次选择的起点入手,本次选择了索引为x的内容,那么下次就从x+1开始好了,这样包装数组中的元素只用一次,此方法需要保证原数组是有序的。
下面是完整代码,我们需要先对原数组进行排序,然后每次都将可选择起始点后移
class Solution {
public:
vector<vector<int>> Res;
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<int> track;
sort(candidates.begin(),candidates.end());
backtrack(track,candidates,target,0);
return Res;
}
void backtrack(vector<int>& track,vector<int>& candidates, int target,int begin)
{
if(target == 0)
{
Res.push_back(track);
return;
}
vector<bool> used(candidates.size(),false);
for(int i = begin;i<candidates.size();++i)
{
if(used[candidates[i]]) continue;
int temp = target - candidates[i];
if(temp>=0)
{
used[candidates[i]] = true;
track.push_back(candidates[i]);
backtrack(track,candidates,temp,i+1);
track.pop_back();
}
}
}
};
78. 子集(改变起始点)
https://leetcode-cn.com/problems/subsets/
此题要求所有的子集,那么其实更好办,原始数组没有重复元素,显然子集中也不能有重复内容,依旧让起点索引点不断加一,每个点只能选择一次,即可完成此题。
class Solution {
public:
vector<vector<int>> Res;
vector<vector<int>> subsets(vector<int>& nums) {
vector<int> target;
Res.push_back(target);
backtrack(target,nums,0);
return Res;
}
void backtrack(vector<int>& target,vector<int>& nums,int begin)
{
if(target.size()>0&&target.size()<=nums.size())
{
Res.push_back(target);
}
for(int i = begin;i<nums.size();++i)
{
target.push_back(nums[i]);
backtrack(target,nums,i+1);
target.pop_back();
}
}
};
90. 子集 II(约束同层重复元素进行剪枝)
https://leetcode-cn.com/problems/subsets-ii/
增加难度,原数组中有重复元素,老方法不好用了,会产生重复,如下图:
因为要收录全部的子集,所以我们也放宽了收录条件,造成了以上重复的情况
重复情况很有规律,都是同层使用了前一次使用的元素,所以本题的核心是同层剪枝。
书写标志位,Used【i】,这个元素同层用过,那么就不能用了。因为只是牵扯到同层的问题,所以不需要将其作为参数,只要在同层其作用即可。同样的,本题要求原数组从小到大排列,否则方法是失效的。
在上一道题目的基础上,增减同层标志位进行剪枝
代码如下:
unordered_map<int,bool> used;
for(int i = begin;i<nums.size();++i)
{
if(used[nums[i]]) continue;
used[nums[i]] = true;
target.push_back(nums[i]);
backtrack(target,nums,i+1);
target.pop_back();
}
class Solution {
public:
vector<vector<int>> Res;
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<int> target;
sort(nums.begin(),nums.end());//排序
Res.push_back(target);
backtrack(target,nums,0);
return Res;
}
void backtrack(vector<int>& target,vector<int>& nums,int begin)
{
if(target.size()>0&&nums.size()>=target.size())
{
Res.push_back(target);
}
unordered_map<int,bool> used;
for(int i = begin;i<nums.size();++i)
{
if(used[nums[i]]) continue;
used[nums[i]] = true;
target.push_back(nums[i]);
backtrack(target,nums,i+1);
target.pop_back();
}
}
};
如果不排序,会有如下情况发生:
还是发生了重复,所以要排序,才能完全剪枝。
面试题38. 字符串的排列
https://leetcode-cn.com/problems/zi-fu-chuan-de-pai-lie-lcof/
典型的回溯问题,因为是字符串,而且有可能重复,所以需要同层和垂直层的剪枝。
具体代码如下:
class Solution {
public:
vector<string> Res;
vector<string> permutation(string s) {
//标准回溯
if(s.empty()) return Res;
string temp;
unordered_map<int,int>G_item;
backtrack(s,temp,G_item);
return Res;
}
void backtrack(string s,string temp,unordered_map<int,int>& G_item)
{
if(s.size() == temp.size()) {Res.push_back(temp);return;}
int size = s.size();
unordered_map<char,int>Item;
for(int i = 0;i<size;++i)
{
if(Item[s[i]]!=1&&G_item[i]!=1)//没有找到
{
Item[s[i]] = 1;
G_item[i] = 1;
string now = temp;
temp += s[i];
backtrack(s,temp,G_item);
temp = now;
G_item[i] = 0;
}
}
}
};
131. 分割回文串
https://leetcode-cn.com/problems/palindrome-partitioning/
本题需要注意的地方还是挺多的,首先,第一个STL技巧,判断回文:
bool Jadge(string& s)
{
//利用反向迭代器
return s == string(s.rbegin(),s.rend());
}
因为要求是子串,所以虽然算法架构上和其他题目一致,但是细节还是有很大区别的。
我们将循环i,从数组中的索引号,变成字符串中的长度,从长度为1的字符串开始,循环判断回文,再从长度为2的字符串开始,判读是不是回文,一次类推,知道长度恰好等于字符串完整的长度。
整个过程非常巧妙。
这是一道非常好的题目。
我们靠直觉都知道,我们应该分1个字符是回文的情况,两个字符分割的情况,以此类推
但是具体怎么实现呢?那就是截断字符串
现在我们从1个字符串是回文开始判断,第一位是回文,那么我们直接将第一位截断,让剩下的字符串继续递归
什么时候递归停止?因为我们截断了字符串,那么当字符串本身长度为0的时候,自然不会进入循环,自动停止
核心代码:
void bacltrack(vector<string>& track,string s)
{
if(s=="") Res.push_back(track);
for(int i = 1;i<=s.length();++i)
{
string temp = s.substr(0,i);
if(Jadge(temp))
{
track.push_back(temp);
bacltrack(track,s.substr(i,s.length()-i));
track.pop_back();
}
}
}
举例说明程序的运行过程:
当i=1的时候,不断递归,知道最后字符串为0,这是一组,橘黄色的线已经标出
当回溯的时候,到达bab的时候,bab也是一个回文,如此,目前vector还有a,c,刚好组成一组回文分割
这个过程实在是巧妙
核心代码:
if(s=="") Res.push_back(track);
for(int i = 1;i<=s.length();++i)
{
string temp = s.substr(0,i);
if(Jadge(temp))
{
track.push_back(temp);
bacltrack(track,s.substr(i,s.length()-i));
track.pop_back();
}
}
过程非常巧妙,及找全了所有的回文,也不会存在多余的情况。
class Solution {
public:
vector<vector<string>> Res;
vector<vector<string>> partition(string s) {
vector<string> track;
bacltrack(track,s);
return Res;
}
void bacltrack(vector<string>& track,string s)
{
if(s=="") Res.push_back(track);
for(int i = 1;i<=s.length();++i)
{
string temp = s.substr(0,i);
if(Jadge(temp))
{
track.push_back(temp);
bacltrack(track,s.substr(i,s.length()-i));
track.pop_back();
}
}
}
bool Jadge(string& s)
{
//利用反向迭代器
return s == string(s.rbegin(),s.rend());
}
};
下面来看看最复杂的剪枝题目
47. 全排列 II(同层及垂直层屏蔽相同内容进行剪枝)
https://leetcode-cn.com/problems/permutations-ii/
(图源:liweiwei1419 https://leetcode-cn.com/problems/permutations-ii/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liwe-2/)
重复元素如何避免:
同一层,重复元素不能使用 有:i>0 && !used[i-1] && nums[i] == nums[i-1];同层不能有人用你,上一层也不能有人用你
垂直层,重复元素不能使用 这个需要传参:used[i];表示下标为i的元素用过了,不要再使用了。
以上筛选条件,构成一组完美的剪枝条件
class Solution {
public:
vector<vector<int>>Res;
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<int> temp;
unordered_map<int,int>M;//垂直层剪枝(避免访问同一个元素)
if(nums.size() == 0) return Res;
backtrack(nums,temp,M);
return Res;
}
void backtrack(vector<int>& nums,vector<int>& temp,unordered_map<int,int>&M)
{
if(nums.size() == temp.size()) {Res.push_back(temp);return;}
unordered_map<int,int>Used;//同层剪枝(避免相同元素)
for(int i = 0;i<nums.size();++i)
{
if(Used[nums[i]]!=1&&M[i]!=1)
{
Used[nums[i]] = 1;//同层剪枝,用过标记为1,此处是用过的元素,为了同层剔除相同的元素
M[i] = 1;//垂直层剪枝,用过标记为1,此处是用过元素的索引,为了避免重复,但是值相等是可以重复使用的
temp.push_back(nums[i]);
backtrack(nums,temp,M);
temp.pop_back();
M[i] = 0;//垂直层剪枝,用过标记为1
}
}
}
};
二维平面上的回溯问题
二维回溯的思维模式和普通回溯法没有任何的区别,难就难在代码的实现,有时候让人摸不着头脑,不知该如何下手解决。
51. N皇后
https://leetcode-cn.com/problems/n-queens/
将此题简化为,放置N个物品,一个物品的横,竖,斜三个方向上,都不能放置其他物品
部分截图来源:https://leetcode-cn.com/problems/n-queens/solution/nhuang-hou-by-leetcode/
本题求解的是所有可能的解,这个要注意。
初探此题,简直是无法入手,这排列组合,谁知道,还不是要一步一步的试,这刚好就是回溯法的核心,如果第i个皇后没有地方放了,那么就悔棋一次,咱们试试其他地方,要是还不行,再次悔棋!
典型的回溯法思路,问题在于如何实现。
逻辑上我们会这么认为,从第一行开始,遍历每一列,然后放置皇后,标记出因为放置这个皇后而禁止放置任何皇后的区域,然后开始递归,从第二行开始。
第二行开始后,还是遍历每一列,排禁止放置的区域,在可以放置的区域进行放置,然后继续递归,第三行....
逻辑很好理解,那么应该如何实现呢?
算法参考:https://leetcode-cn.com/problems/n-queens/solution/hui-su-suan-fa-xiang-jie-by-labuladong/
首先初始化棋盘:
vector<string> board(n,string(n,'.'));
这是非常关键的一步,往后我们只需要放内容就可以了,不用担心其他内容
回溯的核心:
for(int col = 0;col<size;++col)
{
//排除不合法
if(Valid(board,row,col))
{
board[row][col] = 'Q';
backtrack(board,row+1);
board[row][col] = '.';
}
}
我们直接修改数组,非常方便,进行落子即可,这就是初始化棋盘的方便所在,本题的核心可以说就是初始化这个部分
在此处落子是否可行,我们需要判断:
bool Valid(vector<string>& board,int row,int col)
{
//检查列
int n = board.size();
for(int i = 0;i<n;++i)//固定列,检查所有行在此列上是否有Q元素
{
if(board[i][col] == 'Q') return false;
}
//检查左上方
for(int i = row-1,j = col-1;i>=0&&j>=0;--i,--j)
{
if(board[i][j] == 'Q') return false;
}
//检查右上方
for(int i = row-1,j = col+1;i>=0&&j<n;--i,++j)
{
if(board[i][j] == 'Q') return false;
}
return true;
}
检查上方和两侧斜角部分,有没有被其他旗子占用
class Solution {
public:
vector<vector<string>> Res;
vector<vector<string>> solveNQueens(int n) {
//初始化棋盘
vector<string> board(n,string(n,'.'));
backtrack(board,0);
return Res;
}
void backtrack(vector<string>& board,int row)
{
if(row == board.size()) {Res.push_back(board);return;}
int size = board[row].size();
for(int col = 0;col<size;++col)
{
//排除不合法
if(Valid(board,row,col))
{
board[row][col] = 'Q';
backtrack(board,row+1);
board[row][col] = '.';
}
}
}
bool Valid(vector<string>& board,int row,int col)
{
//检查列
int n = board.size();
for(int i = 0;i<n;++i)//固定列,检查所有行在此列上是否有Q元素
{
if(board[i][col] == 'Q') return false;
}
//检查左上方
for(int i = row-1,j = col-1;i>=0&&j>=0;--i,--j)
{
if(board[i][j] == 'Q') return false;
}
//检查右上方
for(int i = row-1,j = col+1;i>=0&&j<n;--i,++j)
{
if(board[i][j] == 'Q') return false;
}
return true;
}
};
非常巧妙的解法,既然我们已经想到了回溯法,那么我们只需要列出架构,之后的种种细节,我们在也有架构的基础上进行。
52. N皇后 II
https://leetcode-cn.com/problems/n-queens-ii/
如果我现在要求总共有几种解,你该怎么做呢?
class Solution {
public:
int Res;
int totalNQueens(int n) {
//初始化棋盘
vector<string> board(n,string(n,'.'));
backtrack(board,0);
return Res;
}
void backtrack(vector<string>& board,int row)
{
if(row == board.size()) {Res++;return;}
int size = board[row].size();
for(int col = 0;col<size;++col)
{
//排除不合法
if(Valid(board,row,col))
{
board[row][col] = 'Q';
backtrack(board,row+1);
board[row][col] = '.';
}
}
}
bool Valid(vector<string>& board,int row,int col)
{
//检查列
int n = board.size();
for(int i = 0;i<n;++i)//固定列,检查所有行在此列上是否有Q元素
{
if(board[i][col] == 'Q') return false;
}
//检查左上方
for(int i = row-1,j = col-1;i>=0&&j>=0;--i,--j)
{
if(board[i][j] == 'Q') return false;
}
//检查右上方
for(int i = row-1,j = col+1;i>=0&&j<n;--i,++j)
{
if(board[i][j] == 'Q') return false;
}
return true;
}
};
剪枝技巧总结
一般对数组有要求,必须是有序数组,这点需要保证
剪枝一般方法上述都有提到,下面做以总结,剪枝最好的办法就是找到为什么会重复,然后对症下药即可。
标志位
90. 子集 II是个非常好的例子,使用标志位,同层用过的内容,不会再次使用
unordered_map<int,bool> used;
for(int i = begin;i<nums.size();++i)
{
if(used[nums[i]]) continue;
used[nums[i]] = true;
target.push_back(nums[i]);
backtrack(target,nums,i+1);
target.pop_back();
}
规定起始点
当题目要求,可以重复选择同一个元素的时候,我们只用更新起始点就可以了,比如39. 组合总和,起始点需要大于等于上一次选取内容的索引即可。
而78. 子集又是另外一种剪枝的方式,因为不允许使用重复元素,需要不断更新起始点才能保持完成剪枝
跨层剪枝
47. 全排列 II可以说是剪枝的集大成,甚至可以说这道题核心就是剪枝而不是回溯。既然要跨层,那么就必须要传递标致位,让下一次递归操作时知道,什么值改选什么不该。
复杂回溯算法
判断括号是否合法的常用方法:
//以下为两组判断合法括号的方法
bool Jadge(string s)
{
stack<char>Temp;
for(int i = 0;i<s.size();++i)
{
if(s[i] == '(') Temp.push(s[i]);
if(s[i] == ')')
{
if(Temp.size()&&Temp.top() == '(') Temp.pop();
//注意细节,Temp.size()不为0才能完成后续操作
else return false;
}
}
return Temp.size() == 0?true:false;
}
bool JadgeNum(string s)
{
int count = 0;
for(int i = 0;i<s.size();++i)
{
if(s[i] == '(') count++;
else if(s[i] == ')') count--;
if(count<0) return false;
}
return count == 0?true:false;
}
22. 括号生成
https://leetcode-cn.com/problems/generate-parentheses/
本题没有明确给出组成原始的内容,但是隐含在题意内,就是(和),整个过程中,就这两个元素,不断的进行组合
我们先看一段程序,是检查字符串是不是合法括号的:
bool Jadeg(string s)//判断是不是括号
{
if(s.empty()) return false;
stack<char> Text;
for(auto item:s)
{
if(item == '(') Text.push('(');
if(item == ')')
{
if(Text.empty()||Text.top() != '(') return false;
Text.pop();
}
}
return Text.size()==0?true:false;
}
那么我们来看完成版本的程序:
class Solution {
public:
vector<string>Res;
int size = 0;
vector<string> generateParenthesis(int n) {
if(n == 0) return Res;
size = n;
backtracck("");//目前已经组成的字符串,左右括号的个数
return Res;
}
void backtracck(string target)
{
if(target.size() > 2*size) return;//大于最高尺寸后,直接返回停止递归
if( Jadeg(target)&&target.size() == 2*size ) Res.push_back(target);
//就两种情况,'('或者')',全部都给一遍即可
string temp = target;//保存原始内容
target+='(';backtracck(target);//左右都试一下
target = temp;//恢复原样
target+=')';backtracck(target);
target = temp;//恢复原样
}
bool Jadeg(string s)//判断是不是括号
{
if(s.empty()) return false;
stack<char> Text;
for(auto item:s)
{
if(item == '(') Text.push('(');
if(item == ')')
{
if(Text.empty()||Text.top() != '(') return false;
Text.pop();
}
}
return Text.size()==0?true:false;
}
};
但是本方法极限只能算到7
有没有什么办法,进行优化,我们能不能不使用之前的判断括号合理性的API,换一种更加合理和简单的方式?
以下两个方法都是通过左右括号的个数,来进行约束,完成合理组合的两种方法
改进方法一:
算法参考:https://leetcode-cn.com/problems/generate-parentheses/comments/6656
我们对左右括号进行计数,左括号个数为L,右括号个数为R,当二者相等且总长相等时,组成正确的括号表达式
同样的,当L或者R大于总括号数时,比如n =2,那么L和R极限大小就是2,当L和R大于这个数字时,直接break;
同样,当R大于L的时候,显然也是失效的,我们总是先添加左括号再添加右括号,L>=R是常态,当二者相等就是完成组合的时候
,所以当R大于L的时候,break;
以上条件必须全部具备,才能完全筛选出合理的组合
class Solution {
public:
vector<string>Res;
vector<string> generateParenthesis(int n) {
if(n == 0) return Res;
backtracck("",0,0,n);//目前已经组成的字符串,左右括号的个数
return Res;
}
void backtracck(string target,int L,int R,int size)
{
if(L > size || R > size ||R > L||target.size() > 2*size) return;
if(L == R&&target.size() == 2*size ) Res.push_back(target);
//就两种情况,'('或者')',全部都给一遍即可
string temp = target;//保存原始内容
target+='(';backtracck(target,L+1,R,size);//左右都试一下
target = temp;//恢复原样
target+=')';backtracck(target,L,R+1,size);
target = temp;//恢复原样
}
};
改进方法二:
算法参考:https://leetcode-cn.com/problems/generate-parentheses/comments/336762
上面的约束很多,很容易乱,我们也没有其他办法剪枝,有的,版本一我们使用的是从无到有构建,版本二我们对括号个数在初期就进行约束
对L,R在递归初期就进行约束,当L大于0的时候,进行递归,但是只有当R>L的时候,也就是目前已经有左括号进入了组合,现在再去部署右括号才是合理
class Solution {
public:
vector<string>Res;
vector<string> generateParenthesis(int n) {
if(n == 0) return Res;
backtracck("",n,n,n);//目前已经组成的字符串,左右括号的个数
return Res;
}
void backtracck(string target,int L,int R,int size)
{
// if(L > size || R > size ||target.size() > 2*size) return;
if(L == R&&R == 0&&target.size() == 2*size ) Res.push_back(target);
//就两种情况,'('或者')',全部都给一遍即可
string temp = target;//保存原始内容
if(L>0)//左括号剩余,那么拼接左括号
{
target+='(';backtracck(target,L-1,R,size);//左右都试一下
target = temp;//恢复原样
}
if(R>L)//右括号剩余多余左括号剩余,那么可以尝试进行右括号的拼接
{
target+=')';backtracck(target,L,R-1,size);
target = temp;//恢复原样
}
}
};
我们可以来看看,如果不进行R>L的约束,我们该如何?
这些都是多余的组合,这些组合都有一个问题,就是右括号出现在了左括号之前,所以一定要加以限制。
下面我们再看一道经典题目:
301. 删除无效的括号
https://leetcode-cn.com/problems/remove-invalid-parentheses/
思路参考:https://leetcode-cn.com/problems/remove-invalid-parentheses/solution/dfsjie-ti-by-hw_wt/
本题Hard,要求删除最小数量的无效括号,其实无效括号个个数已经是确定的,我们先找出非法括号,然后在这个字符串中尝试着删除括号,对删除完的内容进行判断,是否是有效的
首先我们先 统计非法括号的个数:
//计算需要删除的错误左右括号个数
for(auto item:s)
{
if(item == '(') left++;//记录全部的左括号
else if(item == ')')
{
if(left>0) left--;//当遇到匹配的右括号时,删除一个,表示这个括号不在非法括号的范围内
else right++;//一旦left 不大于0,但是此时出现了右括号,显然是非法括号
}
}
现在我们知道了要删除多少个左括号和右括号,这些都是非法的内容,我们从第一个字符开始,尝试删除
for(int i = begin;i<s.size();++i)
{
if (i != begin && s[i] == s[i-1]) continue;//联系的左/右括号,不需要删除
if (s[i] == '(' && left > 0)//尝试删除此处的左括号
{
DFS(s.substr(0, i) + s.substr(i+1, s.size()-1-i), i, left - 1, right);
}
if (s[i] == ')' && right > 0)//尝试删除此处的右括号
{
DFS(s.substr(0, i) + s.substr(i+1, s.size()-1-i), i, left, right - 1);
}
}
完整代码如下:
class Solution {
public:
vector<string>Res;
vector<string> removeInvalidParentheses(string s) {
int left = 0,right = 0;
//计算需要删除的错误左右括号个数
for(auto item:s)
{
if(item == '(') left++;//记录全部的左括号
else if(item == ')')
{
if(left>0) left--;//当遇到匹配的右括号时,删除一个,表示这个括号不在非法括号的范围内
else right++;//一旦left 不大于0,但是此时出现了右括号,显然是非法括号
}
}
DFS(s, 0, left, right);
return Res;
}
void DFS(string s,int begin,int left,int right)
{
if(left == right&&left == 0)
{
if(JadgeNum(s)) Res.push_back(s);
// if(Jadge(s)) Res.push_back(s);
return;
}
for(int i = begin;i<s.size();++i)
{
if (i != begin && s[i] == s[i-1]) continue;//联系的左/右括号,不需要删除
if (s[i] == '(' && left > 0)//尝试删除此处的左括号
{
DFS(s.substr(0, i) + s.substr(i+1, s.size()-1-i), i, left - 1, right);
}
if (s[i] == ')' && right > 0)//尝试删除此处的右括号
{
DFS(s.substr(0, i) + s.substr(i+1, s.size()-1-i), i, left, right - 1);
}
}
}
//以下为两组判断合法括号的方法
bool Jadge(string s)
{
stack<char>Temp;
for(int i = 0;i<s.size();++i)
{
if(s[i] == '(') Temp.push(s[i]);
if(s[i] == ')')
{
if(Temp.size()&&Temp.top() == '(') Temp.pop();
//注意细节,Temp.size()不为0才能完成后续操作
else return false;
}
}
return Temp.size() == 0?true:false;
}
bool JadgeNum(string s)
{
int count = 0;
for(int i = 0;i<s.size();++i)
{
if(s[i] == '(') count++;
else if(s[i] == ')') count--;
if(count<0) return false;
}
return count == 0?true:false;
}
};
DFS部分详解:
void DFS(string s,int begin,int left,int right)
{
if(left == right&&left == 0)
{
if(Check(s)) Res.push_back(s);
return;
}
for(int i = begin;i<s.size();++i)
{
//这个部分如果输入是())() 删除1和删除2,两种删除方法的结果一样,都是()()()
//此处的判断是为了剪枝
if (i != begin && s[i] == s[i-1]) continue;
if (s[i] == '(' && left > 0)//尝试删除此处的左括号
{
//此处begin也是一个重要的细节,此处s为()())(),删除3位置的),那么下次应是从4位置的)
//开始,但是传递给下一个递归的target已经删除了)(3位置),begin序号不能变,否则跳过一个
DFS(s.substr(0, i) + s.substr(i+1, s.size()-1-i), i, left - 1, right);
}
if (s[i] == ')' && right > 0)//尝试删除此处的右括号
{
DFS(s.substr(0, i) + s.substr(i+1, s.size()-1-i), i, left, right - 1);
}
}
}
本题难在,一般的回溯法都是内容的拼接和组合,本题是拆解,这是最难的部分。
17. 电话号码的字母组合
https://leetcode-cn.com/problems/letter-combinations-of-a-phone-number/
本题极具技巧性,首先我们来看示例,23,是从2中选择一个数字,在3中也选择一个数字,然后进行组合
这并不是常规的回溯组合要求,我们目前所见到的都是同一串字符,不同位置的组合
但是本质还是一样的,我们看图:
在组合要求是2的时候,我们能够选择abc三个字母,到了选择3,我们需要选择def;
那么既然如此,我们就在递归的时候,规定本轮loop,我们是在怎么的组合要求下进行选择的即可
就像我们以前规定起点和终点一样,现在我们也是在规定选取范围
核心代码:
void backtrack(string target,string digits,int begin)//begin代表了本轮的组合要求
{
if(target.size() == digits.size()) {Res.push_back(target);return;}
string Here = Number[digits[begin]];//本轮要选取的内容
for(auto item:Here)
{
string temp = target;
target += item;
backtrack(target,digits,begin+1);
target = temp;
}
}
下面对键盘进行初始化,为了方便,我们直接用Hash表进行优化:
unordered_map<char,string>Number;
//对整个键盘进行初始化
if(digits == "") return Res;
Number['2'] = "abc";Number['3'] = "def";
Number['4'] = "ghi";Number['5'] = "jkl";
Number['6'] = "mno";Number['7'] = "pqrs";
Number['8'] = "tuv";Number['9'] = "wxyz";
完整代码如下:
class Solution {
public:
unordered_map<char,string>Number;
vector<string> Res;
vector<string> letterCombinations(string digits) {
//对整个键盘进行初始化
if(digits == "") return Res;
// Number[0] = "abc";Number[1] = "def";
Number['2'] = "abc";Number['3'] = "def";
Number['4'] = "ghi";Number['5'] = "jkl";
Number['6'] = "mno";Number['7'] = "pqrs";
Number['8'] = "tuv";Number['9'] = "wxyz";
int begin = 0;
backtrack("",digits,0);
//第一个参数是目前已经完成的组合,参数二就是给定的组合要求,参数三是本轮付出怎么样的组合要
return Res;
}
void backtrack(string target,string digits,int begin)
{
if(target.size() == digits.size()) {Res.push_back(target);return;}
string Here = Number[digits[begin]];//本轮要选取的内容
for(auto item:Here)
{
string temp = target;
target += item;
backtrack(target,digits,begin+1);
target = temp;
}
}
};