3054 words
15 minutes
回溯算法入门到实战:用 JavaScript 讲透原理、步骤与模板

很多人第一次学回溯时,会觉得它像一种“暴力枚举的玄学写法”:代码里全是递归、数组 push / pop、还有各种 usedstartIndexSet。但只要你把它看成一棵决策树,整个思路就会一下子清晰很多。

回溯算法的本质:在决策树上做深度优先搜索;如果当前选择走不通,就撤销这一步,回到上一个状态继续试。

这篇文章会讲三件事:

  1. 回溯到底在解决什么问题,它和 DFS 是什么关系。
  2. 写回溯题时,应该按什么步骤把题面翻译成代码。
  3. 如何用 JavaScript 写出能过面试、也能自己复用的回溯模板。

回溯算法流程图

一句话口诀:先判断,再选择;选完递归;回来撤销。

一、什么是回溯算法?#

回溯(Backtracking)是一种通过试错来搜索所有可行解的方法。

它通常适合这类题:

  1. 要求找出所有方案
  2. 需要从若干候选项中不断“选一个,再继续选下一个”。
  3. 每一步都会影响下一步可选范围。
  4. 一旦发现当前路径不合法,就应该立刻停止继续往下搜。

典型关键词包括:

  • 全排列
  • 组合
  • 子集
  • 棋盘放置
  • 路径搜索
  • 字符串切分
  • 满足约束的所有解

回溯和 DFS 的关系#

很多人会把“回溯”和“DFS”混在一起。更准确地说:

  • DFS(深度优先搜索) 是一种搜索顺序。
  • 回溯 是一种“DFS + 撤销状态”的解题框架。

也就是说,回溯通常借助 DFS 来遍历整棵决策树,但它比普通 DFS 多了一个关键动作:撤销选择

如果你把每次递归都看成“进入下一层决策”,那么回溯的核心动作就是这三步:

  1. 做选择。
  2. 递归进入下一层。
  3. 撤销刚才的选择。

二、把题目翻译成“决策树”#

几乎所有回溯题,都可以画成一棵树:

  • 节点:当前已经做出的选择。
  • :这一步新增的选择。
  • 叶子节点:一个完整答案,或者一条失败路径。

比如在全排列问题里,[1, 2, 3] 的决策树可以理解成:

  1. 第一层决定第 1 个位置放谁。
  2. 第二层决定第 2 个位置放谁。
  3. 第三层决定第 3 个位置放谁。

全排列的决策树示意图

所以写回溯时,你其实一直在回答四个问题:

  1. 路径(path)是什么? 当前已经选了哪些元素?
  2. 选择列表(choices)是什么? 这一层还能选谁?
  3. 结束条件是什么? 什么情况下说明找到答案了?
  4. 剪枝条件是什么? 什么情况下可以直接停止,不必继续递归?

这四个问题想清楚,代码基本就出来了。

三、回溯的通用模板#

先看最常用的 JavaScript 模板:

function backtrack() {
if (满足结束条件) {
result.push(当前路径的拷贝);
return;
}
for (const choice of 当前层可选项) {
if (这个选择不合法) continue;
做选择;
backtrack(进入下一层);
撤销选择;
}
}

把它写成更贴近真实题目的样子:

function solve(nums) {
const result = [];
const path = [];
function dfs() {
if (path.length === nums.length) {
result.push([...path]);
return;
}
for (const num of nums) {
if (当前不能选 num) continue;
path.push(num);
dfs();
path.pop();
}
}
dfs();
return result;
}

模板里最重要的三个动作#

1. 做选择#

把当前元素加入路径,或者更新状态。

path.push(num);

2. 递归深入#

让下一层继续做决策。

dfs();

3. 撤销选择#

回到进入这一层之前的状态,否则会污染后续分支。

path.pop();

很多回溯题写错,不是因为递归没想明白,而是因为“撤销状态”没还原干净。

四、写回溯题的实践步骤#

下面是一套很适合面试和刷题时使用的实战步骤。

第 1 步:先问“这一层在决定什么”#

回溯最怕一上来就写代码。建议先停 30 秒,想一句话:

当前递归层,到底在决定哪个位置、哪一项、哪一行、哪一个选择?

例如:

  • 全排列:当前层决定“当前位置放哪个数”。
  • 子集:当前层决定“第 i 个数要不要选”。
  • N 皇后:当前层决定“当前行的皇后放在哪一列”。

第 2 步:定义状态变量#

回溯里最常见的状态变量就这几种:

状态含义常见场景
path当前路径排列、组合、子集
result所有答案所有回溯题
used某个元素是否已使用排列
startIndex本层从哪里开始枚举组合、子集
row / col当前行列位置棋盘问题
Set / Map记录约束是否冲突N 皇后、数独

第 3 步:写清结束条件#

结束条件决定“什么时候收集答案,什么时候停止递归”。

例如:

  • path.length === nums.length:找到一个完整排列。
  • sum === target:找到一个合法组合。
  • row === n:N 皇后已经放完最后一行。

第 4 步:枚举当前层的所有选择#

这一步对应模板里的 for 循环。你需要明确:

  1. 当前有哪些候选项?
  2. 哪些候选项不合法?
  3. 这些不合法的情况能否提前剪掉?

第 5 步:做选择、递归、撤销#

这是回溯最核心的固定动作:

path.push(choice);
dfs(...);
path.pop();

如果你还维护了别的状态,也要成对撤销:

used[i] = true;
dfs(...);
used[i] = false;

第 6 步:考虑剪枝#

剪枝的意思是:有些分支明知道不可能得到答案,就不要继续搜了。

常见剪枝方式:

  1. 当前和已经超过目标值。
  2. 当前皇后位置已经和已有皇后冲突。
  3. 候选数组已排序,后面的数只会更大,可以直接 break

剪枝不会改变正确性,但会显著提升性能。

五、经典例题 1:全排列#

题目:给定一个不重复数组 nums,返回它的所有排列。

思路拆解#

  1. path 表示当前已经排好的序列。
  2. used[i] 表示 nums[i] 是否已被放进 path
  3. path.length === nums.length 时,得到一个完整排列。

JavaScript 实现#

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]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
path.push(nums[i]);
used[i] = true;
dfs();
used[i] = false;
path.pop();
}
}
dfs();
return result;
}
console.log(permute([1, 2, 3]));
// [
// [1, 2, 3], [1, 3, 2],
// [2, 1, 3], [2, 3, 1],
// [3, 1, 2], [3, 2, 1]
// ]

这题的关键点#

  1. 排列问题里,顺序不同算不同答案,所以需要 used
  2. 每一层都要从头枚举整个 nums
  3. 回来时一定要同时还原 used[i]path

复杂度#

  • 时间复杂度:O(n * n!)
  • 空间复杂度:O(n)

六、经典例题 2:组合总和#

题目:给定一个无重复正整数数组 candidates 和目标值 target,找出所有和为 target 的组合。每个数字可以重复使用。

思路拆解#

这题和排列不一样,顺序不重要,所以不能每层都从头开始枚举,否则会出现重复组合。

这里我们要多维护一个 startIndex

  • 本层只能从 startIndex 开始往后选。
  • 如果一个数字可以重复使用,递归时仍然传 i
  • 如果一个数字只能用一次,递归时传 i + 1

先把数组排序,就能利用“后面只会更大”来做剪枝。

JavaScript 实现#

function combinationSum(candidates, target) {
const result = [];
const path = [];
candidates.sort((a, b) => a - b);
function dfs(startIndex, sum) {
if (sum === target) {
result.push([...path]);
return;
}
for (let i = startIndex; i < candidates.length; i++) {
const num = candidates[i];
const nextSum = sum + num;
if (nextSum > target) break; // 剪枝:后面的数只会更大
path.push(num);
dfs(i, nextSum); // 还能重复使用当前元素
path.pop();
}
}
dfs(0, 0);
return result;
}
console.log(combinationSum([2, 3, 6, 7], 7));
// [[2, 2, 3], [7]]

这题的关键点#

  1. 组合问题通常需要 startIndex 来避免重复。
  2. 排序之后,nextSum > target 可以直接 break,这是典型剪枝。
  3. 如果题目改成“每个数只能用一次”,那递归要改成 dfs(i + 1, nextSum)

七、经典例题 3:N 皇后#

题目:把 n 个皇后放到 n x n 的棋盘上,使任意两个皇后都不能互相攻击。

思路拆解#

N 皇后是回溯题里非常经典的“约束搜索”:

  1. 每一层递归表示“当前要处理第几行”。
  2. 每一行只能放一个皇后。
  3. 不能和之前皇后出现在同一列、同一主对角线、同一副对角线。

为了快速判断冲突,我们用三个集合:

  • cols:记录哪些列已有皇后。
  • diag1:记录主对角线 row - col
  • diag2:记录副对角线 row + col

JavaScript 实现#

function solveNQueens(n) {
const result = [];
const board = Array.from({ length: n }, () => Array(n).fill("."));
const cols = new Set();
const diag1 = new Set();
const diag2 = new Set();
function dfs(row) {
if (row === n) {
result.push(board.map(line => line.join("")));
return;
}
for (let col = 0; col < n; col++) {
if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) {
continue;
}
board[row][col] = "Q";
cols.add(col);
diag1.add(row - col);
diag2.add(row + col);
dfs(row + 1);
board[row][col] = ".";
cols.delete(col);
diag1.delete(row - col);
diag2.delete(row + col);
}
}
dfs(0);
return result;
}
console.log(solveNQueens(4));
// [
// [".Q..", "...Q", "Q...", "..Q."],
// ["..Q.", "Q...", "...Q", ".Q.."]
// ]

这题的关键点#

  1. row 表示递归深度,每层只处理一行。
  2. 冲突检测要尽量 O(1),否则会非常慢。
  3. 放置和撤销都要同步维护棋盘与三个集合。

八、什么时候该用 used,什么时候该用 startIndex#

这是回溯初学者最容易混淆的地方。

场景关键状态原因
排列used因为每个位置都能重新从全部元素里挑一个
组合 / 子集startIndex因为顺序不重要,只能往后选,避免重复
棋盘搜索行列 + 约束集合因为重点不是“元素是否用过”,而是“位置是否合法”

你可以这样记:

  • 顺序重要:大概率要 used
  • 顺序不重要:大概率要 startIndex
  • 有约束冲突:大概率要额外集合或标记数组

九、回溯题最常见的 5 个错误#

1. 忘记拷贝路径#

错误写法:

result.push(path);

正确写法:

result.push([...path]);

因为 path 是同一个数组对象,后面还会不断变化。

2. 撤销状态不完整#

比如你做选择时改了两个状态:

path.push(nums[i]);
used[i] = true;

回来时也必须撤销两个状态:

used[i] = false;
path.pop();

3. 组合问题里递归起点写错#

如果顺序不重要,却每次都从 0 开始枚举,就会生成重复答案。

4. 剪枝条件写反#

剪枝只能“提前排除不可能的分支”,不能把合法答案剪掉。

5. 没想清楚“当前层决定什么”#

这是所有回溯 bug 的源头。如果层的含义不清楚,for 循环范围、结束条件、状态变量都会跟着乱。

十、如何判断一道题是不是回溯题?#

你可以快速做这几个判断:

  1. 题目是否要求所有解全部方案所有路径
  2. 解是否由“一步步选择”构成?
  3. 当前选择是否会影响后续选择?
  4. 发现不合法后,是否应该立刻停止当前分支?

如果这四个问题里有 3 个以上答案是“是”,基本就可以往回溯上想。

十一、回溯的本质总结#

回溯并不神秘,它就是:

  1. 把问题抽象成一棵决策树。
  2. 用 DFS 一路往下试。
  3. 遇到答案就收集。
  4. 遇到死路就返回。
  5. 返回时撤销状态,继续尝试其他分支。

所以你真正要掌握的,不是“背几个模板”,而是这套固定思维:

  1. 当前层在决定什么?
  2. 当前路径记录了什么?
  3. 本层还能选什么?
  4. 什么时候结束?
  5. 哪些分支可以剪掉?
  6. 回来时要还原哪些状态?

当你能稳定回答这 6 个问题时,绝大多数回溯题都能自己写出来。

最后记住一句:回溯 = 决策树 + DFS + 撤销状态 + 剪枝。

回溯算法入门到实战:用 JavaScript 讲透原理、步骤与模板
https://fuwari.vercel.app/posts/backtracking-algorithm-js/
Author
Owen
Published at
2026-05-29
License
CC BY-NC-SA 4.0