一文读懂 JavaScript 全排列与回溯算法
在算法学习中,回溯(Backtracking) 是一个既重要又容易让人困惑的概念。本文将以一段经典的 全排列(Permutations) 代码为例,带你彻底搞懂回溯的执行过程,以及它与递归的本质区别。
#题目:给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入: nums = [1,2,3]
输出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入: nums = [0,1]
输出: [[0,1],[1,0]]
示例 3:
输入: nums = [1]
输出: [[1]]
一、核心代码:生成全排列
我们先看一段标准的回溯代码,用于生成数组 nums的所有排列方式:
var permute = function(nums) {
const n = nums.length;
const path = Array(n).fill(0);
const onPath = Array(n).fill(false);
const ans = [];
function dfs(i) {
// 1. 终止条件
if (i === n) {
ans.push(path.slice());
return;
}
// 2. 枚举所有可能的选择
for (let j = 0; j < n; j++) {
if (!onPath[j]) {
// 3. 做选择
path[i] = nums[j];
onPath[j] = true;
// 4. 递归
dfs(i + 1);
// 5. 撤销选择(回溯)
onPath[j] = false;
}
}
};
dfs(0);
return ans;
};
二、代码执行机制详解
1. 变量的含义
path:路径数组,用于存储当前正在构建的排列。onPath:状态数组,onPath[j]表示nums[j]是否已经被使用。i:当前递归层,也表示我们要填充path的第i个位置。
2. 为什么是 i === n?
很多初学者会疑惑,为什么不是 i === n - 1?
- 数组索引范围是
0到n-1。 dfs(i)的含义是: “我要决定第i个位置放谁” 。- 当
i递增到n时,意味着0到n-1的位置都已经填满,此时path才构成一个完整的排列。
3. 为什么要 path.slice()?
path是一个全局引用的数组,在回溯过程中会被反复修改。如果不使用 slice()拷贝,最终 ans中的所有元素都会指向同一个数组,导致结果全部变成最后一次修改的状态。
4. path会被同时修改吗?
不会。JavaScript 是单线程的,代码是串行执行的。
- 虽然
path不会被“同时”修改,但它会被“反复”修改。 - 回溯的关键在于:在递归返回前,必须恢复现场(如
onPath[j] = false),以便下一次尝试其他分支。
三、回溯 vs 递归:核心区别
这是最容易混淆的地方,请记住这句话:
递归是代码结构,回溯是算法思想。
| 维度 | 递归 (Recursion) | 回溯 (Backtracking) |
|---|---|---|
| 本质 | 函数调用自身 | 尝试 + 撤销(试错) |
| 目的 | 简化问题,分解子问题 | 寻找所有可能的解 |
| 特征 | 自顶向下 | 深度优先搜索 (DFS) + 状态重置 |
| 关系 | 回溯通常用递归实现 | 递归不一定是回溯 |
举例说明
- 阶乘计算 (
factorial) :是递归,不是回溯。因为没有“试错”和“撤销选择”的过程。 - 全排列:既是递归,也是回溯。因为它在递归过程中不断尝试不同的数字,并在失败后退回上一步。
四、决策树视角
全排列的本质是一棵决策树。以 [1, 2, 3]为例:
第1层 (i=0): 选第一个数 [1]
├─ 第2层 (i=1): 选第二个数 [1, 2]
│ └─ 第3层 (i=2): 选第三个数 [1, 2, 3] -> 记录结果
├─ 第2层 (i=1): 选第二个数 [1, 3]
│ └─ 第3层 (i=2): 选第三个数 [1, 3, 2] -> 记录结果
...
回溯算法就是通过递归遍历这棵树,并在遍历完一条路径后,回到上一个节点选择其他分支。
五、总结
- 回溯模板:
做选择 -> 递归 -> 撤销选择。 - 终止时机:当
i等于数组长度n时,代表路径已满。 - 状态恢复:这是回溯的灵魂,确保每一次尝试都不会影响其他分支。
- 性能:全排列的时间复杂度是 O(n · n!) ,随着数据量增长会极速膨胀,因此在实际应用中常配合剪枝优化。