回溯算法深度解析:从本质到实战,攻克LeetCode组合与切割问题
在算法面试和日常开发中,回溯算法是解决“所有可能方案”类问题的利器。它看似简单,却蕴含着深度优先搜索(DFS)的穷举智慧。本文将带你深入回溯算法的核心,通过经典LeetCode例题,彻底掌握其思想、模板与优化技巧,助你在解决组合、切割、子集等问题时游刃有余。
一、回溯算法:本质、效率与核心思想
回溯算法并非一种独立的新算法,其本质是一种深度优先搜索(DFS)的穷举策略。它的工作流程可以概括为:做出一个选择,然后沿着这个选择深入探索;如果发现此路不通(不满足条件),则退回(回溯)到上一个决策点,撤销刚才的选择,尝试另一个选项。这个过程会系统地遍历所有可能的路径,直到找到所有解或穷尽所有可能。
为什么回溯总是与递归绑定在一起?
原因非常直观:递归天然地利用了函数调用栈来保存状态。回溯 = 递归 + 试探 + 撤销选择
当我们从递归调用返回时,程序会自动回到上一层函数的状态,这完美地实现了“回溯”到上一步的需求。如果不用递归,开发者就需要手动维护一个栈来记录状态和选择路径,代码会变得复杂且容易出错。回溯是递归的副产品,只要有递归就会有回溯
关于回溯算法的效率,必须建立一个清晰的认知:回溯 ≠ 高效算法。它的本质是穷举,时间复杂度通常很高。例如:
- 组合/子集问题:时间复杂度为
O(2^n) - 排列问题:时间复杂度为
O(n!) - 棋盘类问题(如N皇后):通常是指数级甚至更高。
因此,解决回溯题的核心目标往往不是追求极致的速度,而是:“正确地、完整地找出所有合法解”。
剪枝技巧可以让程序少走一些明显的“死路”,但无法改变其穷举的本质。 [AFFILIATE_SLOT_1]在可接受范围内,把所有合法解都找出来
二、识别回溯问题与抽象为树模型
哪些问题一看就应该用回溯法解决?记住下面这个“回溯问题清单”能帮你快速判断:
- 组合问题:从N个元素中选出K个,不强调顺序。例如从[1,2,3]中选2个,[1,2]和[2,1]被视为同一个解。
- 切割问题:将一个字符串按特定规则进行分割,如“分割回文串”、“复原IP地址”。
- 子集问题:找出一个集合的所有可能子集,或判断子集是否满足某些条件。
- 排列问题:对N个元素进行全排列,强调顺序。[1,2,3]和[3,2,1]是两个不同的解,通常需要`used`数组记录使用状态。
- 棋盘问题:如N皇后、数独,本质是在二维或多维空间中进行试探性填充。
一个快速的判断法是:“当你需要找出所有满足条件的方案,并且每一步都有多个选择,需要尝试不同可能性时,很可能就是回溯问题。”
“要列出所有可能解 / 所有方案” → 99% 是回溯
理解回溯算法的关键是将问题抽象成一棵树。
这棵树通常是一棵N叉树:所有回溯问题都可以抽象成树形结构
- 树的深度:代表递归的深度,即已经做出了多少次选择。
- 树的宽度:代表在当前层(当前状态下)可以做的所有选择。
- 根节点到叶子节点的路径:代表一个完整的候选解。
回溯的过程,就是深度优先遍历这棵决策树,在遍历过程中通过条件判断进行剪枝,并收集所有到达叶子节点(满足终止条件)的路径。
| 回溯概念 | 树的含义 |
|---|---|
| 集合大小 | 树的宽度 |
| 递归深度 | 树的深度 |
| 一次选择 | 一个节点 |
| 一个解 | 一条从根到叶子的路径 |
三、回溯三部曲:比记忆模板更重要的思维框架
掌握回溯,与其死记硬背模板,不如深刻理解其三个核心组成部分,我们称之为“回溯三部曲”:
1. 回溯函数参数(定义状态)
回溯函数的参数,本质是描述当前搜索状态的所有信息。
常见的参数包括:描述“当前走到哪一步了”
- `startIndex` (或 `begin`):用于组合/子集问题,控制下一层搜索的起始位置,避免重复。
- `path` (或 `track`):一个列表,记录当前已经做出的选择(即从根到当前节点的路径)。
- `used` 数组:用于排列问题,标记哪些元素已经被使用过。
- 其他状态:如当前和 `currentSum`、目标值 `target`、剩余深度等。
2. 终止条件(到达叶子节点)
终止条件定义了什么时候一条路径构成了一个完整的、需要被记录的解决方案。
if (满足条件) {
保存结果;
return;
} 常见的终止条件有:路径长度达到k、元素之和等于target、已经处理完所有数字(`startIndex`到达数组末尾)、棋盘被填满等。3. 单层搜索逻辑(For循环 + 递归 + 回溯)
这是回溯算法的灵魂所在,它描述了在树的每一层如何进行探索。
for (本层可选元素) {
做选择;
backtracking(...);
撤销选择;
} 这个过程可以概括为一句话:“对于当前层的每一个可选选项,尝试选择它,然后深入下一层探索,探索完毕后再撤销这个选择,以尝试下一个选项。”横向遍历选择,纵向递归深入
基于这三部曲,我们可以得到一个几乎通用的回溯模板,理解后应形成肌肉记忆:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中的元素) {
处理节点; // 做选择
backtracking(...); // 递归
回溯; // 撤销选择
}
} 这里需要特别注意:`for`循环控制的是树的宽度(横向遍历),递归调用控制的是树的深度(纵向深入)。四、经典例题实战:组合问题精讲
让我们通过几道LeetCode经典题目,将“三部曲”思维付诸实践。
1. LeetCode 77. 组合
这道题是回溯算法的入门经典:“给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。”
从 1 到 n 中,选出 k 个数的所有组合
思路解析:将数字1到n视为一个集合,每次从中选取一个数。树的深度由已选数字个数 `path.size()` 决定,宽度由当前还能选的数字(从 `startIndex` 到 `n`)决定。以 `n=4, k=2` 为例:
[]
/ | | \
[1] [2] [3] [4]
/ | \ | \ \
[1,2][1,3][1,4][2,3][2,4][3,4] 每一条从根到叶子、长度为 `k` 的路径就是一个合法答案。回溯三部曲应用:
✅ 参数:需要 `n`(上限), `k`(目标长度), `startIndex`(下一层起点)。
✅ 终止条件:当 `path.size() == k` 时,收集结果。
path.size() == k✅ 单层搜索:从 `startIndex` 遍历到 `n`,选择数字,递归,回溯。
class Solution {
public:
vector>result;
vectorpath;
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> combine(int n, int k) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
}; 我们还可以进行剪枝优化:如果剩余可选的数字个数已经不足以凑齐k个,则无需继续。优化后代码:
class Solution {
private:
vector> result;
vector 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> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
}; 2. LeetCode 216. 组合总和 III
此题在组合基础上增加了数值约束:“找出所有相加之和为 n 的 k 个数的组合,只使用数字1-9,且每个数字最多使用一次。”
从 1~9 中选出 k 个数,使它们的和等于 n,每个数字只能用一次
思路解析:集合固定为[1,9],需要同时满足长度=k与和=n两个条件。树的深度是已选数字个数,宽度是当前可选的数字。
回溯三部曲应用:
✅ 参数设计:除了 `k`, `n`, `startIndex`,关键新增 `currentSum` 记录当前路径和。
void backtracking(int k, int n, int sum, int startIndex) | 参数 | 含义 |
|---|---|
| 需要选几个数 | |
| 目标和 | |
| 当前路径的和 | |
| 下一次选择的起点(防止重复) |
✅ 终止与剪枝:1)若 `currentSum > n`,直接剪枝返回。2)当 `path.size() == k` 时,只有 `currentSum == n` 才收集结果。
path.size() == k 且 sum == n✅ 单层搜索与剪枝:在for循环中也可根据和进行剪枝。
class Solution {
public:
vector>result;
vectorpath;
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> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(k, n, 0, 1);
return result;
}
};
[AFFILIATE_SLOT_2]
五、拓展应用:电话号码、组合总和与字符串切割
1. LeetCode 17. 电话号码的字母组合
题目要求:“给定一个仅包含数字 2-9 的字符串,返回它能表示的所有字母组合。”
例如输入“23”,输出 `["ad","ae","af","bd","be","bf","cd","ce","cf"]`。给定一个数字字符串(2–9),返回它能表示的所有字母组合
2 -> abc
3 -> def
结果:
ad ae af
bd be bf
cd ce cf这仍然是回溯问题,因为“每一位数字对应多个字母,需要遍历所有可能的映射组合”。
抽象成树:树的深度是输入数字的位数,每一层的宽度是对应数字的字母个数。在多个“选择集合”中,每层选一个,枚举所有路径
""
/ | \
a b c ← 第 0 层(数字 2)
/ | \ / | \ / | \
d e f d e f d e f ← 第 1 层(数字 3) | 回溯概念 | 本题含义 |
|---|---|
| 树的深度 | 的长度 |
| 每一层 | 处理一个数字 |
| 每个节点 | 选择一个字母 |
| 一条路径 | 一个字母组合 |
回溯三部曲应用:
✅ 参数:通常只需要 `digits` 和当前处理到的索引 `index`。`path` 记录当前组合。
void backtracking(const string& digits, int index)✅ 终止条件:`index == digits.length()`,即所有数字已处理完。
if (index == digits.size()) {
result.push_back(s);
return;
}✅ 单层搜索:取当前数字对应的字母串,遍历每个字母,加入路径并递归。
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
};
vectorresult;
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 letterCombinations(string digits) {
s.clear();
result.clear();
if (digits.size() == 0) {
return result;
}
backtracking(digits, 0);
return result;
}
}; 2. LeetCode 39. 组合总和 & 40. 组合总和 II
LeetCode 39要求:“给定一个无重复元素的数组 candidates 和一个目标数 target,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。”
给定一个无重复元素的数组 ,每个元素可以无限次使用,找出所有和为 的组合。
关键点:可重复使用元素。抽象成树后,同一数字可以在下一层再次被选择。
[]
/ | | \
[2] [3] [6] [7]
/ | \ | |
[2,2][2,3][2,6] [3,3] [6,?]
|
[2,2,3]
|
[2,2,3] → sum = 7 ✓ | 回溯概念 | 本题含义 |
|---|---|
| 树的深度 | 当前组合长度(不固定) |
| 树的宽度 | 中可选的数 |
| 一条路径 | 一个候选组合 |
| 叶子节点 |
回溯三部曲应用:
✅ 参数:`candidates`, `target`, `startIndex`, `currentSum`。
void backtracking(vector& candidates,
int target,
int sum,
int startIndex) | 参数 | 含义 |
|---|---|
| 候选数组 | |
| 目标和 | |
| 当前路径和 | |
| 本轮搜索起点(控制组合顺序) |
✅ 终止条件:`currentSum == target`。
if (sum == target) {
result.push_back(path);
}✅ 单层搜索与核心区别:为了可重复选取,递归时 `startIndex` 传递 `i` 而非 `i+1`。
for (int i = startIndex;
i < candidates.size() && sum + candidates[i] <= target;
i++) “可重复使用的关键,就是递归时 startIndex 不增加(传i),让下一层还能选到当前这个数。”完整代码:同一个数字可以被重复选取
class Solution {
public:
vector>result;
vectorpath;
void backtracking(vector& 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> combinationSum(vector& candidates, int target) {
result.clear();
path.clear();
sort(candidates.begin(), candidates.end());
backtracking(candidates,target,0,0);
return result;
}
}; 而LeetCode 40. 组合总和 II要求每个数字只能用一次,且数组可能包含重复元素。核心区别在于:1)递归时 `startIndex` 传 `i+1`;2)需要对数组排序,并在同一层遍历中进行去重(`i > startIndex && candidates[i] == candidates[i-1]` 时跳过)。代码示意:
class Solution {
public:
vector>result;
vectorpath;
void backtracking(vector& candidates, int target,int sum ,int startIndex){
if(sum>target)return;
if(sum==target){
result.push_back(path);
}
for(int i = startIndex;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> combinationSum2(vector& candidates, int target) {
result.clear();
path.clear();
sort(candidates.begin(),candidates.end());
backtracking(candidates,target,0,0);
return result;
}
}; 六、复杂案例:分割回文串中的回溯与预处理
LeetCode 131. 分割回文串是一个综合性的回溯问题,结合了字符串切割与动态规划。题目要求:“给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。”
给定一个字符串 ,把它切割成若干子串,使得每一个子串都是回文串,返回所有可能的切割方案。
看到“切割”和“所有方案”,应立刻想到回溯。
抽象成树:以 `s = "aab"` 为例,第一层决定第一刀切在哪。回溯 + 树形结构
start=0
|
├── "a" → start=1
| ├── "a" → start=2
| | └── "b" → start=3 ✓
| └── "ab" ✗
|
└── "aa" → start=2
└── "b" → start=3 ✓ | 回溯概念 | 本题含义 |
|---|---|
| 树的深度 | 已切割的段数 |
| 每一层 | 从某个 开始尝试切 |
| 每个节点 | 一个子串 |
| 一条路径 | 一种切割方案 |
难点与优化:核心难点在于高效判断子串是否为回文。如果在回溯中临时用双指针判断,会导致高时间复杂度。优秀的解法是“先用动态规划(DP)预处理出所有子串的回文状态,再回溯切割”。
先用 DP 预处理所有回文信息,再在回溯中 O(1) 查询
解法拆分:
✅ 第一步:DP预处理。构建二维布尔数组 `dp[i][j]`,表示 `s[i..j]` 是否为回文。
isPalindrome[i][j] “dp[i][j] = true 表示子串 s[i..j] 是回文串。”填表代码:(双闭区间)是不是回文串
vector> isPalindrome; isPalindrome.resize(s.size(), vector(s.size(), false)); 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]);✅ 第二步:回溯切割。参数 `startIndex` 表示切割起点。
void backtracking(string& s, int startIndex) 终止条件是 `startIndex == s.length()`。if (startIndex >= s.size()) {
result.push_back(path);
return;
} 单层搜索中,枚举切割终点 `i`,如果 `dp[startIndex][i]` 为真则切割,并递归处理剩余部分。 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;
}✅ 主函数:先预处理DP表,再启动回溯。
class Solution {
public:
vector>result;
vectorpath;
vector>isPalindrome;
void computePalindrome(const string& s) {
// isPalindrome[i][j] 代表 s[i:j](双边包括)是否是回文字串
isPalindrome.resize(s.size(), vector(s.size(), false)); // 根据字符串s, 刷新布尔矩阵的大小
for(int i = s.size()-1;i>=0;i--){
for(int j = i; j=s.size()){
result.push_back(path);
return;
}
for(int i = startIndex;i> partition(string s) {
result.clear();
path.clear();
computePalindrome(s);
backtracking(s, 0);
return result;
}
}; 总结
回溯算法是解决“所有可能方案”问题的系统性方法。其核心在于将问题抽象为决策树,并通过“选择-递归-撤销”的深度优先遍历来穷举所有路径。掌握“回溯三部曲”——明确状态参数、定义终止条件、实现单层搜索逻辑——是理解并实现回溯代码的关键。从基础的组合问题(77,216),到映射问题(17),再到可重复/不可重复的组合总和(39,40),乃至需要预处理的字符串切割问题(131),其底层框架一脉相承。记住,回溯的目标是正确且完整地找出所有解,合理运用剪枝可以提升效率,但无法改变其穷举的本质。通过大量练习,将这种“树形遍历”的思维内化,你就能在面对各类需要枚举的场景时,迅速找到清晰的解题路径。
knsumstartIndexdigitscandidatestargetcandidatessum == targetcandidatestargetsumstartIndexsstartIndexs[i..j]
浙公网安备 33010602011771号