讲解

回溯是「带着撤销的深度优先搜索」:沿一条选择路径走到底,走不通或走完了就退回上一步,换个选择继续——撤销(恢复现场)是它区别于普通 DFS 的标志。代码骨架高度统一:维护一个 path(当前选择序列),递归函数每层遍历本层所有可选元素,做选择、递归、撤销选择。「做选择 → 递归 → 撤销」三段式写熟,回溯题就拿到了框架分。

三类经典问题对应三种遍历方式。全排列:每层从「还没用过的元素」里选,需要 used 数组标记,终止条件是 path 长度等于 n——决策树有 n! 条路径,时间复杂度 O(n·n!),这是本质复杂度,不存在多项式算法。子集:每个元素都有「选 / 不选」两态,用 start 参数控制「只能往后选」避免 [1,2] 和 [2,1] 重复;它的巧妙写法是每进入一个递归节点就把当前 path 收进结果——每个节点都是一个子集。组合(如组合总和):同样用 start 防重,但允许元素复用时递归传 i 而不是 i+1;配合排序后剪枝(和已超 target 就 break)能砍掉大量分支。

剪枝是回溯从「能跑」到「跑得快」的关键:在进入递归前预判「这条路不可能出答案」,直接跳过。N 皇后是剪枝的天然教材:逐行放皇后,每列、两条对角线各用一个集合记录占用,放之前三查集合,冲突即跳过——n=8 时 92 种解、n=4 时 2 种解,没有剪枝的暴力根本跑不动稍大的 n。

示例

全排列、子集、组合总和、N 皇后计数四连发——同一个骨架,四种参数:

import assert from 'node:assert/strict';

function permute(nums) {
  const result = [];
  const path = [];
  const used = new Array(nums.length).fill(false);
  function dfs() {
    if (path.length === nums.length) {
      result.push(path.slice()); // 必须拷贝,path 还会被改
      return;
    }
    for (let i = 0; i < nums.length; i++) {
      if (used[i]) continue;
      used[i] = true; // 做选择
      path.push(nums[i]);
      dfs();
      path.pop(); // 撤销选择
      used[i] = false;
    }
  }
  dfs();
  return result;
}

function subsets(nums) {
  const result = [];
  const path = [];
  function dfs(start) {
    result.push(path.slice()); // 每个节点都是一个子集
    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);
      dfs(i + 1); // 只能往后选,天然去重
      path.pop();
    }
  }
  dfs(0);
  return result;
}

function combinationSum(candidates, target) {
  const result = [];
  const path = [];
  let sum = 0;
  function dfs(start) {
    if (sum === target) {
      result.push(path.slice());
      return;
    }
    for (let i = start; i < candidates.length; i++) {
      if (sum + candidates[i] > target) continue; // 剪枝
      sum += candidates[i];
      path.push(candidates[i]);
      dfs(i); // 元素可复用:传 i 而不是 i + 1
      sum -= candidates[i];
      path.pop();
    }
  }
  dfs(0);
  return result;
}

function totalNQueens(n) {
  let count = 0;
  const cols = new Set();
  const diag1 = new Set(); // row - col 恒定
  const diag2 = new Set(); // row + col 恒定
  function dfs(row) {
    if (row === n) {
      count++;
      return;
    }
    for (let col = 0; col < n; col++) {
      if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) continue;
      cols.add(col);
      diag1.add(row - col);
      diag2.add(row + col);
      dfs(row + 1);
      cols.delete(col);
      diag1.delete(row - col);
      diag2.delete(row + col);
    }
  }
  dfs(0);
  return count;
}

assert.strictEqual(permute([1, 2, 3]).length, 6);
assert.deepStrictEqual(permute([1]), [[1]]);
assert.strictEqual(subsets([1, 2, 3]).length, 8); // 2^3
assert.deepStrictEqual(combinationSum([2, 3, 6, 7], 7), [[2, 2, 3], [7]]);
assert.strictEqual(totalNQueens(4), 2);
assert.strictEqual(totalNQueens(1), 1);

console.log('回溯示例全部通过');

横向对比四个 dfs 的参数差异:排列无 start 但要 used;子集有 start 且 i+1;可复用组合有 start 且传 i;N 皇后每层固定选「第 row 行的列号」。参数差异就是问题差异,框架本身纹丝不动。

常见坑

  • 结果里存的是 path 的引用:回溯会反复修改同一个 path,收结果必须 slice() 拷贝——最常见的 bug,症状是结果数组里全是空数组或同一个数组。
  • 撤销不完整:做选择和撤销必须严格对称(push 对 pop、add 对 delete、置 true 对置 false),漏一处状态就被污染,且 bug 隐蔽。
  • 去重靠事后过滤:组合类问题该用 start 参数在生成时防重,生成完再 Set 去重既慢又丑。
  • 该剪枝时硬跑:如组合总和中 sum 已超 target 还继续递归,复杂度直接失控;排序后 break 剪枝效果更好。
  • 误把回溯当多项式算法:排列、子集的本质复杂度是阶乘/指数级,n 稍大就跑不动是题目性质,不是你的代码错了。

小结

回溯 = 做选择 → 递归 → 撤销的 DFS;排列用 used、子集/组合用 start 防重、可复用传 i、剪枝保性能;结果收 path 的拷贝。下一章看搜索的另一翼:广度优先搜索。