开始学习回溯!
回溯理论基础
代码随想录文章链接:代码随想录
文章摘要:
什么是回溯法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
在二叉树系列中,我们已经不止一次,提到了回溯。
回溯是递归的副产品,只要有递归就会有回溯。
所以以下讲解中,回溯函数也就是递归函数,指的都是一个函数。
回溯法的效率
回溯法的性能如何呢,这里要和大家说清楚了,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
此时大家应该好奇了,都什么问题,这么牛逼,只能暴力搜索。
回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
回溯法模板
在前段时间的二叉树学习中我们说了递归三部曲,这里我再给大家列出回溯三部曲。
- 回溯函数模板返回值以及参数
回溯算法中函数返回值一般为void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
回溯函数伪代码如下:
void backtracking(参数)
- 回溯函数终止条件
那么我们在讲解递归三部曲的时候,就知道递归函数一定要有终止条件,不能无限的递归下去。
回溯也一样,要有终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
if (终止条件) {
存放结果;
return;
}
- 回溯搜索的遍历过程
在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
如图:
注意图中,我特意举例集合大小和孩子的数量是相等的!
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
然后结合上面的思路,我们可以写出下面这个伪代码模板:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
看完这些,我们就开始刷题吧!
力扣题部分:
77. 组合
题目链接:. - 力扣(LeetCode)
题面:
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
思路:
本题是回溯法的经典题目。
直接的解法当然是使用for循环,例如示例中k为2,很容易想到 用两个for循环,这样就可以输出 和示例中一样的结果,下面k = 2时的代码。
int n = 4;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
cout << i << " " << j << endl;
}
}
如果 k = 3 呢?
我们就需要三个for循环。
如果n为100,k为50呢?那就50层for循环,是不是开始窒息。
此时就会发现虽然想暴力搜索,但是用for循环嵌套连暴力都写不出来!
回溯搜索法来了,虽然回溯法也是暴力,但至少能写出来,不像for循环嵌套k层让人绝望。
那么回溯法怎么暴力搜呢?
上面我们说了要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。
此时递归的层数大家应该知道了,例如:n为100,k为50的情况下,就是递归50层。
如果n = 4, k = 2,回溯思路应该就是这样的:
大致看懂这个图,接下来就让我们开始回溯三部曲:
回溯函数模板返回值以及参数
代码如下:
void backtracking(int n, int k, int startIndex)
n 和 k 都没什么问题,startindex是什么呢?
startindex是保证回溯过程不会重复的一个标志。
题目的意思是{1,2}和{2,1}是同一种组合,如果我们只弄for循环没有startindex,会导致组合重复,看看下面这个for循环,如果没有startindex,i从0开始,显然会重复。
for(int i = startIndex; i <= n; i ++)
除此之外,我们还要创建两个全局变量,一个用来存放符合条件单一结果(如{1,2}),一个用来存放符合条件结果的集合。
代码如下:
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
回溯函数终止条件
遍历过程中如果path的长度和k相等,意味着当前我们找到的组合符合条件了,这个时候我们就要把path记录下来,记录给result然后再结束本次递归。
终止条件代码如下:
if (path.size() == k) {
result.push_back(path);
return;
}
回溯搜索的遍历过程
其实遍历的循环外壳上面已经写过了。
for里面的内容需要注意——递归前加进来的元素需要在递归后去除。
整体代码如下:
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
写到这里,代码整合一下就出来了。
我们可以结合模板看下面的代码,基本就是套了模板的壳。
代码实现:
class Solution {
public:
vector<int>path;
vector<vector<int>>result;
void backtracking(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);
backtracking(n, k, i + 1);
path.pop_back();
}
}
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
216.组合总和III
题目链接:. - 力扣(LeetCode)
题面:
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
思路:
上面讲了那么多,这道题就不重复了,直接回溯三部曲:
回溯函数模板返回值以及参数
和上面相比,我们需要一个通过sum来判断和是不是n来决定是否符合条件。
当然,两个全局变量是不可少的。
vector<int>rightnums;
vector<vector<int>>result;
void find(int n, int k, int sum, int index)
回溯函数终止条件
如果长度等于k,就该停止操作了,当然停止前要判断是否符合条件,符合得记得加入答案的数组。
if(rightnums.size() == k)
{
if(sum == n) result.push_back(rightnums);
return;
}
回溯搜索的遍历过程
这回的集合元素只有1-9,所以for循环的i从index到9就行了。遍历如图所示
处理过程就是 path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。
代码如下:
for (int i = startIndex; i <= 9; i++) {
sum += i;
path.push_back(i);
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
别忘了处理过程 和 回溯过程是一一对应的,处理有加,回溯就要有减!
整合一下上面的代码答案就出来了。
代码实现:
class Solution {
public:
vector<int>rightnums;
vector<vector<int>>result;
void find(int n, int k, int sum, int index)
{
if(rightnums.size() == k)
{
if(sum == n) result.push_back(rightnums);
return;
}
for(int i = index; i <= 9; i ++)
{
rightnums.push_back(i);
sum += i;
find(n, k, sum, i + 1);
sum -= i;
rightnums.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
find(n, k, 0, 1);
return result;
}
};
17.电话号码的字母组合
题目链接:. - 力扣(LeetCode)
题面:
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。对应关系如图所示。
思路:
回溯三部曲:
回溯函数模板返回值以及参数
vector<string>result;
string mapnums[10] = {"6", "6","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
string ans = "";
把映射的内容记录成字符串数组放在全局变量的位置(就是下面代码的mapnums),然后还是两个全局变量,一个是放当前的组合,一个记录所有符合条件组合的答案。
回溯函数终止条件
if(ans.size() == digits.size())
{
result.push_back(ans);
return;
}
因为这个组合通过一一映射得到,显然当前组合的长度和字符串digits的长度相同时记录下来并结束当前递归函数。
回溯搜索的遍历过程
int k, i, d;
k = ans.size();
d = digits[k] - '0';
string s;
for(i = 0; i < mapnums[d].size(); i ++)
{
s = mapnums[d][i];
ans += s;
backtracking(digits);
ans.pop_back();
}
这回的for循环要看数字对应的映射字符串数组mapnums,数字不同遍历对象也不同。
我们通过k记录当前的组合长度,那digits[k]就刚好是读取的当前数字,由于数字是字符char格式,转int需要记得减去字符0。
s代表的是读取数字可能的字符,根据mapnums和当前数字d确定映射的具体字符串,s就是字符串的每种可能性。
这样的遍历下来,每种情况都可以通过这个回溯递归函数穷举得到。
代码实现:
class Solution {
public:
vector<string>result;
string mapnums[10] = {"6", "6","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
string ans = "";
void backtracking(string digits)
{
if(ans.size() == digits.size())
{
result.push_back(ans);
return;
}
int k, i, d;
k = ans.size();
d = digits[k] - '0';
string s;
for(i = 0; i < mapnums[d].size(); i ++)
{
s = mapnums[d][i];
ans += s;
backtracking(digits);
ans.pop_back();
}
}
vector<string> letterCombinations(string digits) {
if(digits.size() == 0) return result;
backtracking(digits);
return result;
}
};