递归把问题缩小成同类子问题,回溯在递归前后加上选择与撤销,两者合起来才能穷举所有可能。
前端渲染无限级菜单、勾选父节点时联动子节点,这类结构天然递归,写成循环反而拧巴。面试里的排列、组合、棋盘题则统一靠回溯,模板只有十行,难的是剪枝。
终止条件决定什么时候不再往下拆;本层逻辑负责把子问题的结果组合成当前答案;返回值含义必须用一句话说清楚,否则调用方拿到的值没有意义。
function maxDepth(node) {
if (!node) return 0; // 终止条件
const left = maxDepth(node.left); // 子问题
const right = maxDepth(node.right);
return Math.max(left, right) + 1; // 本层逻辑
}
maxDepth 的返回值含义是"以 node 为根的树高",终止条件返回 0 正好与其自洽。换成"返回节点个数"也能写,但初始值和加一的位置都要跟着变。
每次调用都会创建栈帧,保存参数、局部变量和返回地址。V8 默认栈约 1MB,普通递归深度到一万层左右就会抛出 RangeError: Maximum call stack size exceeded。Node 可以用 --stack-size 调大,但只是把崩溃点往后推,正确做法是改成迭代加显式栈。
function sum(n) {
return n === 0 ? 0 : n + sum(n - 1);
}
sum(100000); // RangeError: Maximum call stack size exceeded
尾调用指函数的最后一步直接返回另一个函数的调用结果。ES6 规范写了 PTC(proper tail calls),V8 以调试困难、栈信息丢失为由没有实现,Chrome 至今不支持。所以 return f(x) 依然会压栈,改写成尾递归既不会更快,也不会免于溢出。
// 形式上是尾递归,V8 里照样爆栈
function sumTail(n, acc = 0) {
return n === 0 ? acc : sumTail(n - 1, acc + n);
}
回溯是带撤销的递归:做选择、进下一层、回来后撤销,保证同一层尝试下一个分支时状态是干净的。
function backtrack(path, start) {
if (满足结束条件) {
res.push([...path]); // 必须拷贝,path 是共享引用
return;
}
for (let i = start; i < nums.length; i++) {
path.push(nums[i]); // 选择
backtrack(path, i + 1); // 递归
path.pop(); // 撤销
}
}
以全排列 [1,2,3] 为例,决策树的每一层决定"这一位放谁":
// []
// ├─ [1] ─┬─ [1,2] ── [1,2,3]
// │ └─ [1,3] ── [1,3,2]
// ├─ [2] ─┬─ [2,1] ── [2,1,3]
// │ └─ [2,3] ── [2,3,1]
// └─ [3] ─┬─ [3,1] ── [3,1,2]
// └─ [3,2] ── [3,2,1]
树的深度是 3,每层最多 3 个分支,叶子就是答案。排列需要 used 记录已选元素,组合和子集用 start 保证只往后选,两者都是"避免同一元素被重复使用"的不同实现。
排序后剪枝:if (i > start && nums[i] === nums[i - 1]) continue;。它砍掉的是"同一层里值相同的第二个分支",因为排序后相同元素相邻,前一分支已经把这类结果穷举完了。
used 数组:if (used[i] || (i > 0 && nums[i] === nums[i - 1] && !used[i - 1])) continue;。前半句防同一元素重复使用(树枝),后半句防同一层重复(树层)。!used[i - 1] 表示前一个相同元素刚被撤销,说明当前处于同一层,这条分支必须跳过。
| 类型 | 结束条件 | 下一层起点 |
|---|
| 去重方式 |
|---|
| 排列 | path.length === n | 0,配合 used | 排序 + used |
| 组合 | 和等于 target 或选够 k 个 | i(可重复)或 i + 1 | 排序剪枝 |
| 子集 | 每个节点都收集 | i + 1 | 排序剪枝 |
| 切割 | 切到字符串末尾 | i + 1 | 同层相同子串跳过 |
| 棋盘 | 走到最后一行 | 下一行的所有列 | 列、两条斜线集合 |
很多人以为递归一定比迭代慢,实际上简单递归被 JIT 内联后差距很小,真正的限制是栈深度。
很多人以为把 path 直接 push 进结果就行,实际上存的是同一个数组引用,递归结束后全是空数组,必须 [...path]。
很多人以为重复结果可以在最后用 Set 过滤,实际上指数级搜索里提前剪枝能砍掉整棵子树,后置去重只是浪费。
先排序让相同元素相邻,再用 used 同时处理"同一元素不能用两次"和"同一层相同值不重复选"。时间 O(n·n!),空间 O(n)。
function permuteUnique(nums) {
nums.sort((a, b) => a - b);
const res = [], path = [], used = new Array(nums.length).fill(false);
const dfs = () => {
if (path.length === nums.length) { res.push([...path]); return; }
for (let i = 0; i < nums.length; i++) {
if (used[i] || (i > 0 && nums[i] === nums[i - 1] && !used[i - 1])) continue;
used[i] = true;
path.push(nums[i]);
dfs();
path.pop();
used[i] = false;
}
};
dfs();
return res;
}
元素可以重复使用,所以下一层仍从 i 开始;排序后一旦 candidates[i] > rest 就直接 break,后面的更大更不可能凑出。时间 O(S·n)(S 为解的个数),空间 O(target / min)。
function combinationSum(candidates, target) {
candidates.sort((a, b) => a - b);
const res = [], path = [];
const dfs = (start, rest) => {
if (rest === 0) { res.push([...path]); return; }
for (let i = start; i < candidates.length; i++) {
if (candidates[i] > rest) break;
path.push(candidates[i]);
dfs(i, rest - candidates[i]);
path.pop();
}
};
dfs(0, target);
return res;
}
任意前缀中左括号数不小于右括号数,且总数不超过 n,这两个条件就是剪枝。时间 O(4ⁿ/√n)(第 n 个卡特兰数),空间 O(n)。
function generateParenthesis(n) {
const res = [], path = [];
const dfs = (open, close) => {
if (path.length === 2 * n) { res.push(path.join('')); return; }
if (open < n) { path.push('('); dfs(open + 1, close); path.pop(); }
if (close < open) { path.push(')'); dfs(open, close + 1); path.pop(); }
};
dfs(0, 0);
return res;
}
逐行放置,用三个集合分别记录已占用的列、主对角线 row - col 和副对角线 row + col,冲突就跳过。时间 O(n!),空间 O(n²)(棋盘与结果)。
function solveNQueens(n) {
const res = [], board = Array.from({ length: n }, () => '.'.repeat(n));
const cols = new Set(), diag1 = new Set(), diag2 = new Set();
const dfs = (row) => {
if (row === n) { res.push([...board]); return; }
for (let col = 0; col < n; col++) {
const d1 = row - col, d2 = row + col;
if (cols.has(col) || diag1.has(d1) || diag2.has(d2)) continue;
cols.add(col); diag1.add(d1); diag2.add(d2);
board[row] = board[row].slice(0, col) + 'Q' + board[row].slice(col + 1);
dfs(row + 1);
board[row] = '.'.repeat(n);
cols.delete(col); diag1.delete(d1); diag2.delete(d2);
}
};
dfs(0);
return res;
}