数据结构:回溯算法
回溯问题解决模板
回溯法又叫回溯搜索法,是一种搜索方式;回溯是递归的副产品,只要有递归就会有回溯。一般一个递归对应一个回溯,是成对出现的;回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。回溯的本质就是穷举,就是暴力搜索。不要瞧不起暴力搜索,很多问题能用暴力搜索解决已经很不错了。
回溯解决的问题:
- 组合问题:N个数里面按一定规则找出k个数的集合;
- 切割问题:一个字符串按一定规则有几种切割方式;
- 子集问题:一个N个数的集合里有多少符合条件的子集;
- 排列问题:N个数按一定规则全排列,有几种排列方式;
- 棋盘问题:N皇后,解数独等等;
区分排列组合:排列问题有顺序,而组合问题不考虑顺序;
回溯法解决的问题都可以抽象为N叉树结构(画图,纵向和横向):
遇到回溯问题,直接画出如上的树状图然后套用模板即可;
回溯三部曲:
- 确定回溯函数的返回值以及参数;
- 回溯函数终止条件:访问到树状图的叶子时终止,一旦访问到叶子节点,就说明找到了一条可行路径,保持结果;
- 回溯的遍历过程:回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。(两个方向,一个方向往下,一个方向往左)
// 回溯函数模板:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {(往右走)
处理节点;
backtracking(路径,选择列表); // 递归(往下走)
回溯,撤销处理结果
}
}
根据树状图我们可以看出我们既要横向遍历也要纵向遍历才能访问到所有叶子节点;所以for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历;这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
回溯问题分类
组合
不用回溯会发送什么?不用回溯依旧考虑使用暴力搜索,但是继续写下去就会发现for要嵌套k层,而k在变化,我们可以写一层、两层、十层for嵌套,但是k层,怎么写?写不出来,只能用回溯;(回溯法就是解决这种k层for循环嵌套)
先将问题直接抽象为树状结构,一旦抽象出来树状结构,就可以套用回溯法的模板:(选定一个n和k之后画,否则根本画不出来)
画出树状图之后,我们就可以发现要到达每个叶子节点,要走两个方向,一个方向是往深走,一个方向是往广走,这样才能找到所有叶子。往深就是用递归,递归一次下降一层;往广就是用for循环,循环一遍往右走一步;时刻把握这两个方向,回溯就变得很简单了;
回溯三步曲:
- 递归函数的返回值以及参数:除了
n
和k
,还要有一个startIndex
,记录本层递归中,集合从哪里开始遍历,集合就是[1,2,...,n]
,和之前遇到的数组的left
和right
一样,这样就不用每次递归都新建一个数组或者集合;(递归的传入参数怎么看?从上往下看,看每一层那些东西变化了,递归导致了向下走,所以这些变化的变量就是递归的参数) - 回溯函数终止条件:回溯是一个
n
叉树,回溯终止就是到达叶子节点,本题中的叶子节点是结果集合中有K
个元素; - 单层搜索过程:回溯法的搜索过程就是一个树型结构的遍历过程,所以写回溯的题首先画出一个简而化之的
n
叉树;for
循环用来横向遍历,递归的过程用来纵向遍历;
回溯,一定是一层递归一层回溯,一一对应,递归前改变了什么,递归后就要回溯恢复什么;本题中递归前push
,递归后就要pop
;
class Solution {
public:
vector<vector<int>> result; // 存放符合条件结果的集合;
vector<int> path;
void backtracking (int n, int k, int startIndex) {
// startIndex记录本层递归中,集合从哪里开始遍历的;
// 类似于之前传入数组时附带参数left,right,这样就不用每次递归都切割数组;
if (path.size() == k) { // 递归到最后一层;
result.push_back(path);
// path.clear(); 不需要清空?因为后面回溯了,path.pop_back()弹出去了,不用清空;
return; // 到达叶子节点才会返回;
}
for (int i = startIndex; i <= n; i++) {
path.push_back(i);
backtracking(n, k, i + 1); // 递归,注意是i + 1,到下一层了;
// 递归完一定要回溯:修改了什么值就要回溯什么值;递归前push了,递归后就要pop;
path.pop_back();
// return; 不需要返回?返回了就不在for循环里了;
}
}
vector<vector<int>> combine(int n, int k) {
// 组合问题利用回溯法;
result.clear();
path.clear();
backtracking(n, k, 1);
return result;
}
};
注意用left
和right
来避免频繁创建vector
,如果不适用边界控制,而是每一次都传入一个新数组,空间消耗很大。(这也是一种技巧)
注意回溯只会发生在递归后,本题中一开始找到叶子节点后都想回溯,这肯定不合适,找到叶子节点的if
里没有任何递归,没有递归怎么能回溯?
回溯法的剪枝优化:(看树状图中有没有没必要走的路径)
n = 4
,k = 4
的话,那么第一层for
循环的时候,从元素2
开始的遍历都没有意义了。 在第二层for
循环,从元素3
开始的遍历都没有意义了。
如果for
循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了。(我们要4个,可是却从3个里选,不可能在3个里选到4个)
所以我们可以优化负责向右走的for循环:
for (int i = startIndex; i <= n; i++) // 优化前
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // 优化后,i为本次搜索的起始位置
组合例题
回溯法选择的必要性:从示例上来说,输入"23",最直接的想法就是两层for循环遍历了吧,正好把组合的情况都输出了。如果输入"233"呢,那么就三层for循环,如果"2333"呢,就四层for循环;选择k个字符要k重循环,没办法手动写出k层循环,所以用回溯;
回溯三部曲:
-
回溯的传入参数和返回值(从上往下看):集合
[abc]
往下变成了集合[def]
,所以传入参数中应该有输入的数字字符;其次,只用传入每层的数字字符吗?不是,要传入本层以及下层的数字字符,即传入本层和下层的输入字符串;每一次都传入新的字符串这一点可以优化,即优化为传入旧的字符串和一个新字符串开头字符在旧字符串的位置索引; -
递归终止条件:结果的长度和传入字符串的长度一致时;
-
单层递归逻辑:双层循环,第一层选中一个数字,第二层选中数字对应的字母映射集中的一个字母;
(注:并不需要两层循环,第一层循环选择数字可以用传入参数加一实现;单层循环则递归终止条件要改变,starti
代表选择第几个数字,starti == digits.size()
代表选择传入数字都被选择完了;)
class Solution {
public:
vector<string> result; // 保存结果;
string path; // 保存单个组合结果;
vector<string> NumTo =
{{"!@#"}, {"abc"}, {"def"}, {"ghi"}, {"jkl"},
{"mno"}, {"pqrs"}, {"tuv"}, {"wxyz"}}; // 注意初始化用{}而不是[];
void backtracking (string digits, int starti) {
if (path.size() == digits.size() && path.size() != 0) { // 递归终止条件;
result.push_back(path); // 保存结果;
return;
}
for (int i = starti; i < digits.size(); i++) {
// char c = 'a' + (int(digits[i]) - 1) * 3 + 0或者1或者2;
int num = digits[i] - '0' - 1; // 获取到数字
for (int j = 0; j < NumTo[num].size(); j++) { // 获取到数字代表的字符的组合
// char c = 'a' + ((digits[i] - '0') - 2) * 3 + j;
char c = NumTo[num][j];
path.push_back(c);
backtracking(digits, i + 1);
path.pop_back();
}
}
return;
}
vector<string> letterCombinations(string digits) {
// 输入的string需要处理?不必要,可以用规律;
result.clear();
backtracking(digits, 0);
return result;
}
};
注意本题中横向遍历时里面还可以有一个for
循环!
回溯法遍历过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集;
注意本题中,要求数字可以无限重复选取。也就是取了2之后,下一层依旧可以选择2,这么一来,和之前的题目有一些不同:
- 之前每深入一层,可选集合中元素就会减少1;本题中,深入并不一定会减小可选集合的大小;
- 之前深入到最后,可选集合中选无可选,所以退出递归;可是本题可选集合规模不一定会缩小,所以如果按照之前的判断,就会无限递归,要重新选择递归终止条件;
- 重新选择递归终止条件:利用
sum == target
时保存结果以及sum > target
时提前剪枝;
题目中的无限制重复被选取,不过就是之前要每次startIndex
加一,现在不用加一;但是还是要有startIndex
去控制,否则就会出现223,322
这种明明是一种情况却统计了两遍的错误;
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
int sum = 0; // 保存path之和;
void backtracking (vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) { // 找到一组目标;
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size(); i++) {
path.push_back(candidates[i]);
sum += candidates[i];
if (sum > target) { // 提前剪枝;
// 不继续向下递归,直接回溯;(和if外操作一一对应回溯)
sum -= candidates[i];
path.pop_back(); // 看上去if里没有递归不用回溯,但是if外有递归啊!
// return; // 不应该return;而是用else,直接return退出了for循环;
// break; //break也不行,也退出了for循环;
} else {
// 递归,注意每次都是从0开始找candidates(因为可以重复,不可以重复则要每次加一)
// backtracking(candidates, target, sum);
backtracking(candidates, target, sum, i); // 最后一个参数startIndex,保证了即使重复也有序,不会出现233和323同时在;
// 回溯:
sum -= candidates[i];
path.pop_back();
}
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
// backtracking(candidates, target, sum); 不加以控制会出现233、323这种明显一样却算了两次;
result.clear();
path.clear();
backtracking(candidates, target, sum, 0);
return result;
}
};
数组中有重复元素,但是数组每个元素只能使用1次;
难度在于去重;这么去重?元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
去重逻辑:同一层上相同数字只能被取一次;不同层上相同数字可以被取多次;
总结起来我们的去重逻辑就是:对树层去重,对树枝不去重;(使用一个布尔类型数组used
实现)
- 递归传入的数组是有序的;只有有序才好判断值重复,重复值都相邻;(树层排序)
- 注意多了
used
数组,回溯时也要处理used
数组,别忘了,回溯时要和递归前的操作一一对应; - 由于每个元素只用取一次,所以递归时传入的
startIndex
参数位置要逐次加1
,如果每个元素能重复使用,则不用加1
;(和上一道题目区分) - 在判断不需要递归的分支时,要设置好比较顺序,
i>0
应该在前; - 不需要递归的分支的规则:
i>0
,并且上一个遍历的元素和下一个遍历的元素值相等,并且used
数组为false
;具体在树状图上看就是同一层的重复值元素;
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
// 传入的candidates已经有序;
void backtracking (vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
// 多了一个布尔类型数组used用于去重;
// 终止条件,依旧是sum=target;
if (sum == target) {
result.push_back(path); // 保存结果;
return;
}
// 单层逻辑(循环遍历)
for (int i = startIndex; i < candidates.size(); i++) {
// used[i - 1] == true,说明第i-1个元素作为上一个被使用过的节点;即i和i-1在同一树枝不在同一层;
// used[i - 1] == flase, 说明第i-1和i个元素在同一层;
// 元素相等并且在同一层时要跳过,不继续向下递归;
// 必须有i > 0,否则i-1越界;
if (i > 0 && candidates[i - 1] == candidates[i] && used[i-1] == false) { // 注意顺序,i > 0要在前面,否则会越界;
continue; // 不继续递归,直接continue,而且由于递归前没有操作,也不用回溯;
}
// 向下递归;
sum += candidates[i];
if (sum > target) {
sum -= candidates[i];
continue;
}
used[i] = true;
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i + 1, used); // 每个元素只能使用一次,所以i + 1;
sum -= candidates[i];
path.pop_back();
used[i] = false; //回溯;
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
// 难点在于去重;比起得到结果之后再去重,在过程中就直接去重更省时间;
path.clear();
result.clear();
sort(candidates.begin(), candidates.end());
vector<bool> used(candidates.size(), false); // 注意创建的初始化;
backtracking(candidates, target, 0, 0, used);
return result;
}
};
三道组合题目的不同:
题目 | 描述 | startIndex是否每层加1 | 递归终止条件 | 其他 |
---|---|---|---|---|
组合 | 从n 个不重复的数中选取k 个数,不可重复选择 | 是 | 选取的数达到k 个 | |
组合总和 | 从n 个不重复的数中选取k 个数,可以重复选择 | 否 | 看总和是否到达target | |
组合总和II | 从n 个可能重复的数中选取k 个数,不可以重复选择 | 是 | 看总和是否到达target | 使用used 布尔数组辅助去重 |
切割
分割问题和组合问题一样,用回溯,画好递归的树状图,根据图写代码;
字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…。
回溯三部曲:
- 递归传入参数:只用传入一个字符串;
- 递归结束条件:传入的字符串长度为0;
for
循环:(单层递归逻辑)- 看单层,每一次切割的位置右移1位,所以直接
for(int i = 0; i < s.size(); i++){}
; - 循环内部逻辑:切割为两个字符串,左边字符串
leftS
判断是否回文,如果不回文则提前结束;右边字符串rightS
传到下一层,即作为递归的参数;
- 看单层,每一次切割的位置右移1位,所以直接
(根据树状图写代码,不容易忽视细节,同层怎么for
循环,递归向下传递什么参数,递归的有穷性都可以轻易看出)
子串可以不用切割数组的方法,而是用substr()
函数:string str = s.substr(startIndex, i - startIndex + 1);
切割为[startIndex, i]
在s
中的子串;
判断回文也可以使用双指针法;
class Solution {
public:
vector<vector<string>> result;
vector<string> path;
bool judge (string s) {
// 判断是否回文;
for (int i = 0; i < s.size() / 2 + 1; i++) {
if (s[i] != s[s.size() - 1 - i]) return false;
}
return true;
}
void backtracking (string s) {
if (s.size() == 0) { // 如果切割完成;递归终止条件
result.push_back(path);
return;
}
for (int i = 0; i < s.size(); i++) {
// 同层逻辑:for循环;
// 切割字符串,从startIndex开始切;
// 切为[0, i]和(i, s.end());
string leftS(s.begin(), s.begin() + i + 1);
string rightS(s.begin() + i + 1, s.end());
// 判断leftS是否回文;
if (!judge(leftS)) continue;
// leftS已经回文;
// 递归到下一层的逻辑:
path.push_back(leftS);
backtracking(rightS); // 下一层分割的字符串减少为rightS;
path.pop_back(); // 回溯;
}
}
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
backtracking(s);
return result;
}
};
子集
和一般回溯一样,但是注意,之前的回溯保存结果都是只保存叶子节点,而叶子节点恰是退出递归时获取的;本题要所有子集,所以保存结果不止叶子节点,是所有节点;
- 根据树状图填写回溯模版,得到大致的回溯函数;
- 找到保存结果的地方,不再是之前的
if(){}
递归结束条件里面,而是在递归结束条件之外,每次递归都会产生一个可以保存的结果; - 事实上,递归终止条件可以不写;
class Solution {
public:
vector<vector<int>> result;
vector<int> sub;
void backtracking (vector<int>&nums, int startVal) {
result.push_back(sub);
if (startVal == nums.size()) {
// result.push_back(sub); // 不止叶子节点;所有节点;
return;
}
for (int i = startVal; i < nums.size(); i++) {
sub.push_back(nums[i]);
backtracking(nums, i + 1);
sub.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
result.clear();
sub.clear();
backtracking(nums, 0);
return result;
}
};
子集是收集树形结构中树的所有节点的结果。而组合问题、分割问题是收集树形结构中叶子节点的结果。
排列
排列和组合进行对比,排列有顺序,同一层之前使用过的元素还可以使用,不能用startVal来标注下一个遍历的元素,而是用used数组标注;
- 使用used布尔数组来标注使用过的元素;
- 每次for循环都是从0开始,而不是之前的设置从startIndex开始;
- 到达叶子节点时保存结果;
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
// 使用used数组记录使用过的元素;
void backtracking (vector<int>& nums, vector<bool>& used) {
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i] == true) continue; // path已经收录过的元素,跳过;
used[i] = true; // 标记为使用过;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false; // 回溯;
}
}
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
棋盘
总结
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯法确实不好理解,所以需要把回溯法抽象为一个树形图来理解;