什么是回溯算法(本质一句话版)
回溯 = 递归 + 试探 + 撤销选择
回溯算法本质是一种 深度优先搜索(DFS) 的穷举策略:
-
先做选择
-
继续向下搜索
-
发现不合适就退回(回溯)
-
换一个选择再试
👉 只要你写了递归,只要你在递归返回前"撤销状态",那你就在用回溯。
为什么回溯一定和递归绑定?
你文中这句话非常关键:
回溯是递归的副产品,只要有递归就会有回溯
原因很简单:
-
递归 = 函数调用栈
-
回溯 = 利用调用栈 自动帮你回到上一步状态
没有递归,你就得自己维护栈和状态,非常痛苦。
回溯算法的效率认知(非常重要)
回溯 ≠ 高效算法
-
本质是:穷举所有可能
-
时间复杂度通常是:
-
组合 / 子集:
O(2^n) -
排列:
O(n!) -
棋盘类:指数级甚至更高
-
👉 回溯题的核心目标不是"快",而是:
在可接受范围内,把所有合法解都找出来
剪枝只是 让死路少走一点 ,但永远改变不了穷举本质。
哪些问题一看就是回溯?
可以直接记这个"回溯问题清单"👇
1️⃣ 组合问题(不强调顺序)
-
从 N 个数中选 k 个
-
例如:
[1,2,3]选 2 个 -
{1,2}和{2,1}是 同一个解
2️⃣ 切割问题
-
字符串按规则切割
-
如:分割回文串、IP 地址复原
3️⃣ 子集问题
-
一个集合的所有可能子集
-
是否满足某些条件
4️⃣ 排列问题(强调顺序)
-
{1,2}和{2,1}是 两个解 -
通常需要
used[]数组
5️⃣ 棋盘问题
-
N 皇后
-
数独
-
本质:在二维/多维空间里试探
👉 一句话判断法
"要列出所有可能解 / 所有方案" → 99% 是回溯
回溯算法 = 一棵树(这是理解的关键)
所有回溯问题都可以抽象成树形结构
树结构对应关系
| 回溯概念 | 树的含义 |
|---|---|
| 集合大小 | 树的宽度 |
| 递归深度 | 树的深度 |
| 一次选择 | 一个节点 |
| 一个解 | 一条从根到叶子的路径 |
所以:
-
回溯 = 遍历一棵 N 叉树
-
找到叶子节点 = 找到一个合法解
回溯三部曲(比模板更重要)
你可以忘记模板,但不能忘记这三件事:
① 回溯函数参数(状态)
回溯函数的参数,本质是:
描述"当前走到哪一步了"
常见参数包括:
-
startIndex(组合 / 子集) -
path(当前选择路径) -
used[](排列) -
剩余值、目标值等
② 终止条件(叶子节点)
终止条件 = 什么时候算一个完整解
if (满足条件) {
保存结果;
return;
}
-
深度够了
-
长度满足
-
目标值达成
-
棋盘填满
③ 单层搜索逻辑(for + 递归 + 回溯)
这是回溯的灵魂:
for (本层可选元素) {
做选择;
backtracking(...);
撤销选择;
}
理解为一句话:
横向遍历选择,纵向递归深入
通用回溯模板(你需要"肌肉记忆")
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中的元素) {
处理节点; // 做选择
backtracking(...); // 递归
回溯; // 撤销选择
}
}
⚠️ 注意:
-
for 循环 ≠ 递归
-
for 是"选谁"
-
递归是"走多深"
77. 组合
整体思路讲解
这道题是:
从 1 到 n 中,选出 k 个数的所有组合
1️⃣ 为什么这是回溯问题?
因为题目要求的是:
-
列出所有可能的组合
-
而不是求最大值 / 最小值 / 个数
👉 "所有可能方案" = 典型回溯
2️⃣ 把问题抽象成一棵树
我们把 1 ~ n 看成一个集合,每次从中选一个数:
-
树的深度 :已经选了多少个数(
path.size()) -
树的宽度:当前还能选哪些数
举个例子:n = 4, k = 2
[]
/ | | \
[1] [2] [3] [4]
/ | \ | \ \
[1,2][1,3][1,4][2,3][2,4][3,4]
-
每一条 从根到叶子的路径
-
且路径长度为
k -
就是一个合法答案
3️⃣ 回溯三部曲(对应本题)
✅ ① 回溯函数的参数(状态)
我们需要知道三件事:
-
n:上限是多少 -
k:要选几个 -
startIndex:下一次从哪里开始选(避免重复)
✅ ② 终止条件(什么时候收集答案)
path.size() == k
说明:
-
已经选够
k个数 -
当前路径就是一个合法组合
-
存起来即可,不用再往下选
✅ ③ 单层搜索逻辑(for + 递归 + 回溯)
在当前这一层:
-
从
startIndex开始遍历 -
每选一个数:
-
加入
path -
递归继续选下一个
-
回溯时撤销选择
-
cpp
class Solution {
public:
vector<vector<int>>result;
vector<int>path;
void backtracking(int n , int k,int startIndex){
if(path.size()==k){
result.push_back(path);
}
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) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
};
优化后整体代码如下:
-
已经选择的元素个数:path.size();
-
所需需要的元素个数为: k - path.size();
-
列表中剩余元素(n-i) >= 所需需要的元素个数(k - path.size())
-
在集合n中至多要从该起始位置 : i <= n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。
cpp
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
整体思路(先不看代码)
题目要求的是:
从 1~9 中选出 k 个数,使它们的和等于 n,每个数字只能用一次
和你之前写的 combine 相比,多了 两个强约束:
-
数字范围固定:
1 ~ 9 -
路径不仅要长度为
k,而且和必须等于n
👉 本质仍然是:
在有限集合中,找出满足条件的所有组合
→ 标准回溯问题
把问题抽象成"树"
1️⃣ 树的含义
-
树的深度 :当前已经选了几个数(
path.size()) -
树的宽度 :当前还能选哪些数(
startIndex ~ 9) -
一条合法路径:
-
长度 =
k -
路径和 =
n
-
2️⃣ 搜索目标
我们要找的是:
cpp
path.size() == k 且 sum == n
而不是只满足其中一个。
回溯三部曲(这题非常标准)
✅ ① 回溯函数参数(状态设计)
cpp
void backtracking(int k, int n, int sum, int startIndex)
每个参数的意义非常明确:
| 参数 | 含义 |
|---|---|
k |
需要选几个数 |
n |
目标和 |
sum |
当前路径的和 |
startIndex |
下一次选择的起点(防止重复) |
👉 sum 是这道题新增的关键状态
✅ ② 终止条件(什么时候停)
(1)和已经超了,直接剪掉
这是非常重要的剪枝:
-
后面只会选更大的数
-
不可能再回到
n -
立刻返回,避免无效搜索
(2)已经选了 k 个数
逻辑含义:
-
数量已经固定,不能再选了
-
只有当
sum == n才是合法解 -
不管成不成立,都要
return,否则会越界选
⚠️ 这一步是**"数量约束优先于数值约束"**的体现
✅ ③ 单层搜索逻辑(for + 递归 + 回溯)
剪枝后的 for 循环
这是本题的灵魂剪枝,非常重要。
cpp
class Solution {
public:
vector<vector<int>>result;
vector<int>path;
void backtracking(int k,int n,int sum, int startIndex){
if (sum > n) return;
if (path.size() == k) {
if (sum == n) {
result.push_back(path);
}
return;
}
// 剪枝:i 最大只能到 9 - (k - path.size()) + 1
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
sum += i;
path.push_back(i);
backtracking(k, n, sum, i + 1); // 注意这里是 i + 1
path.pop_back();
sum -= i;
}
}
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(k, n, 0, 1);
return result;
}
};
17. 电话号码的字母组合
整体思路(先不看代码)
题目要求的是:
给定一个数字字符串(2--9),返回它能表示的所有字母组合
例如:"23"
cpp
2 -> abc
3 -> def
结果:
ad ae af
bd be bf
cd ce cf
为什么这是回溯问题?
因为题目要求的是:
-
列出所有可能的组合
-
每一位数字,都有多个可选字母
-
每一位的选择都会影响后面的选择
👉 这正是:
在多个"选择集合"中,每层选一个,枚举所有路径
这类问题的特征非常明显:
-
有 层次感
-
每一层都要做选择
-
要遍历所有可能路径
把问题抽象成一棵树(关键理解)
假设 digits = "23":
cpp
""
/ | \
a b c ← 第 0 层(数字 2)
/ | \ / | \ / | \
d e f d e f d e f ← 第 1 层(数字 3)
对应关系非常清晰:
| 回溯概念 | 本题含义 |
|---|---|
| 树的深度 | digits 的长度 |
| 每一层 | 处理一个数字 |
| 每个节点 | 选择一个字母 |
| 一条路径 | 一个字母组合 |
回溯三部曲(对应本题)
✅ ① 回溯函数参数(状态)
cpp
void backtracking(const string& digits, int index)
为什么只需要 index?
-
index表示 当前处理 digits 的第几位 -
当前路径已经通过
s保存了 -
不需要
startIndex,因为:-
每一层只能选"当前数字"的字母
-
不存在"回头选"的问题
-
👉 这是和组合题最大的区别
✅ ② 终止条件(叶子节点)
cpp
if (index == digits.size()) {
result.push_back(s);
return;
}
含义:
-
所有数字都已经处理完
-
当前
s的长度一定等于digits.size() -
s是一条完整路径(一个解)
✅ ③ 单层搜索逻辑
对于当前数字 digits[index]:
-
找到它对应的字母集合
-
遍历每一个字母
-
依次加入路径,递归处理下一个数字
cpp
class Solution {
public:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
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;
}
};
39. 组合总和
整体思路(不看代码也能懂)
题目要求的是:
给定一个无重复元素的数组
candidates,每个元素可以无限次使用 ,找出所有和为target的组合。
关键点有三个:
-
要求所有组合 → 回溯
-
是组合,不是排列 → 不能出现
[2,3]和[3,2] -
元素可以重复使用 → 递归时
startIndex不前移
把问题抽象成一棵树
以 candidates = [2,3,6,7],target = 7 为例:
cpp
[]
/ | | \
[2] [3] [6] [7]
/ | \ | |
[2,2][2,3][2,6] [3,3] [6,?]
|
[2,2,3]
|
[2,2,3] → sum = 7 ✓
树结构理解
| 回溯概念 | 本题含义 |
|---|---|
| 树的深度 | 当前组合长度(不固定) |
| 树的宽度 | candidates 中可选的数 |
| 一条路径 | 一个候选组合 |
| 叶子节点 | sum == target |
回溯三部曲(对应本题)
✅ ① 回溯函数参数(状态)
cpp
void backtracking(vector<int>& candidates,
int target,
int sum,
int startIndex)
每个参数都很关键:
| 参数 | 含义 |
|---|---|
candidates |
候选数组 |
target |
目标和 |
sum |
当前路径和 |
startIndex |
本轮搜索起点(控制组合顺序) |
👉 startIndex 决定"组合"而不是"排列"
✅ ② 终止条件(什么时候收集结果)
cpp
if (sum == target) {
result.push_back(path);
}
含义:
-
当前路径的和刚好等于
target -
这是一个合法组合
-
直接保存
⚠️ 注意:
这里没有写 return,是因为:
-
后面的 for 循环已经通过条件
sum + candidates[i] <= target保证不会继续向下扩展
-
即使不 return,也不会产生非法解
(不过在逻辑表达上,加 return 会更清晰)
✅ ③ 单层搜索逻辑(for + 递归 + 回溯)
cpp
for (int i = startIndex;
i < candidates.size() && sum + candidates[i] <= target;
i++)
这是排序 + 剪枝的核心体现:
-
candidates已经排序 -
一旦
sum + candidates[i] > target -
后面的数只会更大,直接剪掉
为什么这里递归用的是 i 而不是 i+1?(重点)
backtracking(candidates, target, sum, i);
这是这道题和你前面做的题最本质的区别。
原因只有一句话:
同一个数字可以被重复选取
-
如果写
i + 1→ 每个数只能用一次(会变成 combinationSum2) -
写
i→ 下一层仍然可以选当前这个数
👉 这一步直接决定了"可重复使用"
cpp
class Solution {
public:
vector<vector<int>>result;
vector<int>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() && sum + candidates[i] <= target; i++){
sum+=candidates[i];
path.push_back(candidates[i]);
backtracking(candidates,target,sum,i);
sum-=candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
sort(candidates.begin(), candidates.end());
backtracking(candidates,target,0,0);
return result;
}
};
40. 组合总和 II
组合总和II和一开始可以重复的区别就在于不可重复,所以只需要改一下递归条件,把递归传递的I改为i+1即可
cpp
class Solution {
public:
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);
}
for(int i = startIndex;i<candidates.size() && sum + candidates[i] <= target;i++){
if (i > startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates,target,sum,i+1);
path.pop_back();
sum-=candidates[i];
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
result.clear();
path.clear();
sort(candidates.begin(),candidates.end());
backtracking(candidates,target,0,0);
return result;
}
};
131. 分割回文串
整体思路(先不看代码)
题目要求的是:
给定一个字符串
s,把它切割成若干子串,使得每一个子串都是回文串,返回所有可能的切割方案。
关键词只有两个:
-
切割
-
所有方案
👉 一看到"切割 + 所有方案",就要条件反射想到:
回溯 + 树形结构
把问题抽象成一棵树(这是理解的关键)
以 s = "aab" 为例:
cpp
start=0
|
├── "a" → start=1
| ├── "a" → start=2
| | └── "b" → start=3 ✓
| └── "ab" ✗
|
└── "aa" → start=2
└── "b" → start=3 ✓
树结构映射关系
| 回溯概念 | 本题含义 |
|---|---|
| 树的深度 | 已切割的段数 |
| 每一层 | 从某个 startIndex 开始尝试切 |
| 每个节点 | 一个子串 |
| 一条路径 | 一种切割方案 |
👉 走到字符串末尾(startIndex == s.size)
👉 说明刚好完成一次完整切割
这道题的核心难点在哪里?
不是回溯本身,而是这一句判断:
当前子串是不是回文?
如果你在回溯中每次都临时判断回文:
-
时间复杂度会非常高
-
整体会退化成三重循环
所以你的做法是非常标准、非常优秀的工程解法:
先用 DP 预处理所有回文信息,再在回溯中 O(1) 查询
整体解法拆分(两步走)
✅ 第一步:预处理回文子串(DP)
构建一个二维数组:
cpp
isPalindrome[i][j]
表示:
s[i..j](双闭区间)是不是回文串
✅ 第二步:回溯切割字符串
-
从
startIndex开始 -
枚举所有可能的切割终点
i -
只要
s[startIndex..i]是回文,就可以切 -
然后递归处理后半段
五、代码逐段讲解
① 回文 DP 预处理部分
cpp
vector<vector<bool>> isPalindrome;
这是整道题性能的核心
初始化并填表
cpp
isPalindrome.resize(s.size(), vector<bool>(s.size(), false));
根据字符串长度,建立 n × n 的 DP 表。
填表顺序(非常关键)
cpp
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i; j < s.size(); j++) {
为什么 i 要 从后往前?
因为状态转移用到了:
isPalindrome[i+1][j-1]
👉 必须先算"更短的子串"
状态转移逻辑
cpp
if (i == j)
isPalindrome[i][j] = true;
else if (j - i == 1)
isPalindrome[i][j] = (s[i] == s[j]);
else
isPalindrome[i][j] = (s[i] == s[j] && isPalindrome[i+1][j-1]);
对应三种情况:
1️⃣ 单个字符 → 一定是回文
2️⃣ 两个字符 → 相等就是回文
3️⃣ 多个字符 → 首尾相等 + 中间是回文
② 回溯函数设计
cpp
void backtracking(string& s, int startIndex)
参数含义
startIndex:下一刀从哪里开始切
👉 这是切割类回溯的"灵魂参数"
终止条件(完成一次切割)
cpp
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
含义:
-
字符串已经被完整切完
-
当前
path就是一种合法方案
单层搜索逻辑(枚举切割位置)
cpp
for (int i = startIndex; i < s.size(); i++) {
表示:
-
尝试把第一刀切在:
-
[startIndex..startIndex] -
[startIndex..startIndex+1] -
...
-
[startIndex..s.size()-1]
-
回文判断(剪枝)
cpp
if (isPalindrome[startIndex][i]) {
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else {
continue;
}
👉 不是回文,直接剪掉这一整条分支
这是回溯效率的关键。
递归 + 回溯
backtracking(s, i + 1); path.pop_back();
-
递归处理剩余字符串
-
回溯撤销本次切割
③ 主函数入口
computePalindrome(s); backtracking(s, 0);
逻辑非常清晰:
-
先预处理回文
-
再启动回溯切割
cpp
class Solution {
public:
vector<vector<string>>result;
vector<string>path;
vector<vector<bool>>isPalindrome;
void computePalindrome(const string& s) {
// isPalindrome[i][j] 代表 s[i:j](双边包括)是否是回文字串
isPalindrome.resize(s.size(), vector<bool>(s.size(), false)); // 根据字符串s, 刷新布尔矩阵的大小
for(int i = s.size()-1;i>=0;i--){
for(int j = i; j<s.size();j++){
if(i==j){isPalindrome[i][j]=true;}
else if(j-i==1){isPalindrome[i][j]=(s[i]==s[j]);}
else{
isPalindrome[i][j]=(s[i] == s[j] && isPalindrome[i+1][j-1]);
}
}
}
}
void backtracking(string& s,int startIndex){
if(startIndex>=s.size()){
result.push_back(path);
return;
}
for(int i = startIndex;i<s.size();i++){
if(isPalindrome[startIndex][i]){
string str = s.substr(startIndex,i-startIndex+1);
path.push_back(str);
}else{
continue;
}
backtracking(s,i+1);
path.pop_back();
}
}
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
computePalindrome(s);
backtracking(s, 0);
return result;
}
};