题目描述
给定一个不含重复数字的数组 nums,返回它的所有可能的全排列。
答案可以按任意顺序返回。
例如:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
初始思路
一开始可以把这道题归类为"每一步选哪个数"的问题。
因为数组中的数字不重复,所以只需要用一个布尔数组 onPath 记录每个下标对应的数字是否已经被选过。
递归过程中,每一层从 nums 中枚举一个还没有选过的数,把它加入当前路径 path,继续往下一层搜索。
解题思路
这题的关键是先明确 dfs(i) 的含义。
定义:
dfs(i) 表示当前正在填排列中的第 i 个位置
当 i == nums.length 时,说明已经填完了一个完整排列,此时把 path 拷贝一份加入答案。
为什么要拷贝一份?
因为 path 在后续回溯过程中还会继续被修改。如果直接加入 path 本身,答案里的内容也会被后续修改影响。
每一层递归做的事情是:
1. 枚举 nums 中的每个位置 j
2. 如果 nums[j] 没有在当前路径中,就选择它
3. 标记 onPath[j] = true,并把 nums[j] 加入 path
4. 递归填写下一个位置
5. 递归结束后撤销选择
也就是典型的回溯模板:
做选择
递归
撤销选择
代码实现
class Solution {
List<List<Integer>> ans;
public List<List<Integer>> permute(int[] nums) {
ans = new ArrayList<>();
int len = nums.length;
boolean[] onPath = new boolean[len];
List<Integer> path = new ArrayList<>();
dfs(0, path, nums, onPath);
return ans;
}
public void dfs(int i, List<Integer> path, int[] nums, boolean[] onPath) {
if (i == nums.length) {
ans.add(new ArrayList<>(path));
return;
}
for (int j = 0; j < nums.length; j++) {
if (!onPath[j]) {
path.add(nums[j]);
onPath[j] = true;
dfs(i + 1, path, nums, onPath);
path.remove(path.size() - 1);
onPath[j] = false;
}
}
}
}
为什么这样写
全排列可以理解为"从左到右依次填位置"。
假设 nums = [1, 2, 3],递归树可以这样理解:
第 0 个位置:可以选 1、2、3
第 1 个位置:只能从剩下没选过的数里选
第 2 个位置:继续从剩下没选过的数里选
每条从根节点走到叶子节点的路径,都是一个完整排列。
onPath[j] 的作用就是保证每个数字在同一个排列中只会被使用一次。
易错点
1. 没有说清楚 dfs 的含义
这题不能只模糊地理解成"递归选数"。
更准确的定义应该是:
dfs(i):当前正在填第 i 个位置
有了这个定义,终止条件 i == nums.length 就很自然。
2. 忘记撤销选择
递归前做了两件事:
path.add(nums[j]);
onPath[j] = true;
递归后就必须对应撤销:
path.remove(path.size() - 1);
onPath[j] = false;
否则当前分支的选择会影响后面的分支。
3. 加入答案时没有拷贝 path
不能写成:
ans.add(path);
因为 path 是同一个列表对象,后续还会继续修改。
正确写法是:
ans.add(new ArrayList<>(path));
4. removeLast() 的兼容性
如果 path 声明为 List<Integer>,并且使用的是常见的 ArrayList 写法,更稳妥的是:
path.remove(path.size() - 1);
不要依赖 removeLast(),避免在不同 Java 版本或平台环境下出现兼容问题。
复杂度分析
- 时间复杂度:
O(n * n!)。一共有n!个排列,每个排列拷贝到答案中需要O(n)。 - 空间复杂度:
O(n)。递归栈、path和onPath都需要O(n);如果把返回结果也计入空间,则为O(n * n!)。
复盘
这题的回溯框架并不复杂,核心问题是要把递归函数的含义定义清楚。
我一开始的方向是对的:用布尔数组记录哪些数已经选过,再用 DFS 枚举所有可能。
真正需要修正的是:不要只停留在"选哪个数"的感觉上,而要明确当前递归层正在填排列中的哪个位置。这样终止条件、选择逻辑和撤销逻辑都会更清楚。
Tips
全排列这类题可以记住一句话:
每一层填一个位置,每个位置枚举所有还没选过的数。
只要递归前后能做到"选择"和"撤销选择"严格对应,代码就不容易乱。