【初学者报道】|一文读懂 JavaScript 全排列与回溯算法

0 阅读3分钟

一文读懂 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

  • 数组索引范围是 0n-1
  • dfs(i)的含义是: “我要决定第 i个位置放谁”
  • i递增到 n时,意味着 0n-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] -> 记录结果
...

回溯算法就是通过递归遍历这棵树,并在遍历完一条路径后,回到上一个节点选择其他分支。


五、总结

  1. 回溯模板做选择 -> 递归 -> 撤销选择
  2. 终止时机:当 i等于数组长度 n时,代表路径已满。
  3. 状态恢复:这是回溯的灵魂,确保每一次尝试都不会影响其他分支。
  4. 性能:全排列的时间复杂度是 O(n · n!) ,随着数据量增长会极速膨胀,因此在实际应用中常配合剪枝优化。