前置知识
递归:是一种编程技术,函数直接或间接调用自身,将大问题分解为结构相同的子问题。递归关注的是问题的分解和终止条件(base case),例如计算阶乘、树的遍历等。
回溯:是一种算法思想,常用于在解空间中搜索所有可行解 。它通过尝试每一种可能的选择,当发现当前选择无法得到正确解时,就撤销该选择(回溯),并尝试下一个选择。回溯通常借助递归实现,但核心在于"试错"和"回退"。
什么是回溯法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
在二叉树系列中,我们已经不止一次,提到了回溯。回溯是递归的副产品,只要有递归就会有回溯。
所以以下讲解中,回溯函数也就是递归函数,指的都是一个函数。
回溯法的效率
回溯法的性能如何呢,这里要和大家说清楚了,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
此时大家应该好奇了,都什么问题,这么牛逼,只能暴力搜索。
回溯法解决的问题
回溯法,一般可以解决如下几种问题:
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
相信大家看着这些之后会发现,每个问题,都不简单!
另外,会有一些同学可能分不清什么是组合,什么是排列?
记住组合无序,排列有序,就可以了。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
如何理解回溯法
回溯法解决的问题都可以抽象为树形结构,是的,我指的是所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

注意图中,特意举例集合大小和孩子的数量是相等的!
回溯法模板
这里给出Carl总结的回溯算法模板,回溯三部曲:
1、回溯函数模板返回值以及参数
在回溯算法中,我的习惯是函数起名字为backtracking。
回溯算法中函数返回值一般为void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。但后面的回溯题目的讲解中,为了方便大家理解,我在一开始就帮大家把参数确定下来。
2、回溯函数终止条件
既然是树形结构,遍历树形结构一定要有终止条件,所以回溯也有要终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
cpp
if (终止条件) {
存放结果;
return;
}
3、回溯搜索的遍历过程
在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
回溯函数遍历过程伪代码如下:
cpp
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中看出for循环可以理解是横向遍历 ,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索到叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
cpp
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
17.电话号码的字母组合

思路与解法
从示例上来说,输入"23",最直接的想法就是两层for循环遍历了吧,正好把组合的情况都输出了。
如果输入"233"呢,那么就三层for循环,如果"2333"呢,就四层for循环...
for循环的层数如何写出来,此时又是回溯法登场的时候了。
理解本题后,要解决如下三个问题:
1、数字和字母如何映射
2、两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来
3、输入1 * #按键等等异常情况(题目其实明确范围是 ['2', '9'])
1、数字和字母如何映射
可以使用map或者定义一个二维数组,例如:string letterMap[10],来做映射,我这里定义一个二维数组,代码如下:
cpp
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
2、回溯法解决n个for循环的问题
例如:输入:"23",抽象为树形结构,如图所示:

图中可以看出遍历的深度,就是输入"23"的长度,而叶子节点就是我们要收集的结果,输出["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]。
回溯三部曲:
1、确定回溯函数参数
首先需要一个字符串s来收集叶子节点的结果,然后用一个字符串数组result保存起来,这两个变量定义为全局。
再来看参数,参数指定是有题目中给的string digits,然后还要有一个参数就是int型的index。
这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
代码如下:
cpp
vector<string> result;
string s;
void backtracking(const string& digits, int index)
2、确定终止条件
例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。
那么终止条件就是index等于输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。然后收集结果,结束本层递归。
cpp
if (index == digits.size()) {
result.push_back(s);
return;
}
注意就是size,而不是size-1,因为遍历到最后一个元素后面才结束!!
3、确定单层遍历逻辑
首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。
然后for循环来处理这个字符集,代码如下:
cpp
int digit = digits[index] - '0'; // 将输入字符串中的字符转为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(); // 回溯
}
为什么要回溯?比如拿到ad后,要回溯,就是把d弹出再加入e得到ae。
核心代码:
cpp
class Solution {
private:
const string letterMap[10] ={
"",
"",
"abc",
"def",
"ghi",
"jkl",
"mno",
"pqrs",
"tuv",
"wxyz",
};
public:
string s;
vector<string> results;
//index用于记录遍历到第几个数字了
void backtracking(string digits,int index){
if(index==digits.size()){
results.push_back(s);
return;
}
int digit = digits[index]-'0';
string letter = letterMap[digit];
for(int i=0;i<letter.size();i++){
s.push_back(letter[i]);
backtracking(digits,index+1);
s.pop_back();
}
}
vector<string> letterCombinations(string digits) {
s.clear();
result.clear();
if(digits.size()==0) return results;
backtracking(digits,0);
return results;
}
};
ACM:
cpp
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Solution {
private:
const string letterMap[10] ={
"",
"",
"abc",
"def",
"ghi",
"jkl",
"mno",
"pqrs",
"tuv",
"wxyz",
};
public:
string s;
vector<string> results;
//index用于记录遍历到第几个数字了
void backtracking(string digits,int index){
if(index==digits.size()){
results.push_back(s);
return;
}
int digit = digits[index]-'0';
string letter = letterMap[digit];
for(int i=0;i<letter.size();i++){
s.push_back(letter[i]);
backtracking(digits,index+1);
s.pop_back();
}
}
vector<string> letterCombinations(string digits) {
s.clear();
results.clear();
if(digits.size()==0) return results;
backtracking(digits,0);
return results;
}
};
int main(){
string digits;
getline(cin,digits);
Solution sol;
vector<string> results =sol.letterCombinations(digits);
for(string&s:results){
cout<<s<<" ";
}
cout<<endl;
return 0;
}
39. 组合总和

本题选取数字没有数量要求,可以无限重复,但是有总和target的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:

注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
回溯三部曲
1、递归函数参数
这里依然是定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。(这两个变量也可以作为函数参数传入)
首先是题目中给出的参数,集合candidates和目标值target。
此外我还定义了int型的sum变量来统计单一结果path里的总和,其实这个sum也可以不用,用target做相应的减法就可以了,最后target==0就说明找到符合的结果了,但为了代码逻辑清晰,我依然用了sum。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
① 如果是一个集合 来求组合的话,就需要startIndex。
② 如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我在讲解排列的时候会重点介绍。
代码如下:
cpp
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex)
2、递归终止条件
终止只有两种情况,sum大于target和sum等于target。
sum等于target的时候,需要收集结果,代码如下:
cpp
if (sum > target) {
return;
}
if (sum == target) {
result.push_back(path);
return;
}
3、单层搜索的逻辑
单层for循环依然是从startIndex开始,搜索candidates集合。注意本题元素是可重复选取的。
如何重复选取呢,看代码,注释部分:
cpp
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(); // 回溯
}
核心代码:
cpp
class Solution {
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]);
backtracking(candidates,target,sum,i);
sum-=candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
backtracking(candidates,target,0,0);
return result;
}
};
131.分割回文串

回文串是指一个字符串,它正向读和反向读的结果完全相同。简单来说,就是对称的字符串。
思路与解法:
本题涉及到两个关键问题:
1、切割问题,有不同的切割方式
2、判断回文
回溯究竟是如何切割字符串呢?其实切割问题类似组合问题。
例如对于字符串abcdef:
组合问题: 选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个...。
切割问题: 切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段...。
所以切割问题,也可以抽象为一棵树形结构,如图:

递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
回溯三部曲
1、递归函数参数
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
代码如下:
cpp
vector<vector<string>> result;
vector<string> path; // 放已经回文的子串
void backtracking (const string& s, int startIndex) {
2、递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
所以终止条件代码如下:
cpp
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
}
3、单层搜索的逻辑
递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)循环中,我们定义了起始位置startIndex,那么 [startIndex, i]就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在vector<string> path中,path用来记录切割过的回文子串。
代码如下:
cpp
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // 是回文子串
// 获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else { // 如果不是则直接跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经添加的子串
}
【注】如果子串是回文,则用 substr 截取该子串(参数:起始位置,长度)。
判断回文子串
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
那么判断回文的C++代码如下:
cpp
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) {
return false;
}
}
return true;
}
本题列出如下几个难点:
① 切割问题可以抽象为组合问题
② 如何模拟那些切割线(startIndex是上一层已经确定了的分割线,i是这一层试图寻找的新分割线 )
③ 切割问题中递归如何终止
④ 在递归循环中如何截取子串(substr)
⑤ 如何判断回文
核心代码:
cpp
class Solution {
private:
vector<vector<string>> result;
vector<string> path;
bool isString(const string&s,int start,int end){
for(int i=start,j=end;i<j;i++,j--){
if(s[i]!=s[j]){
return false;
}
}
return true;
}
void backtracking(const string&s, int startIndex){
if(startIndex>=s.size()){
result.push_back(path);
return;
}
for(int i=startIndex;i<s.size();i++){
if(isString(s,startIndex,i)==true){
string subs=s.substr(startIndex,i-startIndex+1);
path.push_back(subs);
}
else{
continue;
}
backtracking(s,i+1);
path.pop_back();
}
}
public:
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
backtracking(s,0);
return result;
}
};
【注】
1、 vector<vector<string>> result;注意类型是string!!
2、别忘了:
cpp
else{
continue;
}
3、isString函数中return true;一定要放在for循环外!!
78.子集

思路与解法
求子集问题和77.组合和131.分割回文串 又不一样了。
如果把子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点 ,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
什么时候for可以从0开始呢?
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:

从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
回溯三部曲
1、递归函数参数
全局变量数组path为子集收集元素,二维数组result存放子集组合。
递归函数参数在上面讲到了,需要startIndex。
代码如下:
cpp
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIndex) {
2、递归终止条件
从图中可以看出:剩余集合为空的时候,就是叶子节点。
那么什么时候剩余集合为空呢?
就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了,代码如下:
cpp
if (startIndex >= nums.size()) {
return;
}
其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了。
3、单层搜索逻辑
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
那么单层递归逻辑代码如下:
cpp
for (int i = startIndex; i < nums.size(); i++) {
path.push_back(nums[i]); // 子集收集元素
backtracking(nums, i + 1); // 注意从i+1开始,元素不重复取
path.pop_back(); // 回溯
}
核心代码:
cpp
class Solution {
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();
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
result.clear();
path.clear();
backtracking(nums, 0);
return result;
}
};
【注】
1、收集子集要放在终止添加的上面!!因为求子集是找所有节点!
空集什么时候加的?
在这段代码中,空集之所以被包含在结果中,是因为回溯函数 backtracking 在一开始就将当前的path加入了 result。你第一次调用 backtracking(nums, 0) 时,path 是空的,所以就把空集 [ ] 加入了 result。
2、注意i = startIndex,不是0!!
46. 全排列

思路与解法
相信这个排列问题就算是让你用for循环暴力把结果搜索出来,这个暴力也不是很好写。以[1,2,3]为例,抽象成树形结构如下:

回溯三部曲
1、递归函数参数
首先排列是有序的,也就是说[1,2]和[2,1]是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1,所以处理排列问题就不用使用startIndex了。
但排列问题需要一个used数组,标记已经选择的元素,如图橘黄色部分所示。
cpp
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used)
2、递归终止条件
叶子节点,就是收割结果的地方。什么时候是到达叶子节点呢?
当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
代码如下:
cpp
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
3、单层搜索的逻辑
这里和77.组合问题 、131.切割问题和78.子集问题最大的不同就是for循环里不用startIndex了。
因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。
而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
cpp
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;
}
核心代码:
cpp
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
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;
}
};
ACM:
cpp
#include <vector>
#include <iostream>
#include <sstream>
using namespace std;
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
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;
used[i]= true;
path.push_back(nums[i]);
backtracking(nums,used);
used[i]=false;
path.pop_back();
}
}
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(),false);
backtracking(nums,used);
return result;
}
};
int main(){
string line;
getline(cin,line);
stringstream ss(line);
int num;
vector<int> nums;
while(ss>>num) nums.push_back(num);
Solution sol;
vector<vector<int>> res = sol.permute(nums);
for(int i=0;i<res.size();i++){
for(int j=0;j<res[0].size();j++){
if(j>0) cout<<" ";
cout<<res[i][j];
}
cout<<endl;
}
return 0;
}
【注】
1、使用 getline 和 stringstream 是为了方便地从一行输入中读取多个整数(数量未知)。
getline(cin, line) 读取一整行(直到遇到换行符),把整行内容保存到字符串 line 中。
然后用 stringstream ss(line) 将字符串包装成类似流的对象,这样就可以像使用 cin 一样从中提取整数(ss >> num)
使用要包含头文件<sstream>