别再凭直觉写递归了------三个坑 + 一个模板,回溯从此不靠蒙
1. 开篇:这道"全排列",你已经重写了三遍
LeetCode 46 全排列。你心想:"回溯嘛,不就是一个 for 循环加递归。"
js
// ❌ 第一版:没写撤销
function permute(nums) {
const result = [], path = []
function dfs() {
if (path.length === nums.length) {
result.push(path) // ⚠️ 直接 push 引用
return
}
for (let n of nums) {
if (path.includes(n)) continue
path.push(n)
dfs()
// ⚠️ 忘了 pop()!撤销没做!
}
}
dfs()
return result
}
// 跑完发现 result 里全是同一个空数组 []------因为 push 的是引用,而且没撤销
你删掉重写。第二遍加上 pop(),但没加 used[] 标记,重复元素去不掉。第三遍终于写对了,但面试官追问"组合题怎么改?"------你又卡住了。
根本原因:你没把回溯抽象成一个模板。每次都是凭直觉写,所以每次都要重来。这篇文章把回溯浓缩成一个公式------排列、组合、子集,全部只改三处。
2. 核心概念:递归和回溯,先分清再写
递归 :把大问题拆成同类小问题,直到小到能直接解决。公式:
f(n) = g(f(n-1))。
回溯 :把"尝试→回退→换条路再试"这个过程写成代码。本质是在递归的"归"之后多一步------撤销刚才的选择。
先看纯递归,再看回溯------感受它们的区别:
js
// 纯递归:阶乘 ------ 只"递"和"归",没有"回退换路"
function factorial(n) {
if (n <= 1) return 1 // 终止条件
return n * factorial(n - 1) // 子问题关系
}
// 回溯:全排列 ------ 多了"做选择"和"撤销选择"
function permute(nums) {
// ... 完整代码见 Step 2
path.push(nums[i]) // 做选择
backtrack() // 递归
path.pop() // 撤销选择 ← 回溯多出来的这一步
}
一句话区分:纯递归是一条路走到黑;回溯是走一条路记下来,退回岔路口再走另一条。
3. 核心代码
Step 1:回溯的万能模板
js
function backtrack(路径, 选择列表) {
if (满足结束条件) {
result.push([...路径]) // ⚠️ 必须拷贝!原因见下方
return
}
for (let 选择 of 选择列表) {
if (选择不合法) continue // 剪枝
路径.push(选择) // 🔑 做选择
backtrack(路径, 新的选择列表)
路径.pop() // 🔑 撤销选择------和"做选择"完全对称
}
}
⚠️ result.push([...路径]) 不能写成 result.push(路径) 。因为 路径 是引用,之后 pop() 会改掉它------最终 result 里全是同一个空数组。
这个坑的本质是 JavaScript 的对象引用机制。你 push 进去的是一个数组对象的地址 ,不是它的值。后面你 pop() 修改了这个数组,result 里存的那个地址指向的也是同一个数组------自然也被改了。[...路径] 创建一个新数组,切断这个联系。
Step 2:全排列(LeetCode 46)------模板第一改
给定
[1, 2, 3],返回所有排列。
js
function permute(nums) {
const result = []
const path = []
const used = new Array(nums.length).fill(false) // 🔑 标记哪些用过了
function backtrack() {
if (path.length === nums.length) { // 🔑 结束条件:全选完了
result.push([...path]) // ⚠️ 拷贝!
return
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue // 🔑 剪枝:用过的跳过
path.push(nums[i]) // 做选择
used[i] = true
backtrack()
path.pop() // 撤销选择(和上面完全对称)
used[i] = false
}
}
backtrack()
return result
}
排列题的关键选择 :需要 used[] 标记,因为 [1,2] 和 [2,1] 是不同的排列,同一个数不能重复用,但可以被不同位置再次选中。
scss
决策树(permute([1,2,3])):
[]
/1 |2 \3
[1] [2] [3]
/ \ / \ / \
[1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
| | | | | |
[1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]
回溯 = 深度优先遍历这棵树,到叶子就记录,然后退回上一层走另一个分支。
Step 3:组合(LeetCode 77)------模板第二改
从
[1..n]中选 k 个数,返回所有组合。注意[1,2]和[2,1]算同一种。
js
function combine(n, k) {
const result = []
const path = []
function backtrack(start) { // 🔑 比排列多一个 start 参数
if (path.length === k) {
result.push([...path])
return
}
for (let i = start; i <= n; i++) { // 🔑 从 start 开始,不往前看
path.push(i)
backtrack(i + 1) // 🔑 下一层从 i+1 开始
path.pop()
}
}
backtrack(1)
return result
}
组合 vs 排列:只改了两处:
| 改动 | 排列 | 组合 |
|---|---|---|
| 循环起点 | let i = 0 每次从头选 |
let i = start 只往后选 |
| 递归传参 | backtrack() 无参 |
backtrack(i + 1) 下移起点 |
| 去重方式 | used[] 数组标记 |
start 参数保证不回头 |
🔑
start参数是组合题的核心 :它保证了每个数只用一次且不回头,天然避免了[1,2]和[2,1]重复。
Step 4:子集(LeetCode 78)------模板第三改
给定
[1, 2, 3],返回所有子集(包括空集)。
js
function subsets(nums) {
const result = []
const path = []
function backtrack(start) {
result.push([...path]) // 🔑 每个节点都记录!不是到叶子才记录
for (let i = start; i < nums.length; i++) {
path.push(nums[i])
backtrack(i + 1) // 和组合一样,不回头
path.pop()
}
}
backtrack(0)
return result
}
子集和组合的唯一区别:结束条件不同。
- 组合:
path.length === k才记录(只要指定长度的) - 子集:每一步都记录(收集决策树上的每一个节点,不论深度)
ini
决策树对比([1,2,3]):
组合 k=2: 子集:
[] ← 跳过 [] ← 记录 ✅
├1 ├1 ← 记录 ✅
│├2 → [1,2] ✅ │├2 → 记录 ✅
│└3 → [1,3] ✅ │└3 → 记录 ✅
├2 ├2 ← 记录 ✅
│└3 → [2,3] ✅ │└3 → 记录 ✅
└3 ← 跳过 └3 ← 记录 ✅
同一棵树,组合只摘指定深度的果子,子集是路过就摘。
模板总结:三道题改的是同一份代码
js
// 🔑 回溯三要素------每道题只要改这三个地方
function backtrack(...) {
if (/* 🔴 改这里:什么时候记录答案? */) {
result.push([...path])
return
}
for (let i = /* 🔴 改这里:从哪开始选? */; i < ...; i++) {
if (/* 🔴 改这里:什么情况跳过? */) continue
path.push(...)
backtrack(/* 🔴 改这里:下一层从哪开始? */)
path.pop()
}
}
| 题型 | 记录条件 | 循环起点 | 剪枝 | 下一层传参 |
|---|---|---|---|---|
| 排列 | path.length === n |
i = 0 |
used[i] |
无 start |
| 组合 | path.length === k |
i = start |
无(或剪枝优化) | i + 1 |
| 子集 | 每次进入都记录 | i = start |
无 | i + 1 |
4. 总结:下次回溯题,抄模板改三处就行
回扣开篇:你开头全排列重写三遍,不是因为不聪明------是因为每次凭直觉写,没把"做选择→递归→撤销选择"内化成肌肉记忆。下次遇到回溯题,先别想细节,先把这个模板敲出来,再改那三个地方。
一句话总结(可截图转发)
回溯 = 递归 + 做选择 + 撤销选择(完全对称)。排列用 used\[\] 标记,组合用 start 参数不回头,子集是路过就记录。
result.push([...path])必须拷贝,否则白写。
避坑清单
| 坑 | 现象 | 修复 |
|---|---|---|
result.push(path) 不拷贝 |
result 里全是空数组 | result.push([...path]) |
| 忘写终止条件 | Maximum call stack size exceeded |
每个递归函数第一行写终止条件 |
| 撤销没做干净 | 后面分支结果混入前面残留 | 做选择和撤销要一一对应,完全对称 |
| 重复元素不会去重 | 全排列结果有重复 | 排序后 if (i > start && nums[i] === nums[i-1]) continue |
| 递归太深爆栈 | 超过约 1 万层报错 | 改迭代或尾递归 |
下一步行动
- 把这个模板手写三遍,直到不用看就能敲出来
- 用模板做三道变体:39. 组合总和、40. 组合总和 II、47. 全排列 II------感受"模板不变,只改三处"
- 试着不看代码,凭理解画出全排列的决策树------面试官最爱让候选人画这个
留个开放问题:回溯的时间复杂度怎么算?全排列是 O(n!),组合是 O(C(n,k)),子集是 O(2^n)------这些数怎么来的?你能用决策树的节点数解释吗?评论区聊聊 👇