一 回溯基础
1.DFS 和回溯算法区别
DFS 是一个劲的往某一个方向搜索,而回溯算法建立在 DFS 基础之上的,但不同的是在搜索过程中,达到结束条件后,恢复状态,回溯上一层,再次搜索。因此回溯算法与 DFS 的区别就是有无状态重置
2.何时使用回溯算法
当问题需要 "回头",以此来查找出所有的解的时候,使用回溯算法。即满足结束条件或者发现不是正确路径的时候(走不通),要撤销选择,回退到上一个状态,继续尝试,直到找出所有解为止
3.怎么样写回溯算法(从上而下,※代表难点,根据题目而变化)
①画出递归树,找到状态变量(回溯函数的参数),这一步非常重要※
②根据题意,确立结束条件
③找准选择列表(与函数参数相关),与第一步紧密关联※
④判断是否需要剪枝
⑤作出选择,递归调用,进入下一层
⑥撤销选择
4.回溯问题的类型
这里先给出,我总结的回溯问题类型,并给出相应的 leetcode题目(一直更新),然后再说如何去编写。特别关注搜索类型的,搜索类的搞懂,你就真的搞懂回溯算法了,,是前面两类是基础,帮助你培养思维
5.回到子集、组合类型问题上来(ABC 三道例题)
A、 子集 - 给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
解题步骤如下
①递归树
观察上图可得,选择列表里的数,都是选择路径(红色框)后面的数,比如[1]这条路径,他后面的选择列表只有 "2、3",[2] 这条路径后面只有 "3" 这个选择,那么这个时候,就应该 使用一个参数 start,来标识当前的选择列表的起始位置。也就是标识每一层的状态,因此被形象的称为 "状态变量",最终函数签名如下
cpp
//nums为题目中的给的数组
//path为路径结果,要把每一条 path 加入结果集
void backtrack(vector<int>nums,vector<int>&path,int start)
②找结束条件
此题非常特殊,所有路径都应该加入结果集,所以不存在结束条件。或者说当 start 参数越过数组边界的时候,程序就自己跳过下一层递归了,因此不需要手写结束条件,直接加入结果集
**res为结果集,是全局变量vector<vector>res,到时候要返回的
res.push_back(path);//把每一条路径加入结果集
③找选择列表
在①中已经提到过了,子集问题的选择列表,是上一条选择路径之后的数,即
csharp
for(int i=start;i<nums.size();i++)
④判断是否需要剪枝
从递归树中看到,路径没有重复的,也没有不符合条件的,所以不需要剪枝
⑤做出选择(即for 循环里面的)
csharp
void backtrack(vector<int>nums,vector<int>&path,int start)
{
for(int i=start;i<nums.size();i++)
{
path.push_back(nums[i]);//做出选择
backtrack(nums,path,i+1);//递归进入下一层,注意i+1,标识下一个选择列表的开始位置,最重要的一步
}
}
⑤撤销选择
整体的 backtrack 函数如下
csharp
void backtrack(vector<int>nums,vector<int>&path,int start)
{
res.push_back(path);
for(int i=start;i<nums.size();i++)
{
path.push_back(nums[i]);//做出选择
backtrack(nums,path,i+1);//递归进入下一层,注意i+1,标识下一个选择列表的开始位置,最重要的一步
path.pop_back();//撤销选择
}
}
B、子集 II(剪枝思想)--问题描述:
给定一个可能 包含重复元素 的整数数组 nums,返回该数组所有可能的子集(幂集)。
输入: [1,2,2]
输出:
[
[2],
[1],
[1,2,2],
[2,2],
[1,2],
[]
]
解题步骤
①递归树
可以发现,树中出现了大量重复的集合,②和③和第一个问题一样,不再赘述,我们直接看第四步
④判断是否需要剪枝,需要先对数组排序,使用排序函数 sort(nums.begin(),nums.end())
显然我们需要去除重复的集合,即需要剪枝,把递归树上的某些分支剪掉。那么应去除哪些分支呢?又该如何编码呢?
观察上图不难发现,应该去除当前选择列表中,与上一个数重复的那个数,引出的分支,如 "2,2" 这个选择列表,第二个 "2" 是最后重复的,应该去除这个 "2" 引出的分支
去除图中红色大框中的分支)
编码呢,刚刚说到是 "去除当前选择列表中,与上一个数重复的那个数,引出的分支",说明当前列表最少有两个数,当i>start时,做选择的之前,比较一下当前数,与上一个数 (i-1) 是不是相同,相同则 continue,
csharp
void backtrack(vector<int>& nums,vector<int>&path,int start)
{
res.push_back(path);
for(int i=start;i<nums.size();i++)
{
if(i>start&&nums[i]==nums[i-1])//剪枝去重
continue;
}
}
⑤做出选择
csharp
void backtrack(vector<int>& nums,vector<int>&path,int start)
{
res.push_back(path);
for(int i=start;i<nums.size();i++)
{
if(i>start&&nums[i]==nums[i-1])//剪枝去重
continue;
temp.push_back(nums[i]);
backtrack(nums,path,i+1);
}
}
⑥撤销选择
整体的backtrack函数如下
csharp
** sort(nums.begin(),nums.end());
void backtrack(vector<int>& nums,vector<int>&path,int start)
{
res.push_back(path);
for(int i=start;i<nums.size();i++)
{
if(i>start&&nums[i]==nums[i-1])//剪枝去重
continue;
temp.push_back(nums[i]);
backtrack(nums,path,i+1);
temp.pop_back();
}
}
C、组合总和 - 问题描述
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
输入: candidates = [1,2,3], target = 3,
所求解集为:
[
[1,1,1],
[1,2],
[3]
]
解题步骤
①递归树
(绿色箭头上面的是路径,红色框[]则为结果,黄色框为选择列表)
从上图看出,组合问题和子集问题一样,1,2 和 2,1 `是同一个组合,因此 需要引入start参数标识,每个状态中选择列表的起始位置。另外,每个状态还需要一个 sum 变量,来记录当前路径的和,函数签名如下
csharp
void backtrack(vector<int>& nums,vector<int>&path,int start,int sum,int target)
②找结束条件
由题意可得,当路径总和等于 target 时候,就应该把路径加入结果集,并 return
csharp
if(target==sum)
{
res.push_back(path);
return;
}
③找选择列表
csharp
for(int i=start;i<nums.size();i++)
④判断是否需要剪枝
从①中的递归树中发现,当前状态的sum大于target的时候就应该剪枝,不用再递归下去了
csharp
for(int i=start;i<nums.size();i++)
{
if(sum>target)//剪枝
continue;
}
⑤做出选择
题中说数可以无限次被选择,那么 i 就不用 +1 。即下一层的选择列表,从自身开始。并且要更新当前状态的sum
csharp
for(int i=start;i<nums.size();i++)
{
if(sum>target)
continue;
path.push_back(nums[i]);
backtrack(nums,path,i,sum+nums[i],target);//i不用+1(重复利用),并更新当前状态的sum
}
⑤撤销选择
整体的 backtrack 函数如下
csharp
void backtrack(vector<int>& nums,vector<int>&path,int start,int sum,int target)
{
for(int i=start;i<nums.size();i++)
{
if(sum>target)
continue;
path.push_back(nums[i]);
backtrack(nums,path,i,sum+nums[i],target);//更新i和当前状态的sum
pacht.pop_back();
}
}
总结:子集、组合类问题,关键是用一个 start 参数来控制选择列表!!最后回溯六步走:
①画出递归树,找到状态变量(回溯函数的参数),这一步非常重要※
②根据题意,确立结束条件
③找准选择列表(与函数参数相关),与第一步紧密关联※
④判断是否需要剪枝
⑤作出选择,递归调用,进入下一层
⑥撤销选择
二 46. 全排列
1 题目
解题思路
对于一个长度为 nnn 的数组(假设元素互不重复),其排列方案数共有:
排列方案的生成:
根据数组排列的特点,考虑深度优先搜索所有排列方案。即通过元素交换,先固定第 1位元素( n 种情况)、再固定第 2位元素( n−1种情况)、... 、最后固定第 n 位元素( 1种情况)。
递归解析:
终止条件: 当 x = len(nums) - 1 时,代表所有位已固定(最后一位只有 111 种情况),则将当前组合 nums 转化为数组并加入 res ,并返回。
递推参数: 当前固定位 x 。
递推工作: 将第 x 位元素与 i ∈\in∈ [x, len(nums)] 元素分别交换,并进入下层递归。
固定元素: 将元素 nums[i] 和 nums[x] 交换,即固定 nums[i] 为当前位元素。
开启下层递归: 调用 dfs(x + 1) ,即开始固定第 x + 1 个元素。
还原交换: 将元素 nums[i] 和 nums[x] 交换(还原之前的交换)。
下图中 list 对应文中的列表 nums ,"abc" 对应 123 。
3 code
cpp
class Solution {
public:
vector<vector<int>> permute(vector<int>& nums) {
dfs(nums, 0);
return res;
}
private:
vector<vector<int>> res;
void dfs(vector<int> nums, int x) {
if (x == nums.size() - 1) {
res.push_back(nums); // 添加排列方案
return;
}
for (int i = x; i < nums.size(); i++) {
//递归
swap(nums[i], nums[x]); // 交换,将 nums[i] 固定在第 x 位
dfs(nums, x + 1); // 开启固定第 x + 1 位元素
//回溯
swap(nums[i], nums[x]); // 恢复交换
}
}
};
三 78. 子集
1 题目
2 解题思路
求子集问题和77.组合和131.分割回文串又不一样了。
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
有同学问了,什么时候for可以从0开始呢?
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
回溯三部曲
递归函数参数
全局变量数组path为子集收集元素,二维数组result存放子集组合。(也可以放到递归函数参数里)
递归函数参数在上面讲到了,需要startIndex。
代码如下:
csharp
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIndex) {
递归终止条件
从图中可以看出:
剩余集合为空的时候,就是叶子节点。
那么什么时候剩余集合为空呢?
就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了,代码如下:
csharp
if (startIndex >= nums.size()) {
return;
}
其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了。
单层搜索逻辑
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
那么单层递归逻辑代码如下:
csharp
for (int i = startIndex; i < nums.size(); i++) {
path.push_back(nums[i]); // 子集收集元素
backtracking(nums, i + 1); // 注意从i+1开始,元素不重复取
path.pop_back(); // 回溯
}
3 code
csharp
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
result.clear();
path.clear();
backtracking(nums,0);
return result;
}
private:
vector<vector<int>> result;
vector<int>path;
void backtracking(vector<int>&nums,int startIndex){
//收集子集,要放在终止添加的上面,否则会漏掉自己
result.push_back(path);
//终止条件可以不加
if(startIndex>=nums.size()) return;
for(int i=startIndex;i<nums.size();i++)
{
//处理节点
path.push_back(nums[i]);
//递归
backtracking(nums,i+1);
//回溯
path.pop_back();
}
}
};
四 17. 电话号码的字母组合
1 题目
2 解题思路
从示例上来说,输入"23",最直接的想法就是两层for循环遍历了吧,正好把组合的情况都输出了。
如果输入"233"呢,那么就三层for循环,如果"2333"呢,就四层for循环...
大家应该感觉出和77.组合遇到的一样的问题,就是这for循环的层数如何写出来,此时又是回溯法登场的时候了。
理解本题后,要解决如下三个问题:
数字和字母如何映射
两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来
输入1 * #按键等等异常情况
数字和字母如何映射
可以使用map或者定义一个二维数组,例如:string letterMap[10],来做映射,我这里定义一个二维数组,代码如下:
csharp
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"]。
回溯三部曲:
确定回溯函数参数
首先需要一个字符串s来收集叶子节点的结果,然后用一个字符串数组result保存起来,这两个变量我依然定义为全局。
再来看参数,参数指定是有题目中给的string digits,然后还要有一个参数就是int型的index。
注意这个index可不是 77.组合和216.组合总和III中的startIndex了。
这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
代码如下:
csharp
vector<string> result;
string s;
void backtracking(const string& digits, int index)
确定终止条件
例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。
那么终止条件就是如果index 等于 输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。
然后收集结果,结束本层递归。
代码如下:
csharp
if (index == digits.size()) {
result.push_back(s);
return;
}
确定单层遍历逻辑
首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。
然后for循环来处理这个字符集,代码如下:
csharp
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(); // 回溯
}
注意这里for循环,可不像是在回溯算法:求组合问题!和回溯算法:求组合总和!中从startIndex开始遍历的。
因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而77. 组合和216.组合总和III都是求同一个集合中的组合!
注意:输入1 * #按键等等异常情况
代码中最好考虑这些异常情况,但题目的测试数据中应该没有异常情况的数据,所以我就没有加了。
但是要知道会有这些异常,如果是现场面试中,一定要考虑到!
3 code
csharp
class Solution {
public:
vector<string> letterCombinations(string digits) {
s.clear();
result.clear();
if(digits.size()==0)return result;
backtracking(digits,0);
return result;
}
private:
vector<string> result;
string s;
const string letterMap[10]={
"",//0
"",//1
"abc",//2
"def",//3
"ghi",//4
"jkl",//5
"mno",//6
"pqrs",//7
"tuv",//8
"wxyz",//9
};
void backtracking(const string& digits,int index)
{
if(index==digits.size())
{
result.push_back(s);
return;
}
//将index指向的数字转为int
int digit=digits[index]-'0';
//取数字对应的字符集
string letters=letterMap[digit];
for(int i=0;i<letters.size();i++)
{
//处理节点
s.push_back(letters[i]);
//递归,注意index+1,一下层要处理下一个数字了
backtracking(digits,index+1);
//回溯
s.pop_back();
}
};
};
五 39. 组合总和
1 题目
2 解题思路
3 code
csharp
class Solution {
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
backtracking(candidates,target,0,0);
return result;
}
private:
vector<vector<int>>result;
vector<int>path;
void backtracking(vector<int>&candidates,int target,int sum,int startIndex)
{
if(sum>target)
{
return;
}
if(sum==target)
{
result.push_back(path);
return;
}
for(int i=startIndex;i<candidates.size();i++)
{
sum+=candidates[i];
path.push_back(candidates[i]);
//不用i+1了,表示可以重复读取当前的数
backtracking(candidates,target,sum,i);
sum-=candidates[i];
path.pop_back();
}
}
};
六 22. 括号生成
1 题目
2 解题思路
3 code
csharp
class Solution {
public:
vector<string> generateParenthesis(int n) {
dfs("",n,n);
return res;
}
private:
vector<string>res;
void dfs(const string& str,int left, int right)
{
//出现类似())))这种格式都是错误的不用再继续了
if(left<0 || left>right) return;
if(left==0 && right==0)
{
res.push_back(str);
return;
}
dfs(str+'(',left-1,right);
dfs(str+')',left,right-1);
}
};
七 79. 单词搜索
1 题目
2 解题思路
假设我们以 board[0][0] 为起点,我们怎么在二维网格中去寻找和给定 word 一致的字符串?
将矩阵看成一个图结构,每个单元格相邻的四个方向(上下左右)就是其邻节点。因为题目说 同一个单元格内的字母不允许被重复使用,即我们在搜索过程中要对处理过的单元格进行标记避免重复处理。
我们从起点出发,假设我们当前待处理的单元格为 (r, c),待匹配的字符为 word 中索引为 i 的字符 word[i]:
如果 (r, c) 的字符与待匹配字符 word[i] 一致,那就可以继续匹配下一个字符 word[i+1],而下一个要去匹配的单元格就是当前单元格四个方向相邻的格子 ------ (r-1, c),(r+1, c),(r, c-1),(r, c+1):
如果相邻单元格越界或者已经访问过了,那么这个单元格就不可用,去找其他单元格;如果所有相邻单元格都不可用,那就匹配失败,回退到上一个待匹配字符 word[i-1] 和对应单元格;
如果 i+1 = n(n为word的长度) ,说明所有字符都匹配完了,那么找到了一致的字符串;
如果不一致,那么当前字符 word[i] 匹配失败,回退到上一个待匹配字符 word[i-1] 和对应单元格;
以此类推,我们可以 以每一个单元格 (i, j) 作为搜索起点,去搜索所有路径上所构成的字符串。
深度优先搜索 + 回溯法
根据上面的过程,我们可以使用深度优先搜索去枚举搜索路径。【深度优先搜索的策略,是沿着一个方向一直搜索直到不满足条件才退回到上一个节点搜索其他方向。】
但是深度优先搜索的本质是将图中所有节点(网格中所有格子)遍历一遍,因此对于遍历过的单元格其标记后就不管了;
而我们要做的是枚举所有的搜索路径,即存在状态回退的过程。因此需要在深度优先搜索的过程中加入回溯的内容。我们可以看到,状态回退的本质 是将当前匹配失败的单元格回退到未匹配的状态,以供其他路径使用到这个位置。
因此我们只需要在每一次递归结束的时候,将当前单元格重新标记为未使用过即可实现回溯。
3 code
csharp
class Solution {
private:
vector<vector<int>> directions = {{-1,0}, {1,0}, {0,-1}, {0,1}}; // 每个单元格的搜索方向
/**
* 判断当前单元格(r,c)的字符与字符串待匹配字符word[idx]是否匹配
* 如果不匹配,直接回退到上一个单元格与字符
* 如果匹配,搜索相邻单元格与下一个待匹配字符word[idx+1]
* @param r: 单元格行号
* @param c: 单元格列号
* @param board: 字符网格
* @param word: 待匹配字符串
* @param idx:待匹配字符word[idx]
* @param used: 标记使用过的单元格,used[r][c]=true 表示使用过
* @return: 返回 word 从idx到结尾的字符匹配情况
*/
bool backtracking(int r, int c, vector<vector<char>>& board, const string& word, int idx, vector<vector<bool>>& used){
if(board[r][c] != word[idx])return false; // 当前单元格字符与待匹配字符不匹配,直接返回false
int m = board.size(), n = board[0].size(); // 获取矩阵尺寸
if(++idx == word.size())return true; // 字符串匹配结束,则整个字符串匹配成功
// 匹配下一个字符
used[r][c] = true; // 标记当前单元格已经使用
for(auto& d: directions){
int nr = r + d[0], nc = c + d[1]; // 枚举相邻单元格进行递归搜索
if(nr < 0 || nr >= m || nc < 0 || nc >= n || used[nr][nc])continue; // 相邻单元格位置不合法或者已经使用了
if(backtracking(nr, nc, board, word, idx, used))return true; // 否则递归搜索成功,直接返回
}
used[r][c] = false; // 否则这个位置搜索失败,回退标记这个位置未使用过
return false;
}
public:
bool exist(vector<vector<char>>& board, string word) {
int m = board.size();
int n = board[0].size();
vector<vector<bool>> used(m, vector<bool>(n)); // 标记使用过的单元格,used[r][c]=true 表示使用过
for(int i = 0; i < m; i++){
for(int j = 0; j < n; j++){
// 以每个位置作为搜索起点,对字符串进行匹配搜索
if(backtracking(i, j, board, word, 0, used)){
return true; // 匹配成功直接返回
}
}
}
return false;
}
};
八 131. 分割回文串
1 题目
2 解题思路
对于这题要采用分割的思想,其核心也是回溯法,一条路走到底再回退。
如图所示:
每次调用backtrack函数就行一次分割,直到分割线处于最后一个元素之后将当前path压入集合。
每次添加一条分割线,用pre记录下一次分割的起始位置,记得每次判断当前分割下来的字符串是否属于回文
3 code
csharp
class Solution {
public:
vector<vector<string>> res;
void backtrack(string s,vector<string>&path,int pre){
string temp;
if(pre>=s.size()){ //判断结束条件
res.push_back(path);
return;
}
for(int i=pre;i<s.size();i++){
bool flag=true;
temp=s.substr(pre, i-pre+1); //用临时变量存取分割所得的字符串
int wide=temp.size();
for(int j=0;j<wide;j++){ //判断是否为回文
if(temp[j]!=temp[wide-1-j]){
flag=false;
break;
}
}
if(flag==false) continue;
path.push_back(temp);
backtrack(s,path,i+1);
path.pop_back(); //还原
}
}
vector<vector<string>> partition(string s) {
vector<string> path;
backtrack(s,path,0);
return res;
}
};