LeetCode 46:全排列 —— 回溯算法的经典入门与本质理解

6 阅读3分钟

一、题目要求

给定一个 不含重复元素 的整数数组 nums,返回它的 所有可能的全排列

示例:

输入:nums = [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]

题目关注点并不在排序或数学公式,而在于:

  • 如何枚举所有可能情况
  • 如何在递归过程中避免重复使用同一个元素
  • 如何正确保存每一种排列结果

二、整体解题思路

这道题是**回溯(Backtracking)**的标准模型题,可以用一句话概括:

在一棵「决策树」上做深度优先遍历,走到叶子节点时收集答案,回退时撤销选择。

拆成三个核心问题:

  1. 如何表示“当前正在构造的排列”?
    使用一个 list,表示当前路径。
  2. 如何避免一个数字在同一排列中被重复使用?
    使用 visited 记录某个数字是否已经用过。
  3. 什么时候可以把当前结果加入答案?
    list.size() == nums.length,说明一个完整排列已经构造完成。

三、完整代码

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        HashMap<Integer, Boolean> visited = new HashMap<>();

        // 初始化访问状态
        for (int num : nums) {
            visited.put(num, false);
        }

        backtrack(res, nums, visited, new ArrayList<>());
        return res;
    }

    private void backtrack(List<List<Integer>> res,
                           int[] nums,
                           HashMap<Integer, Boolean> visited,
                           List<Integer> list) {

        // 递归终止条件:形成一个完整排列
        if (list.size() == nums.length) {
            res.add(new ArrayList<>(list));
            return;
        }

        // 尝试选择每一个还没用过的数字
        for (int num : nums) {
            if (!visited.get(num)) {
                list.add(num);
                visited.put(num, true);

                backtrack(res, nums, visited, list);

                // 回溯:撤销选择
                list.remove(list.size() - 1);
                visited.put(num, false);
            }
        }
    }
}


四、回溯过程逐层拆解

我们以 nums = [1,2,3] 为例,看代码到底在“跑什么”。

1. 初始状态

list = []
visited = {1:false, 2:false, 3:false}

此时还没选任何数字。


2. 第一层递归:选择第 1 个位置

for 循环遍历 nums

  • 选 1

    list = [1]
    visited[1] = true
    

进入下一层递归。


3. 第二层递归:选择第 2 个位置

此时可选的只剩 {2,3}

  • 选 2

    list = [1,2]
    visited[2] = true
    

继续递归。


4. 第三层递归:选择第 3 个位置

只剩下 3:

list = [1,2,3]
visited[3] = true

现在:

list.size() == nums.length

说明这是一个完整排列。


5. 为什么这里一定要 new?

res.add(new ArrayList<>(list));

此时 list 是一个会被继续修改的工作区

如果直接 res.add(list)

  • 后续 remove 会把已经存入 res 的内容一起改掉
  • 最终结果会全部变成空列表或重复内容

new ArrayList<>(list) 的含义是:

把当前路径的“快照”复制一份,永久保存下来。


6. 回溯(撤销选择)

list.remove(list.size() - 1);
visited.put(num, false);

这一步非常关键,它做了两件事:

  1. 把当前数字从排列中移除
  2. 标记该数字“可以被重新使用”

然后返回上一层,尝试新的分支。


五、visited 的作用本质

visited 并不是为了“去重排列”,而是为了:

保证一个数字在同一个排列中只出现一次

它限制的是「路径内部」,不是结果集合。

每次回溯结束后,visited 都会被恢复到进入该递归前的状态。


六、复杂度分析

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

    • 一共有 n! 种排列
    • 每次构造/复制需要 O(n)
  • 空间复杂度:O(n)

    • 递归深度为 n
    • listvisited 只占用线性空间

七、总结

这道全排列题目,本质上是在训练三件事:

  1. 如何用递归表示“选择 → 递进 → 撤销”
  2. 如何区分“工作区状态”和“最终结果”
  3. 如何理解回溯不是暴力,而是受约束的 DFS