【46】全排列

1. 题目描述

image.png

2. 题目链接

https://leetcode.cn/problems/permutations/

3. 思路

这个问题可以看作有【46、47】全排列 - 图2个排列成一行的空格,我们需要从左往右依此填入题目给定的【46、47】全排列 - 图3个数,每个数只能使用一次。那么最直接的一种方法,就是从左往右每一个位置都依此尝试填入一个数,看能不能填完这【46、47】全排列 - 图4个空格,在程序中我们可以用「回溯法」来模拟这个过程。

我们定义递归函数【46、47】全排列 - 图5表示从左往右填到第【46、47】全排列 - 图6个位置,当前排列为【46、47】全排列 - 图7。 那么整个递归函数分为两个情况:

  • 如果【46、47】全排列 - 图8,说明我们已经填完了【46、47】全排列 - 图9个位置(注意下标从【46、47】全排列 - 图10开始),找到了一个可行的解,我们将【46、47】全排列 - 图11放入答案数组中,递归结束。

  • 如果【46、47】全排列 - 图12,我们要考虑这第【46、47】全排列 - 图13个位置填哪个数。根据题目要求我们不能填已经填过的数,因此我们填充一个之前没有填过的数,然后继续填下一个位置,即调用函数【46、47】全排列 - 图14。回溯时要撤销这一个位置填的数及标记并继续尝试其他没被标记过的数。

如何判断数字是否填充过?
我们可以将题目给定的【46、47】全排列 - 图15个数的数组【46、47】全排列 - 图16划分成左右两个部分,左边的表示已经填过的数,右边表示待填的数,我们在回溯的时候只要动态维护这个数组即可。

具体来说,假设我们已经填到第【46、47】全排列 - 图17个位置,那么【46、47】全排列 - 图18数组中【46、47】全排列 - 图19是已填过的数的集合,【46、47】全排列 - 图20是待填的数的集合。我们肯定是尝试用【46、47】全排列 - 图21里的数去填第【46、47】全排列 - 图22个数,假设待填的数的下标为【46、47】全排列 - 图23,那么填完后我们将第【46、47】全排列 - 图24个数和第【46、47】全排列 - 图25个数交换,即能使得在填第【46、47】全排列 - 图26个数时【46、47】全排列 - 图27数组的【46、47】全排列 - 图28部分为已填过的数,【46、47】全排列 - 图29为待填的数,回溯时交换回来即能完成撤销操作。

下图展示了回溯的整个过程:
image.png

4. 代码实现

  1. public List<List<Integer>> permute(int[] nums) {
  2. List<List<Integer>> ans = new ArrayList<>();
  3. List<Integer> output = new ArrayList<>();
  4. for (int num : nums) {
  5. output.add(num);
  6. }
  7. backtrack(nums.length, 0, output, ans);
  8. return ans;
  9. }
  10. private void backtrack(int n, int first, List<Integer> output, List<List<Integer>> ans) {
  11. // 所有的数都填完了
  12. if (first == n) {
  13. ans.add(new ArrayList<>(output));
  14. return;
  15. }
  16. for (int i = first; i < n; i++) {
  17. // 动态维护下一个数
  18. Collections.swap(output, i, first);
  19. backtrack(n, first + 1, output, ans);
  20. // 回溯,再把顺序交换回来
  21. Collections.swap(output, i, first);
  22. }
  23. }
  • 时间复杂度:【46、47】全排列 - 图31,其中【46、47】全排列 - 图32为序列的长度。算法的复杂度首先受【46、47】全排列 - 图33的调用次数制约,【46、47】全排列 - 图34的调用次数为【46、47】全排列 - 图35次,其中【46、47】全排列 - 图36,该式被称作 n 的 k - 排列。而【46、47】全排列 - 图37,这说明【46、47】全排列 - 图38的调用次数是【46、47】全排列 - 图39的。而对于【46、47】全排列 - 图40调用的每个叶结点(共【46、47】全排列 - 图41个),我们需要将当前答案使用【46、47】全排列 - 图42的时间复制到答案数组中,相乘得时间复杂度为【46、47】全排列 - 图43

  • 空间复杂度:【46、47】全排列 - 图44,其中【46、47】全排列 - 图45为序列的长度。除答案数组外,递归函数在递归过程中需要为每一层递归函数分配栈空间,所以这里需要额外的空间且该空间取决于递归的深度,可知递归调用深度为【46、47】全排列 - 图46

    【47】全排列Ⅱ

    1. 题目描述

    image.png

    2. 题目链接

    https://leetcode.cn/problems/permutations-ii/

    3. 思路

    此题是「46. 全排列」的进阶,序列中包含了重复的数字,并要求我们返回不重复的全排列,那么我们依然可以选择使用搜索回溯的方法来做。但相比「46. 全排列」的做法,我们不能再将数组【46、47】全排列 - 图48划分成左右两个部分以此来判断数字是否填充过了。因为我们需要维护顺序性,用于过滤掉重复元素。

我们定义一个标记数组【46、47】全排列 - 图49来标记已经填过的数,在填第【46、47】全排列 - 图50个数的时候,我们遍历题目给定的【46、47】全排列 - 图51个数,如果这个数字没有被标记过,我们就尝试填入并将其标记,然后继续尝试填下一个位置。当回溯时要撤销该位置填的数以及标记,并继续尝试其他没被标记过的数。

但是单纯采用这种方式并不能满足「全排列不重复」 的要求,在递归函数中我们会生成大量重复的排列,因为对于第【46、47】全排列 - 图52的位置,如果存在重复的数字【46、47】全排列 - 图53,我们每次会将重复的数字都重新填上去并继续尝试导致最后答案的重复,因此我们需要处理这个情况。

要解决重复问题,我们只要设定一个规则,保证在填第【46、47】全排列 - 图54个数的时候重复数字只会被填入一次即可。为此,我们选择对原数组升序排序,保证相同的数字都相邻,然后每次填入的数一定是这个数所在重复数集合中「从左往右第一个未被填过的数字」。即如下的判断条件:

  1. if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
  2. continue;
  3. }

这个判断条件保证了对于重复数的集合,一定是从左往右逐个填入的。为什么能够这样保证呢?【46、47】全排列 - 图55是用来判断元素是否重复的,我们这里重点解释一下 !used[i - 1] 的作用:

加上 !used[i - 1] 来去重主要是用来限制两个相邻重复数字的访问顺序。假设有两个重复的数字【46、47】全排列 - 图56,如果我们不去重,会有两种重复排列【46、47】全排列 - 图57,但实际上我们只需要取其中任意一种排列即可。为此,我们需要限制一下【46、47】全排列 - 图58的访问顺序。假设我们只需要取【46、47】全排列 - 图59那个排列的话,则要求我们只有先访问了【46、47】全排列 - 图60(nums[i-1])之后才能去访问【46、47】全排列 - 图61(nums[i]),而如果 !used[i-1] 成立,则表示我们还没有访问【46、47】全排列 - 图62时就想要访问【46、47】全排列 - 图63,此时我们就需要跳过【46、47】全排列 - 图64这个排列。

以数组【46、47】全排列 - 图65为例,回溯的具体过程如下图所示:
image.png
得到的全排列是:【46、47】全排列 - 图67。特点是:【46、47】全排列 - 图68,即出现的顺序只能是先【46、47】全排列 - 图69【46、47】全排列 - 图70

4. 代码实现

  1. public static List<List<Integer>> permuteUnique(int[] nums) {
  2. List<List<Integer>> ans = new ArrayList<>();
  3. // 排序,排序是剪枝的前提
  4. Arrays.sort(nums);
  5. // 用来标记已填充的数字
  6. boolean[] used = new boolean[nums.length];
  7. backtrack(0, nums, used, new ArrayList<>(), ans);
  8. return ans;
  9. }
  10. private static void backtrack(int idx, int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> ans) {
  11. if (idx == nums.length) {
  12. ans.add(new ArrayList<>(path));
  13. return;
  14. }
  15. for (int i = 0; i < nums.length; i++) {
  16. // 不能重复选择同一个数字
  17. if (used[i]) {
  18. continue;
  19. }
  20. // 剪枝条件:i > 0 是为了保证 nums[i - 1] 有意义
  21. if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
  22. continue;
  23. }
  24. // 选择当前元素
  25. path.add(nums[i]);
  26. used[i] = true;
  27. // 递归寻找下一个元素
  28. backtrack(idx + 1, nums, used, path, ans);
  29. // 回溯
  30. used[i] = false;
  31. path.remove(path.size() - 1);
  32. }
  33. }