常见算法-回溯
引言
回溯算法(Backtracking)是一种通过探索所有可能的候选解来找出所有解的算法。当探索到某一步时,发现原先的选择并不优或达不到目标,就退回一步重新选择。这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为"回溯点"。
回溯算法的本质:穷举所有可能,然后选出我们想要的答案。
适用场景:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,数独等等
什么是回溯算法
形象化理解
想象你在一个迷宫中寻找出口:
1 | 入口 → 岔路1 → 岔路2 → 死路 ✗ |
这就是回溯:尝试 → 失败 → 回退 → 再尝试
决策树可视化
回溯算法可以抽象为决策树的遍历过程:
graph TD
A[开始] --> B[选择1]
A --> C[选择2]
A --> D[选择3]
B --> E[选择1.1]
B --> F[选择1.2]
C --> G[选择2.1]
C --> H[选择2.2]
style E fill:#90EE90
style H fill:#90EE90
style F fill:#FFB6C1
style G fill:#FFB6C1
- 绿色节点:满足条件的解
- 粉色节点:不满足条件,需要回溯
回溯框架模板
通用模板
1 | void backtrack(路径, 选择列表) { |
三要素
- 路径(Path):已经做出的选择
- 选择列表(Choices):当前可以做的选择
- 结束条件(End Condition):到达决策树底层,无法再做选择的条件
graph LR
A[开始] --> B{满足结束条件?}
B -->|是| C[保存结果]
B -->|否| D[遍历选择列表]
D --> E{是否有效?}
E -->|否| F[剪枝跳过]
E -->|是| G[做选择]
G --> H[递归调用]
H --> I[撤销选择]
I --> D
F --> D
C --> J[返回]
I --> K{还有选择?}
K -->|是| D
K -->|否| J
核心思想
回溯三步曲
- 做选择:将当前元素加入路径
- 递归:基于当前选择继续探索
- 撤销选择:将当前元素从路径中移除,尝试其他选择
1 | // 伪代码示例 |
关键特性
- 时间换空间:通过回溯避免存储所有中间状态
- 深度优先搜索(DFS):本质上是DFS的一种实现
- 剪枝优化:提前排除不可能的分支,提高效率
经典问题详解
1. 全排列问题
LeetCode 46: 给定一个不含重复数字的数组
nums,返回其所有可能的全排列。
示例:
1 | 输入:nums = [1,2,3] |
决策树
1 | [] |
实现
1 | void permuteBacktrack(vector<int>& nums, vector<int>& path, |
执行过程示例
1 | 输入: [1, 2, 3] |
时间复杂度:O(n × n!)
空间复杂度:O(n)
2. N皇后问题
LeetCode 51: 在 n×n 的棋盘上放置 n 个皇后,使得它们不能相互攻击。
规则:皇后可以攻击同一行、同一列、同一对角线上的棋子。
可视化
1 | 4皇后的一个解: |
决策树
graph TD
A[行0: 选择列] --> B[列0]
A --> C[列1]
A --> D[列2]
A --> E[列3]
C --> F[行1: 列3]
F --> G[行2: 列1]
G --> H[行3: 列2 ✓]
B --> I[行1: 检查...✗]
D --> J[行1: 检查...✗]
实现
1 | void nQueensBacktrack(vector<string>& board, int row, |
时间复杂度:O(n!)
空间复杂度:O(n²)
3. 组合总和
LeetCode 39: 给定一个无重复元素的数组
candidates和一个目标数target,找出所有可以使数字和为target的组合。
示例:
1 | 输入:candidates = [2,3,6,7], target = 7 |
决策树
1 | [] |
实现
1 | void combinationBacktrack(vector<int>& candidates, int target, int start, |
关键点:
- 可以重复使用同一元素,所以递归时传入
i而不是i+1 - 排序后可以提前剪枝
4. 子集问题
LeetCode 78: 给定一个整数数组
nums,返回该数组所有可能的子集。
示例:
1 | 输入:nums = [1,2,3] |
决策树
1 | []✓ |
关键:每个节点都是一个有效子集!
实现
1 | void subsetsBacktrack(vector<int>& nums, int start, |
时间复杂度:O(n × 2ⁿ)
空间复杂度:O(n)
5. 括号生成
LeetCode 22: 生成所有由 n 对括号组成的有效组合。
示例:
1 | 输入:n = 3 |
约束条件
- 左括号数量 ≤ n
- 右括号数量 ≤ 左括号数量
决策树
1 | "" |
实现
1 | void parenthesisBacktrack(int n, int left, int right, |
时间复杂度:O(4ⁿ / √n)(卡特兰数)
空间复杂度:O(n)
剪枝优化技巧
剪枝是提高回溯算法效率的关键!
1. 提前终止
1 | if (当前状态已经不可能产生有效解) { |
示例:组合总和中,如果当前和已经超过目标值
1 | if (candidates[i] > target) break; // 剪枝 |
2. 排序优化
先排序,可以更早地剪枝
1 | sort(candidates.begin(), candidates.end()); |
3. 避免重复
使用 used 数组或 start 索引避免重复计算
1 | // 全排列:used数组 |
4. 对称性剪枝
利用问题的对称性减少搜索空间
1 | // N皇后:只搜索一半,另一半通过对称获得 |
剪枝效果对比
graph LR
A[无剪枝] -->|搜索空间| B[10000节点]
C[有剪枝] -->|搜索空间| D[100节点]
style B fill:#FFB6C1
style D fill:#90EE90
复杂度分析
时间复杂度
回溯算法的时间复杂度通常取决于:
- 决策树的深度:递归的层数
- 每层的选择数量:for循环的次数
| 问题 | 时间复杂度 | 说明 |
|---|---|---|
| 全排列 | O(n × n!) | n层,每层最多n个选择 |
| N皇后 | O(n!) | 每行选择递减 |
| 组合 | O(2ⁿ) | 每个元素选或不选 |
| 子集 | O(n × 2ⁿ) | 2ⁿ个子集,复制需要O(n) |
空间复杂度
主要考虑:
- 递归栈深度:O(深度)
- 路径存储:O(路径长度)
通常为 O(n) 或 O(树的高度)
常见LeetCode题目
入门级
中级
高级
回溯 vs DFS vs BFS
对比表
| 特性 | 回溯 | DFS | BFS |
|---|---|---|---|
| 本质 | 带剪枝的DFS | 深度优先搜索 | 广度优先搜索 |
| 数据结构 | 递归栈 | 栈/递归 | 队列 |
| 路径记录 | 显式维护 | 可选 | 可选 |
| 空间复杂度 | O(h) | O(h) | O(w) |
| 应用场景 | 所有解 | 单个解 | 最短路径 |
h = 树的高度, w = 树的宽度
总结
核心要点
- 回溯 = 穷举 + 剪枝
- 三要素:路径、选择列表、结束条件
- 三步曲:做选择 → 递归 → 撤销选择
- 优化关键:剪枝!剪枝!剪枝!
经典模板
1 | void backtrack(参数) { |
性能优化 Tips
- 排序预处理:便于剪枝
- 使用引用传参:减少复制开销
- 位运算优化 used 数组:节省空间
- 记忆化搜索:避免重复计算子问题
回溯算法是解决复杂搜索问题的利器。虽然时间复杂度较高,但通过巧妙的剪枝优化,可以在实际应用中取得很好的效果。掌握回溯算法,你将拥有解决大部分组合、排列、搜索类问题的能力!
推荐阅读:

