46. 全排列
问题描述
给定一个不含重复数字的数组 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]]
算法思路
回溯法(DFS)
:
- 遍历数组中每个数字
- 若当前数字未被使用,则将其加入当前路径
- 递归处理剩余数字
- 回溯时移除最后添加的数字
- 当路径长度等于数组长度时,保存结果
核心:
- 使用布尔数组
used
标记数字使用状态 - 递归树深度 = 数组长度
- 每层递归尝试所有未使用的数字
代码实现
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 边界情况:空数组直接返回空结果
if (nums == null || nums.length == 0) return result;
// 创建路径列表和标记数组
List<Integer> path = new ArrayList<>();
boolean[] used = new boolean[nums.length];
// 启动回溯
backtrack(nums, used, path, result);
return result;
}
/**
* 回溯核心方法
*
* @param nums 输入数组
* @param used 标记数组,记录元素是否已使用
* @param path 当前路径(当前排列)
* @param result 结果集
*/
private void backtrack(int[] nums, boolean[] used,
List<Integer> path, List<List<Integer>> result) {
// 终止条件:路径长度等于数组长度
if (path.size() == nums.length) {
result.add(new ArrayList<>(path)); // 创建新列表保存当前排列
return;
}
// 遍历所有数字
for (int i = 0; i < nums.length; i++) {
// 跳过已使用的数字
if (used[i]) continue;
// 选择当前数字
path.add(nums[i]);
used[i] = true;
// 递归进入下一层
backtrack(nums, used, path, result);
// 回溯:撤销选择
path.remove(path.size() - 1);
used[i] = false;
}
}
}
算法分析
- 时间复杂度:O(n × n!)
- 排列总数
n!(n 的阶乘)
- 每个排列需要 O(n) 时间复制到结果中
- 排列总数
- 空间复杂度:O(n)
- 递归栈深度 O(n)
- 标记数组 O(n)
- 路径列表 O(n)
算法过程
nums = [1,2,3]
开始:path=[], used=[F,F,F]
├─ 选1: path=[1], used=[T,F,F]
│ ├─ 选2: path=[1,2], used=[T,T,F]
│ │ └─ 选3: path=[1,2,3] → 加入结果
│ ├─ 选3: path=[1,3], used=[T,F,T]
│ │ └─ 选2: path=[1,3,2] → 加入结果
├─ 选2: path=[2], used=[F,T,F]
│ ├─ 选1: path=[2,1], used=[T,T,F]
│ │ └─ 选3: path=[2,1,3] → 加入结果
│ ├─ 选3: path=[2,3], used=[F,T,T]
│ │ └─ 选1: path=[2,3,1] → 加入结果
├─ 选3: path=[3], used=[F,F,T]
│ ├─ 选1: path=[3,1], used=[T,F,T]
│ │ └─ 选2: path=[3,1,2] → 加入结果
│ └─ 选2: path=[3,2], used=[F,T,T]
│ └─ 选1: path=[3,2,1] → 加入结果
结果:6种排列
关键点
-
回溯
三要素
:选择
:将未使用的数字加入路径递归
:处理下一层决策撤销
:回溯时移除最后添加的数字
-
结果保存:
- 必须创建新列表
new ArrayList<>(path)
- 直接添加
path
会导致结果被后续修改影响
- 必须创建新列表
-
标记数组优化:
- 比
path.contains()
更高效( O(1) vs O(n) )
- 比
测试用例
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1:标准示例
int[] nums1 = {1,2,3};
System.out.println("Test 1: " + solution.permute(nums1));
// 预期:6种排列 [[1,2,3],[1,3,2],...]
// 测试用例2:单个元素
int[] nums2 = {1};
System.out.println("Test 2: " + solution.permute(nums2)); // [[1]]
// 测试用例3:两个元素
int[] nums3 = {0,1};
System.out.println("Test 3: " + solution.permute(nums3)); // [[0,1],[1,0]]
// 测试用例4:空数组
int[] nums4 = {};
System.out.println("Test 4: " + solution.permute(nums4)); // []
// 测试用例5:含负数
int[] nums5 = {-1,0,1};
System.out.println("Test 5: " + solution.permute(nums5).size()); // 6
}
常见问题
-
为什么需要创建新列表
new ArrayList<>(path)
?
直接添加path
会导致结果集中所有元素指向同一个列表对象,回溯修改
会影响已保存的结果。 -
如何处理含重复数字的全排列?
本题前提是不含重复数字。若含重复数字,需先排序,然后添加跳过条件:if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
-
能否不使用标记数组?
可以,但效率较低(每次用path.contains()
判断,时间复杂度升至 O(n! × n²))。 -
递归树深度如何?
递归深度 = 数组长度 n,当 n 较大时需注意栈溢出风险(通常 n≤10 可用此解法)。
47. 全排列 II
问题描述
给定一个可包含重复数字的序列 nums
,返回所有不重复的全排列。可以按任意顺序返回答案。
示例:
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
算法思路
回溯法 + 剪枝:
- 排序预处理:将数组排序,使
重复元素相邻
- 回溯框架:
- 遍历每个可用的数字
- 添加当前数字到路径
- 递归处理下一层
- 回溯移除最后添加的数字
- 剪枝关键:
跳过已使用的数字
跳过重复数字
:当前数字与前一个相同,且前一个未被使用(树层剪枝)
树层剪枝
原理:
- 当
nums[i] == nums[i-1]
且!used[i-1]
时 - 说明前一个相同数字在当前位置已尝试过所有组合
- 当前选择会导致重复排列,直接跳过
误区
:
- 在排列问题中,去重的条件应该是:当当前元素与前一个元素相同,并且前一个元素没有被使用(即在
同一层递归
中,已经使用过相同的元素),则跳过。这是因为:- 如果前一个相同的元素已经被使用过
(即used[i-1]为true)
,说明在同一个路径
(递归深度)中,我们正在使用这个重复元素,这是允许
的。 - 如果前一个相同的元素没有被使用
(即used[i-1]为false)
,说明在当前的递归层
中,前一个相同的元素已经被撤销选择(回溯)
,那么再选择当前元素就会产生重复
。
- 如果前一个相同的元素已经被使用过
代码实现
class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
if (nums == null || nums.length == 0) return result;
Arrays.sort(nums); // 关键:排序使重复元素相邻
boolean[] used = new boolean[nums.length];
backtrack(nums, used, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, boolean[] used,
List<Integer> path, List<List<Integer>> result) {
// 完成条件:路径长度等于数组长度
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// 跳过已使用的元素
if (used[i]) continue;
/* 关键剪枝:避免重复排列
* 条件1:当前元素与前一个相同 (i>0 && nums[i]==nums[i-1])
* 条件2:前一个元素未被使用 (!used[i-1])
*
* 解释:前一个相同元素未被使用,说明在当前位置已尝试过该值
* 继续使用当前值会产生重复排列
*/
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) {
continue;
}
// 选择当前元素
used[i] = true;
path.add(nums[i]);
// 递归进入下一层
backtrack(nums, used, path, result);
// 回溯:撤销选择
used[i] = false;
path.remove(path.size() - 1);
}
}
}
算法分析
- 时间复杂度:O(n × n!)
- 最坏情况无重复:O(n! × n)(n! 种排列,每个排列 O(n) 复制)
- 有重复时实际递归次数小于 n!
- 空间复杂度:O(n)
- 递归栈深度 O(n)
- 标记数组 O(n)
- 路径列表 O(n)
算法过程
nums = [1,1,2]
排序后:nums = [1,1,2]
开始:path=[], used=[F,F,F]
├─ i=0: 选第一个1 → used=[T,F,F], path=[1]
│ ├─ i=1: 剪枝条件不成立
│ │ used[0]=true → 不跳过 → 选第二个1 → used=[T,T,F], path=[1,1]
│ │ └─ 选2 → used=[T,T,T], path=[1,1,2] → 加入结果
│ └─ i=2: 选2 → used=[T,F,T], path=[1,2]
│ └─ 选第二个1 → used=[T,T,T], path=[1,2,1] → 加入结果
├─ i=1: 剪枝条件成立 (i=1>0, nums[1]==nums[0]==1, 且 !used[0] 为 true) → 跳过
└─ i=2: 选2 → used=[F,F,T], path=[2]
├─ i=0: 选第一个1 → used=[T,F,T], path=[2,1]
│ └─ i=1: 选第二个1 → used=[T,T,T], path=[2,1,1] → 加入结果
└─ i=1: 剪枝条件成立 → 跳过
最终得到3个不重复排列
关键点
排序预处理
:使重复元素相邻
,便于剪枝判断
- 树层剪枝条件:
i > 0 && nums[i] == nums[i-1] && !used[i-1]
- 去重本质:同一树层中,对重复数字只使用第一个未被使用的
- 结果保存:必须
new ArrayList<>(path)
创建新列表
剪枝条件
条件 | 目的 |
---|---|
i > 0 | 确保有前一个元素 |
nums[i] == nums[i-1] | 当前元素与前一个相同 |
!used[i-1] | 前一个相同元素未被使用(说明在当前位置已尝试过该值) |
测试用例
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1:标准示例
int[] nums1 = {1,1,2};
System.out.println("Test 1: " + solution.permuteUnique(nums1));
// 预期:[[1,1,2],[1,2,1],[2,1,1]]
// 测试用例2:全相同元素
int[] nums2 = {1,1,1};
System.out.println("Test 2: " + solution.permuteUnique(nums2)); // [[1,1,1]]
// 测试用例3:无重复元素
int[] nums3 = {1,2,3};
System.out.println("Test 3: " + solution.permuteUnique(nums3).size()); // 6
// 测试用例4:含多个重复组
int[] nums4 = {2,2,1,1};
System.out.println("Test 4: " + solution.permuteUnique(nums4));
/* 预期:
[[1,1,2,2],[1,2,1,2],[1,2,2,1],
[2,1,1,2],[2,1,2,1],[2,2,1,1]]
*/
// 测试用例5:单个元素
int[] nums5 = {0};
System.out.println("Test 5: " + solution.permuteUnique(nums5)); // [[0]]
}
常见问题
-
为什么需要
!used[i-1]
条件?
保证只对树层
去重:当相同元素的前一个实例未被使用时,说明在当前位置已尝试过该值的排列,跳过当前重复值。 -
排序是否必须?
是,排序使重复元素相邻,剪枝条件才能生效。 -
如何处理
[1,1,2]
中的两个1?
通过剪枝条件,在第二层递归时跳过第二个1的直接选择,但允许在第一个1使用后选择第二个1。 -
与46题的区别?
46题不含重复数字,无需排序和剪枝;本题含重复数字,需额外处理重复情况。 -
剪枝条件中
!used[i-1]
改为used[i-1]
会怎样?
会导致错误跳过有效排列(树枝剪枝),最终结果会遗漏部分排列。