提示:DDU,供自己复习使用。欢迎大家前来讨论~
文章目录
- 回溯算法理论基础
- 一、理论基础
- 1.1 什么是回溯法
- 1.2 回溯法的效率
- 1.3 回溯法解决的问题
- 1.4 如何理解回溯法
- 1.5 回溯法模板
- 二、题目
- 题目一:77.组合
- 解题思路:
- 回溯法三部曲
- 组合问题中的剪枝操作
- 题目二:216.组合总和III
- 解题思路:
- 回溯三部曲
- 剪枝操作:
- 题目三: 17.电话号码的字母组合
- 解题思路
- 数字和字母的映射
- 回溯法来解决n个for循环的问题
- 总结
回溯算法理论基础
回溯算法开始
一、理论基础
1.1 什么是回溯法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
回溯是递归的副产品,只要有递归就会有回溯。
1.2 回溯法的效率
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
1.3 回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
记住组合无序,排列有序,就可以了。
1.4 如何理解回溯法
回溯法解决的问题都可以抽象为树形结构
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
1.5 回溯法模板
回溯三部曲。
- 回溯函数模板返回值以及参数
回溯算法中函数返回值一般为void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来**,所以一般是先写逻辑,然后需要什么参数,就填什么参数。**
void backtracking(参数)
- 回溯函数终止条件
遍历树形结构一定要有终止条件,所以回溯也有要终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
if (终止条件) {
存放结果;
return;
}
- 回溯搜索的遍历过程
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
注意图中,举例集合大小和孩子的数量是相等的!
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
这份模板很重要
小结:
-
回溯和递归是相辅相成的。
-
回溯法其实就是暴力查找,并不是什么高效的算法。
-
回溯法解决的问题都可以抽象为树形结构(N叉树),并给出了回溯法的模板。
二、题目
题目一:77.组合
77. 组合
解题思路:
本题是回溯法的经典题目。
直接的想法就是两层for循环,依次输出两个元素。
代码日下:
int n = 4;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
cout << i << " " << j << endl;
}
}
输入:n = 100, k = 3 那么就三层for循环,代码如下:
代码如下:
int n = 100;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
for (int u = j + 1; u <= n; n++) {
cout << i << " " << j << " " << u << endl;
}
}
}
如果n为100,k为50呢,那就50层for循环,就太多for循环了。
此时就会发现虽然想暴力搜索,但是用for循环嵌套连暴力都写不出来!
此时,回溯搜索法来了,虽然回溯法也是暴力,但至少能写出来,不像for循环嵌套k层让人绝望。
Q:那么回溯法怎么暴力搜呢?
要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。
回溯法解决的问题都可以抽象为树形结构(N叉树),用树形结构来理解回溯就容易多了。
组合问题抽象为如下树形结构:
-
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
-
图中可以发现n相当于树的宽度,k相当于树的深度。
-
图中每次搜索到了叶子节点,我们就找到了一个结果。
回溯法三部曲
- 递归函数的返回值以及参数
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
整体代码如下:
startIndex 就是防止出现重复的组合。需要startIndex来记录下一层递归,搜索的起始位置。
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件单一结果
void backtracking(int n, int k, int startIndex)
- 回溯函数终止条件
if (path.size() == k) {
result.push_back(path);
return;
}
- 单层搜索的过程
回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
for循环每次从startIndex开始遍历,然后用path保存取到的节点i。
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
完整的代码:
class Solution {
private:
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path); //这里是以列表的格式加入进去[[X,X],[X,X],...]
return;
}
for (int i = startIndex; i <= n; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
};
- 时间复杂度: O( n ∗ 2 n n * 2^n n∗2n)
- 空间复杂度: O(n)
组合问题中的剪枝操作
剪枝优化:
回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
for (int i = startIndex; i <= n; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();
}
来举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
注意代码中i,就是for循环里选择的起始位置。
for (int i = startIndex; i <= n; i++) {
接下来看一下优化过程如下:
- 已经选择的元素个数:path.size();
- 还需要的元素个数为: k - path.size();
- 在集合n中至多(小于等于这个下标)要从该起始位置 : n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
借鉴剪枝找边界的方法 :
-
path.size() : 已经找的个数
-
k-path.size() :还需找的个数
-
【x, n】的数组长度起码应该是k-path.size()才有继续搜索的可能, 那么就有 n-x+1 = k-path.size() , 解方程得 x = n+1 - (k-path.size()), 而且这个x是可以作为起点往下搜的 也就是for(i = s; i<=x; i++) 这里的x是可以取到的
所以优化之后的for循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
优化后整体代码如下:
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
path.push_back(i); // 处理节点
backtracking(n, k, i + 1);
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
题目二:216.组合总和III
216. 组合总和 III
解题思路:
这个题目和组合题目类似,但是有一个新的条件,得到的符合个数的元素的和为一个值。
回溯三部曲
- 确定递归函数参数
依然需要一维数组path来存放符合条件的结果,二维数组result来存放结果集。
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
接下来还需要如下参数:
- targetSum(int)目标和,也就是题目中的n。
- k(int)就是题目中要求k个数的集合。
- sum(int)为已经收集的元素的总和,也就是path里元素的总和。
- startIndex(int)为下一层for循环搜索的起始位置。避免元素重复
vector<vector<int>> result;
vector<int> path;
void backtracking(int targetSum, int k, int sum, int startIndex)
强调一下,回溯法中递归函数参数很难一次性确定下来,一般先写逻辑,需要啥参数了,填什么参数。
- 确定终止条件
if (path.size() == k) {
if (sum == targetSum) result.push_back(path); //这里就是和组合题不一致的地方,判断元素和
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
- 单层搜索过程
区别之一就是集合固定的就是9个数[1,…,9],所以for循环固定i<=9
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 {
private:
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
// targetSum:目标和,也就是题目中的n。
// k:题目中要求k个数的集合。
// sum:已经收集的元素的总和,也就是path里元素的总和。
// startIndex:下一层for循环搜索的起始位置。
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
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(); // 回溯
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(n, k, 0, 1);
return result;
}
};
剪枝操作:
已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。
那么剪枝的地方可以放在递归函数开始的地方,剪枝代码如下:
if (sum > targetSum) { // 剪枝操作
return;
}
剪枝也可以放在 调用递归之前,即放在这里,只不过要记得 要回溯操作给做了。
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
sum += i; // 处理
path.push_back(i); // 处理
if (sum > targetSum) { // 剪枝操作
sum -= i; // 剪枝之前先把回溯做了
path.pop_back(); // 剪枝之前先把回溯做了
return;
}
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
for循环的范围也可以剪枝,==i <= 9 - (k - path.size()) + 1==就可以了。
最后C++代码如下:
note:
- path.push_back(i)和path.pop_back(), 一个需要写值,一个不需要直接pop掉最后一个元素。
- backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex,并不是startIndex+1;
class Solution {
private:
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (sum > targetSum) { // 剪枝操作
return;
}
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
sum += i; // 处理
path.push_back(i); // 处理
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(n, k, 0, 1);
return result;
}
};
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n)
题目三: 17.电话号码的字母组合
17. 电话号码的字母组合
解题思路
直接思路,也是使用嵌套的for循环,但是层数多了,就会很头大。
此时使用回溯算法来解决这类问题。
理解本题后,要解决如下三个问题:
- 数字和字母如何映射
- 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来
- 输入1 * #按键等等异常情况
数字和字母的映射
可以使用map或者定义一个二维数组,例如:string letterMap[10],来做映射,这里定义一个二维数组,代码如下:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
回溯法来解决n个for循环的问题
例如:输入:“23”,抽象为树形结构,如图所示:
图中可以看出遍历的深度,就是输入"23"的长度,而叶子节点就是我们要收集的结果,输出[“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”]。
回溯三部曲:
- 确定回溯函数参数
参数指定是有题目中给的string digits,一个新的参数就是int型的index。这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
vector<string> result;
string s;
void backtracking(const string& digits, int index)
- 确定终止条件
例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。
那么终止条件就是如果index 等于 输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。
然后收集结果,结束本层递归。
if (index == digits.size()) {
result.push_back(s);
return;
}
- 确定单层遍历逻辑
首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。
然后for循环来处理这个字符集,代码如下:
int digit = digits[index] - '0'; // 将index指向的数字转为int,,例如表达式 ('5' - '0') 的结果就是int 5
string letters = letterMap[digit]; // 取数字对应的字符集
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]); // 处理
backtracking(digits, index + 1); // 递归,注意index+1,一下层要处理下一个数字了
s.pop_back(); // 回溯
}
**注意:**输入1 * #按键等等异常情况
代码中最好考虑这些异常情况,但题目的测试数据中应该没有异常情况的数据,所以我就没有加了。
但是要知道会有这些异常,如果是现场面试中,一定要考虑到!
完整C++代码:
// 版本一
class Solution {
private:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
public:
vector<string> result;
string s;
void backtracking(const string& digits, int index) {
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0'; // 将index指向的数字转为int
string letters = letterMap[digit]; // 取数字对应的字符集
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]); // 处理
backtracking(digits, index + 1); // 递归,注意index+1,一下层要处理下一个数字了
s.pop_back(); // 回溯
}
}
vector<string> letterCombinations(string digits) {
s.clear();
result.clear();
if (digits.size() == 0) {
return result;
}
backtracking(digits, 0);
return result;
}
};
- 时间复杂度: O( 3 m ∗ 4 n ) 3^m * 4^n) 3m∗4n),其中 m 是对应三个字母的数字个数,n 是对应四个字母的数字个数
- 空间复杂度: O( 3 m ∗ 4 n 3^m * 4^n 3m∗4n)
一些写法,是把回溯的过程放在递归函数里了,例如如下代码,可以写成这样:(注意注释中不一样的地方)(不推荐)
// 版本二
class Solution {
private:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
public:
vector<string> result;
void getCombinations(const string& digits, int index, const string& s) { // 注意参数的不同
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0';
string letters = letterMap[digit];
for (int i = 0; i < letters.size(); i++) {
getCombinations(digits, index + 1, s + letters[i]); // 注意这里的不同
}
}
vector<string> letterCombinations(string digits) {
result.clear();
if (digits.size() == 0) {
return result;
}
getCombinations(digits, 0, "");
return result;
}
};
小结:
本题是多个集合求组合,所以在回溯的搜索过程中,都有一些细节需要注意的。
总结
- 回溯算法的基本理论
- 回溯法的组合问题
回溯算法也是暴力搜索,他解决的是for循环层数过多,无法实现的问题。
要自己多敲一下代码,注意细节的问题。