回溯算法总结
回溯算法总结:核心是画出递归树,一文搞定所有题型!
回溯算法是算法面试中的常客,也是很多初学者觉得“玄学”的部分。其实,回溯本质上就是决策树的遍历,只要掌握了画递归树的技巧,理解“选择-递归-撤销”的套路,就能轻松应对各种题型。本文将从核心思想、模板、题型分类到剪枝技巧,带你彻底掌握回溯算法!
一、回溯算法是什么?
回溯算法(Backtracking)是一种通过试错来寻找所有解的算法。当它尝试某种选择时,如果发现该选择不符合要求(或已经找到解),就撤销该选择,回退到上一步,尝试其他选项。这个过程类似于在迷宫中的探索:走不通就回头,直到找到所有出口。
回溯的本质是深度优先搜索(DFS),通常用于解决:
- 组合问题(如组合总和)
- 子集问题(如子集 II)
- 排列问题(如全排列)
- 分割问题(如分割回文串)
- 棋盘问题(如 N 皇后)
二、核心思想:画出递归树
回溯算法的核心是画出递归树。每一个节点代表当前的状态(已经做出的选择),分支代表下一步可做的选择。通过递归遍历整棵树,就能找到所有满足条件的路径。
举例: 全排列 [1,2,3] 的递归树(简化):
[]
/ | \
[1] [2] [3]
/ \ / \ / \
[1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
| | | | | |
[1,2,3] [1,3,2] ...(所有叶子节点)
叶子节点就是完整的排列。回溯算法通过深度优先遍历这棵树,并在到达叶子节点时记录结果。
三、回溯算法通用模板
def backtrack(路径, 选择列表):
if 满足结束条件:
结果.append(路径[:]) # 保存当前路径的副本
return
if 需要剪枝:
return
for 选择 in 选择列表:
# 做选择
路径.append(选择)
# 递归,进入下一层决策树
backtrack(新路径, 新选择列表)
# 撤销选择
路径.pop()
参数设计要点:
- 路径:记录已经做出的选择。
- 选择列表:当前可以做的选择,通常由起始索引、used 数组等控制。
- 结束条件:到达决策树的底层(如路径长度等于 n,或和等于 target)。
- 剪枝条件:提前终止不可能的分支,提高效率。
四、经典题型分类与解题套路
根据问题的不同要求,回溯算法有几种常见的变体。下表总结了常见题型及其核心技巧:
| 题型 | 特点 | 关键参数 | 是否排序 | 去重方式 |
|---|---|---|---|---|
| 组合总和 I | 元素可重复使用,求组合 | start = i | 建议排序 | 自然避免重复(start 控制) |
| 组合总和 II | 元素不可重复,可能有重复值 | start = i+1 | 必须排序 | 同一层跳过相同值 |
| 子集 I | 求所有子集,元素互异 | start = i+1 | 可选 | 自然避免重复 |
| 子集 II | 求所有子集,可能有重复值 | start = i+1 | 必须排序 | 同一层跳过相同值 |
| 全排列 I | 元素互异,求所有排列 | used 数组 | 不需要 | 用 used 避免重复使用 |
| 全排列 II | 元素可能有重复,求所有排列 | used 数组 | 必须排序 | 同一层跳过相同值(且 !used[i-1]) |
| 分割问题 | 按条件分割字符串 | start 表示起始位置 | 通常不需 | 根据分割条件判断 |
| 棋盘问题 | 放置棋子,满足约束 | 通常逐行放置 | 不需要 | 用数组记录冲突 |
1. 组合类问题(无序)
关键:使用 start 参数控制下一层遍历的起点,避免产生重复组合(如 [1,2] 和 [2,1] 只取其一)。
-
元素可重复使用:递归时传入
i。def backtrack(start, ...): for i in range(start, len(nums)): path.append(nums[i]) backtrack(i, ...) # 可重复使用,仍从 i 开始 path.pop() -
元素不可重复使用:递归时传入
i+1。def backtrack(start, ...): for i in range(start, len(nums)): path.append(nums[i]) backtrack(i+1, ...) # 不可重复,从 i+1 开始 path.pop() -
有重复元素:先排序,在
for循环中跳过同一层相同的值。for i in range(start, len(nums)): if i > start and nums[i] == nums[i-1]: continue # 跳过重复 ...
典型例题:
2. 排列类问题(有序)
关键:不能使用 start,因为每个位置都可以放任意一个未使用的元素。需要使用 used 数组标记哪些元素已经被选过。
-
无重复元素:只需
used标记。used = [False] * len(nums) def backtrack(): if len(path) == len(nums): res.append(path[:]) return for i in range(len(nums)): if used[i]: continue used[i] = True path.append(nums[i]) backtrack() path.pop() used[i] = False -
有重复元素:先排序,在
for循环中加上条件if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue,保证同一层不会重复选取相同值。
典型例题:
3. 分割类问题
关键:将问题转化为在字符串中插入分割线,每次从 start 开始截取子串,判断是否满足条件(如回文串)。通常也需要 start 参数。
def backtrack(start):
if start == len(s):
res.append(path[:])
return
for i in range(start, len(s)):
if isPalindrome(s, start, i):
path.append(s[start:i+1])
backtrack(i+1)
path.pop()
典型例题:
4. 棋盘类问题(如 N 皇后)
关键:通常逐行放置,每层递归处理一行。需要记录已放置棋子的位置,判断是否冲突。
def backtrack(row):
if row == n:
res.append(board)
return
for col in range(n):
if is_valid(row, col):
place_queen(row, col)
backtrack(row+1)
remove_queen(row, col)
典型例题:
五、剪枝优化:让回溯不再暴力
回溯本身是穷举,但通过剪枝可以大幅减少搜索空间。常见的剪枝方法:
- 和约束剪枝:在组合总和中,如果当前和加上当前元素已经超过 target,由于数组已排序,后续更大元素更不可能,直接
break。 - 括号合法性剪枝:生成括号时,右括号不能多于左括号,左括号不能超过 n。
- 排列去重剪枝:全排列 II 中,利用
used[i-1]的状态避免同层重复。 - 可行性剪枝:N 皇后中,提前判断当前位置是否与已放置皇后冲突,不合法则跳过。
六、实例分析:通过递归树理解回溯
例1:全排列 [1,2,3](used 数组)
递归树(简化,每个节点显示 path):
level0: []
├─选择1→ [1]
│ ├─选择2→ [1,2]
│ │ └─选择3→ [1,2,3] ✅
│ └─选择3→ [1,3]
│ └─选择2→ [1,3,2] ✅
├─选择2→ [2]
│ ├─选择1→ [2,1]
│ │ └─选择3→ [2,1,3] ✅
│ └─选择3→ [2,3]
│ └─选择1→ [2,3,1] ✅
└─选择3→ [3]
├─选择1→ [3,1]
│ └─选择2→ [3,1,2] ✅
└─选择2→ [3,2]
└─选择1→ [3,2,1] ✅
代码中,used 数组确保每个元素只被选一次。
例2:组合总和 [2,3,6,7], target=7(可重复)
递归树(部分):
sum=0 []
├─选2→ sum=2 [2]
│ ├─选2→ sum=4 [2,2]
│ │ ├─选2→ sum=6 [2,2,2]
│ │ │ ├─选2→ sum=8>7 剪枝
│ │ │ ├─选3→ sum=9>7 剪枝
│ │ │ └─...
│ │ ├─选3→ sum=7 [2,2,3] ✅
│ │ └─...
│ ├─选3→ sum=5 [2,3]
│ │ ├─选2→ sum=7 [2,3,2] ❌ 但这里因为start=i,会导致 [2,3,2] 出现吗?不会,因为下一层从i=1开始(选3后i=1),所以不会重复选2吗?其实选3后,下一层可以从3开始(因为可重复),也可以从之后的6、7开始,但不会回头选2,因为start保证了不会回头,所以不会出现 [2,3,2](那是排列)。所以组合总和用start保证了组合的唯一性。
│ └─...
├─选3→ sum=3 [3]
│ ├─选3→ sum=6 [3,3]
│ │ └─选3→ sum=9>7 剪枝
│ └─...
├─选6→ sum=6 [6]
│ └─选6→ sum=12>7 剪枝
└─选7→ sum=7 [7] ✅
通过画递归树,可以直观看到剪枝的效果以及为什么用 start 可以避免重复组合。
七、总结:回溯算法三步走
- 画递归树:明确状态(路径、选择列表)、结束条件、剪枝条件。
- 套用模板:根据题型确定参数(是否用 start、used、是否排序等)。
- 剪枝优化:利用排序、约束条件减少无效搜索。
回溯算法虽然听起来复杂,但只要勤画图、多练习,就能掌握其中的套路。建议初学者从经典题目入手,一步步理解“选择-递归-撤销”的过程。当你看到一道新题,能立刻画出递归树时,就已经成功了一大半!
希望这篇总结能帮助你彻底攻克回溯算法!如果觉得有用,欢迎点赞、收藏、分享给更多的小伙伴。

浙公网安备 33010602011771号