1. 问题描述
给定一个不含重复数字的数组 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]]
2. 问题分析
这个问题可以看作有 n nn 个排列成一行的空格,我们需要从左往右依此填入题目给定的 n nn 个数,每个数只能使用一次。那么很直接的可以想到一种穷举的算法,即从左往右遍历该多叉树的每个叶子结点,每一条从根到叶子结点的路径就是一种排列,在程序中我们可以用「回溯法」来模拟这个过程。
class Solution {
//使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
//记录路径
ArrayList<Integer> track = new ArrayList<>();
//递归寻找所有的全排列
backtrack(nums, track);
return res;
}
//回溯
public void backtrack(int[] nums, ArrayList<Integer> track){
//到达叶子节点,将路径装入结果列表
if(track.size() == nums.length){
res.add(new ArrayList(track));
return;
}
for(int i = 0; i < nums.length; i++){
//排除不合法的选择
if(track.contains(nums[i]))
continue;
//做选择,加入一个元素
track.add(nums[i]);
//进入下一层决策树
backtrack(nums, track);
//撤销选择,删除最后一个元素
track.remove(track.size() - 1);
}
}
}
为什么这里是res.add(new ArrayList(track));
if (track.size() == n) {
res.add(track);
return;
}
变量 track 所指向的列表 在深度优先遍历的过程中只有一份 ,深度优先遍历完成以后,回到了根结点,成为空列表。
在 Java 中,参数传递是 值传递,对象类型变量在传参的过程中,复制的是变量的地址。这些地址被添加到 res 变量,但实际上指向的是同一块内存地址,因此我们会看到 6 个空的列表对象。解决的方法很简单,在 res.add(track)
; 这里做一次拷贝即可。
修改的部分:
if (track.size() == n) {
res.add(new ArrayList<>(track));
return;
}
但是,由于track.contains(nums[i])
的时间复杂度为O ( n ) O(n)O(n)量级。因此,我们可以换一种思路,即看作有 n nn 个排列成一行的空格,我们需要从左往右依此填入题目给定的 n nn 个数,每个数只能使用一次。那么很直接的可以想到一种穷举的算法,即从左往右每一个位置都依此尝试填入一个数,看能不能填完这 n nn 个空格。所以我们可以定义递归函数backtrack(first, track)
表示从左往右填到第f i r s t firstfirst个位置,当前排列为t r a c k tracktrack。那么整个递归函数分为两种情况:
(1)如果 first = = n \textit{first}==nfirst==n,说明我们已经填完了 n nn 个位置(注意下标从 0 开始),找到了一个可行的解,我们将 track \textit{track}track 放入答案数组中,递归结束。
(2)如果 first < n \textit{first}<nfirst<n,我们要考虑这第 first \textit{first}first 个位置我们要填哪个数。根据题目要求我们肯定不能填已经填过的数,因此很容易想到的一个处理手段是我们定义一个标记数组 visited [ ] \textit{visited}[]visited[] 来标记已经填过的数,那么在填第 first \textit{first}first 个数的时候我们遍历题目给定的 n nn 个数,如果这个数没有被标记过,我们就尝试填入,并将其标记,继续尝试填下一个位置,即调用函数 backtrack(first + 1, track)
。回溯的时候要撤销这一个位置填的数以及标记,并继续尝试其他没被标记过的数。
使用标记数组来处理填过的数是一个很直观的思路,但是可不可以去掉这个标记数组呢?毕竟标记数组也增加了我们算法的空间复杂度。答案是可以的,我们可以将题目给定的 n nn 个数的数组 nums \textit{nums}nums 划分成左右两个部分,左边的表示已经填过的数,右边表示待填的数,我们在回溯的时候只要动态维护这个数组即可。具体来说,假设我们已经填到第 first \textit{first}first 个位置,那么 nums \textit{nums}nums 数组中 [ 0 , first − 1 ] [0,\textit{first}-1][0,first−1] 是已填过的数的集合,[ first , n − 1 ] [\textit{first},n-1][first,n−1] 是待填的数的集合。我们肯定是尝试用 [ first , n − 1 ] [\textit{first},n-1][first,n−1] 里的数去填第 first \textit{first}first 个数,假设待填的数的下标为 i ii ,那么填完以后我们将第 i ii 个数和第 first \textit{first}first 个数交换,即能使得在填第 first + 1 \textit{first}+1first+1 个数的时候 nums \textit{nums}nums 数组的[ 0 , f i r s t ] [0,first][0,first] 部分为已填过的数,[ first + 1 , n − 1 ] [\textit{first}+1,n-1][first+1,n−1] 为待填的数,回溯的时候交换回来即能完成撤销操作。
举个简单的例子,假设我们有 [2, 5, 8, 9, 10] 这 5 个数要填入,已经填到第 3 个位置,已经填了 [8,9] 两个数,那么这个数组目前为 [8, 9 | 2, 5, 10] 这样的状态,分隔符区分了左右两个部分。假设这个位置我们要填 10 这个数,为了维护数组,我们将 2 和 10 交换,即能使得数组继续保持分隔符左边的数已经填过,右边的待填 [8, 9, 10 | 2, 5] 。当然善于思考的读者肯定已经发现这样生成的全排列并不是按字典序存储在答案数组中的,如果题目要求按字典序输出,那么请还是用标记数组或者其他方法。
class Solution {
List<List<Integer>> res = new ArrayList<List<Integer>>();
public List<List<Integer>> permute(int[] nums) {
List<Integer> track = new ArrayList<Integer>();
for (int num : nums) {
track.add(num);
}
int n = nums.length;
backtrack(n, track, 0);
return res;
}
public void backtrack(int n, List<Integer> track, int first) {
// 所有数都填完了
if (first == n) {
res.add(new ArrayList<Integer>(track));
}
for (int i = first; i < n; i++) {
// 动态维护数组
Collections.swap(track, first, i);
// 继续递归填下一个数
backtrack(n, track, first + 1);
// 撤销操作
Collections.swap(track, first, i);
}
}
}