LeetCode算法系列 47. 全排列 II

1,358 阅读4分钟

白菜Java自习室 涵盖核心知识

LeetCode算法系列(Java版 46). 全排列
LeetCode算法系列(Java版 47). 全排列 II

力扣原题

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]]

解题思路

这题是 46. 全排列 的延伸版本, 前置阅读推荐: LeetCode算法系列(Java版) 46. 全排列

回溯法 采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。

回溯法的公式:

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return
    
    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

全排列 的基础上增加了 序列中的元素可重复 这一条件,但要求:返回的结果又不能有重复元素

思路是:在遍历的过程中,一边遍历一遍检测,在一定会产生重复结果集的地方剪枝

剪枝

回溯算法会应用「剪枝」技巧达到以加快搜索速度。有些时候,需要做一些预处理工作(例如排序)才能达到剪枝的目的。预处理工作虽然也消耗时间,但能够剪枝节约的时间更多。

提示:剪枝是一种技巧,通常需要根据不同问题场景采用不同的剪枝策略,需要在做题的过程中不断总结。

如果要比较两个列表是否一样,一个容易想到的办法是对列表分别排序,然后逐个比对。既然要排序,我们就可以 在搜索之前就对候选数组排序,一旦发现某个分支搜索下去可能搜索到重复的元素就停止搜索,这样结果集中不会包含重复列表。

画出树形结构如下:重点想象深度优先遍历在这棵树上执行的过程,哪些地方遍历下去一定会产生重复,这些地方的状态的特点是什么? 对比图中标注 ① 和 ② 的地方。相同点是:这一次搜索的起点和上一次搜索的起点一样。不同点是:

  • 标注 ① 的地方上一次搜索的相同的数刚刚被撤销;
  • 标注 ② 的地方上一次搜索的相同的数刚刚被使用。

产生重复结点的地方,正是图中标注了「剪刀」,且被绿色框框住的地方。

大家也可以把第 2 个 1 加上 ' ,即 [1, 1', 2] 去想象这个搜索的过程。只要遇到起点一样,就有可能产生重复。这里还有一个很细节的地方:

  • 在图中 ② 处,搜索的数也和上一次一样,但是上一次的 1 还在使用中;
  • 在图中 ① 处,搜索的数也和上一次一样,但是上一次的 1 刚刚被撤销,正是因为刚被撤销,下面的搜索中还会使用到,因此会产生重复,剪掉的就应该是这样的分支

剪枝条件

分析到这里,很容易想到的办法是在结果集中去重。从结果集出发分析形如 [1, 1'] 的重复最小单元,可以得出一个结论:

假设 1 ≠ 1',那么在所有的结果集中,总能找到一对形如以下的 “兄弟结果”

  • [x1, x2, ..., 1, 1' , y1, y2, ...]
  • [x1, x2, ..., 1',1 , y1, y2, ...]

那么 当 nums[i] == nums[i - 1], (i-1 > 0)就是因此产生重复的剪枝条件

代码实现

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Stack;

class Solution {

    List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> permute(int[] nums) {
        // 在搜索之前就对候选数组排序
        Arrays.sort(nums);
        Stack<Integer> path = new Stack<>();
        this.backtrack(nums, path);
        return result;
    }

    private void backtrack(int[] nums, Stack<Integer> path) {
        if (nums.length <= 0) {
            result.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            // 剪枝
            if (i > 0 && nums[i] == nums[i - 1]) continue;
            int[] temp = new int[nums.length - 1];
            System.arraycopy(nums, 0, temp, 0, i);
            System.arraycopy(nums, i + 1, temp, i, nums.length - i - 1);
            path.push(nums[i]);
            backtrack(temp, path);
            path.pop();
        }
    }

    public static void main(String[] args) {
        int[] nums = new int[]{1, 1, 2};
        Solution solution = new Solution();
        List<List<Integer>> result = solution.permute(nums);
        System.out.println(result);
    }

}

输出结果:

[[1, 1, 2], [1, 2, 1], [2, 1, 1]]

复杂度分析

(理由同第 46 题,重复元素越多,剪枝越多。但是计算复杂度的时候需要考虑最差情况。)

  • 时间复杂度: O(N×N!)O(N \times N!)

  • 空间复杂度: O(N×N!)O(N \times N!)

LeetCode算法系列(Java版) 46. 全排列
LeetCode算法系列(Java版) 47. 全排列 II