目录

题目描述

LCR 084. 全排列 II

题意分析

给一个可包含重复数字的整数数组 nums,返回所有不重复的全排列。与上一题相比只改了一个前提——元素不再互不相同——但答案的判定标准随之变了:以前不同的下标选法必然给出不同的值序列,现在两个相同的值互换位置会产出一模一样的排列,必须只保留一份。

明确一下重复的来源。数组里若有 $k$ 个相同的值,它们之间的 $k!$ 种内部排布在值序列上是不可区分的。例如 [1,1,2],把两个 1 当成可区分的个体会得到 $3! = 6$ 条路径,但去掉不可区分性后只有 3 个不同排列。所以本题的任务是:在搜索阶段就让每组相同值只按一种固定次序被取用,而不是搜完再拿哈希集合过滤。

数组长度上界只有 8,$8! = 40320$,即使不加任何去重也跑得完,但输出会带重复,判定不通过。这说明本题考的完全是正确性而非性能——出题人要看的是能否设计出无重无漏的枚举结构。元素取值 [-10, 10] 含负数,排除掉一切以值为下标的标记技巧。

边界:全部元素相同(如 [2,2,2])时答案只有 1 条,是检验去重是否彻底的最强用例;全部互不相同时应退化回 $n!$ 条,是检验有没有去重过头的用例。这两个极端都要能过。

解法:回溯搜索

核心思路

先沿用上一题的骨架:used 标记下标是否已用,第 u 层决定排列的第 u 位,横向枚举所有未用下标。这套结构会把 [1,1,2] 的两个 1 当成不同个体,产出 6 条路径,其中 [1,1,2] 出现两次、[1,2,1] 出现两次、[2,1,1] 出现两次。瓶颈很清楚:相同值之间的相对使用次序被枚举了 $k!$ 遍,而这些次序在答案里不可见

关键观察是:既然相同值的内部次序不可见,那就人为规定一种唯一合法的次序——排序后让相同的值相邻,并强制它们必须从左往右依次被使用。也就是说,只有在 nums[i-1] 已经进入当前路径之后,才允许使用 nums[i]。这样 $k$ 个相同值的 $k!$ 种内部排布中,只有「按下标从小到大」那一种能走通,重复被压缩到恰好一份。

落成条件就是:当 i > 0 && nums[i] == nums[i-1] && !used[i-1] 时跳过下标 i。要维持的不变量是:pathu 位已填好,used 中恰有 utrue,且对任意一组相同值,被标记为已用的那些下标构成该组的一个前缀。最后这一条正是去重的本体。

值得强调的是这个条件的否定形式used[i-1] == true 时不跳过。这看起来反直觉——「前一个相同值已经用了,我还能用」——但恰恰是对的,因为这说明我们正沿着「从左往右依次取用」的合法次序前进,[1,1,2] 里两个 1 都要被用到,正是靠这一条才没被误杀。反过来 !used[i-1] 意味着前一个相同值在本条路径的更早某层被用过又撤销了,也就是说以相同值开头的这棵子树刚刚已经完整搜过一遍,再搜就是重复。

解题步骤

  • 先排序Arrays.sort(nums)。与组合去重同理,只有相同值相邻,nums[i] == nums[i-1] 才能等价于「同一组相同值」。不排序的话 [1,2,1] 里两个 1 隔着一个 2,条件根本不触发。
  • 准备 pathused:语义与上一题完全一致,used[i] 表示下标 i 的元素是否已在当前路径中。
  • 递归基u == n 时深拷贝 path 存入结果并返回。
  • 横向枚举所有下标并做两重过滤if (used[i] || (i > 0 && nums[i] == nums[i - 1] && !used[i - 1])) continue;。第一重 used[i] 保证「每个位置只用一次」;第二重是去重,三个子条件缺一不可——i > 0 防越界,nums[i] == nums[i-1] 限定在同一组相同值内,!used[i-1] 才是真正的判据。
  • 选择、递归、撤销path.addused[i] = true 成对写在递归前,used[i] = falsepath.remove 成对写在递归后。Go 版把 path 预分配成定长切片并按位赋值 path[u] = nums[i],因此只需撤销 used

nums = [1, 1, 2] 走一遍(已有序),下标记作 0、1、2。

dfs(u=0)used = [F,F,F]

i = 0:值 1,i > 0 不成立,过滤不触发。path = [1]used = [T,F,F],进入 dfs(1)

dfs(1) 层:i = 0 已用跳过;i = 1 值 1 与前一个相同,但 used[0] == true不跳过——这正是保留 [1,1,...] 的关键。path = [1,1]used = [T,T,F],进入 dfs(2);该层只有 i = 2 可用,得到第一个排列 [1,1,2]。回溯撤销后,dfs(1) 继续 i = 2path = [1,2]used = [T,F,T],进入 dfs(2);该层 i = 1 值 1 与前一个相同且 used[0] == true,不跳过,得到第二个排列 [1,2,1]

回溯到 dfs(0),撤销后 used = [F,F,F]i = 1:值 1 与 nums[0] 相同且 used[0] == false跳过。这次跳过挡掉的子树是「以第二个 1 开头」的全部路径,而它与刚刚搜完的「以第一个 1 开头」完全同构,正是重复来源。

i = 2:值 2,nums[2] != nums[1],不跳过。path = [2]used = [F,F,T],进入 dfs(1);该层 i = 0 可用,path = [2,1]used = [T,F,T],进入 dfs(2)i = 1used[0] == true 不跳过,得到第三个排列 [2,1,1]。回到 dfs(1)i = 1:此时 used[0] == false,跳过,避免重复产出 [2,1,1]

最终答案恰为 [[1,1,2],[1,2,1],[2,1,1]] 三条。若把条件里的 !used[i-1] 写成 used[i-1],则 dfs(1) 层的 i = 1 会被跳过,[1,1,2][1,2,1] 都搜不到,输出只剩 [2,1,1]

代码实现

class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        int n = nums.length;
        boolean[] used = new boolean[n];
        // 排序让相同值相邻,是去重条件成立的前提。
        Arrays.sort(nums);
        dfs(0, n, nums, used, path, res);
        return res;
    }

    // u:当前要填充的位置。
    private void dfs(
        int u, int n, int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
        if (u == n) {
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < n; ++i) {
            // used[i]:该下标已用;后半段:同组相同值必须从左往右依次取用。
            if (used[i] || (i > 0 && nums[i] == nums[i - 1] && !used[i - 1])) {
                continue;
            }
            path.add(nums[i]);
            used[i] = true;
            dfs(u + 1, n, nums, used, path, res);
            used[i] = false;
            path.remove(path.size() - 1);
        }
    }
}
func permuteUnique(nums []int) [][]int {
    n := len(nums)
    res := make([][]int, 0)
    // path 预分配定长,按位赋值,不需要显式撤销。
    path := make([]int, n)
    used := make([]bool, n)
    // 排序让相同值相邻,是去重条件成立的前提。
    sort.Ints(nums)
    dfs(0, n, nums, used, path, &res)
    return res
}

// u:当前要填充的位置。
func dfs(u, n int, nums []int, used []bool, path []int, res *[][]int) {
    if u == n {
        t := make([]int, n)
        copy(t, path)
        *res = append(*res, t)
        return
    }
    for i := 0; i < n; i++ {
        // used[i]:该下标已用;后半段:同组相同值必须从左往右依次取用。
        if used[i] || (i > 0 && nums[i] == nums[i-1] && !used[i-1]) {
            continue
        }
        path[u] = nums[i]
        used[i] = true
        dfs(u+1, n, nums, used, path, res)
        used[i] = false
    }
}

复杂度分析

  • 时间复杂度:$O(n \times n!)$ 的上界,其中 $n$ 是数组长度。若数组中互不相同的值有各自的重数 $k_1, k_2, \dots$,实际产出的排列数是 $n! / (k_1! k_2! \cdots)$,去重条件把剩下的同构子树在进入前就砍掉,因此实际访问的节点数与答案规模同阶。每条答案拷贝耗时 $O(n)$,排序的 $O(n \log n)$ 可忽略。
  • 空间复杂度:$O(n)$,递归栈深度恰为 npathused 各占 $O(n)$。返回值按惯例不计入额外空间。

关键点总结

  • 排列去重的判据是「同组相同值必须按下标从左往右依次使用」,实现成 nums[i] == nums[i-1] && !used[i-1] 时跳过。这条规则给每个可区分的排列指定了唯一一条生成路径,是搜索去重的通用思路:为等价类挑一个代表元
  • !used[i-1] 而不是 used[i-1] 是本题最大的坑,也是面试官必问的一点。答题时不要背口诀,要能说出「!used[i-1] 意味着以相同值开头的同构子树刚被完整搜过」。
  • 组合去重与排列去重看似相似其实判据不同:组合里只需 i > u(同层跳过),排列里必须借助 used[i-1],因为排列的循环从 0 开始,「同层」这个概念不足以刻画重复。把这两者的差别讲清楚,比会写代码更能体现理解深度。
  • 排序是所有「相邻相同值」判据的前置条件,与题目是否要求有序无关。
  • 用极端用例自查:全相同的输入应只产出 1 条,全不同的输入应产出 $n!$ 条。这两个数字能同时对上,去重逻辑基本就没问题了。

易错点总结

  • 忘记排序nums = [1,2,1] 时两个 1 不相邻,去重条件从不触发,输出 6 条含重复的排列。
  • 条件写成 used[i-1] 而非 !used[i-1]nums = [1,1,2] 会丢掉 [1,1,2][1,2,1],只输出 [2,1,1]
  • 漏掉 i > 0 的边界判断i = 0 时访问 nums[-1],Java 抛数组越界异常,Go 直接 panic。
  • 漏掉 nums[i] == nums[i-1],只判 !used[i-1]nums = [1,2,3] 时几乎所有分支都被误杀,输出只剩 [1,2,3] 一条。
  • 忘记 used[i] = falsenums = [1,2] 时首条排列产出后标记再不复位,后续分支全进不去,答案只剩 1 条。
  • Java 里只撤销 used 而忘了 path.removenums = [1,1,2]path 持续膨胀,第二条结果被记成长度大于 3 的数组。
  • path 引用直接放进 resnums = [1,1,2] 最终输出 3 个内容相同的空列表。
  • Go 里写 *res = append(*res, path) 不做 copy:三条结果共享同一底层数组,全部变成最后一条 [2,1,1]
  • 改用 Set<List<Integer>> 事后去重nums = [1,1,1,1,1,1,1,1] 时搜索树仍是 $8! = 40320$ 条路径,虽然勉强能过但白白多做了 40319 次无用功,面试中会被判定为没解决问题本身。
  • 在排序后用「跳过与前一个相同的值」这种组合式去重(即 i > 0 && nums[i] == nums[i-1] 就跳过)nums = [1,1,2] 会连 [1,1,2] 都搜不出来,因为第二个 1 在任何层都被无条件屏蔽,输出只有 [1,2,...] 形态的残缺结果。

相似题目

题目 难度 考察点
47. 全排列 II 中等 与本题完全同题,代码可原样提交
46. 全排列 中等 元素互不相同,无需排序与去重条件,是本题剥掉去重后的骨架
LCR 083. 全排列 中等 与 46 同题,适合与本题并排对读,只差一行去重判断
面试题 08.08. 有重复字符串的排列组合 中等 把数组换成字符串,去重判据一字不改,可验证模板的迁移性
剑指 Offer 38. 字符串的排列 中等 字符串含重复字符,返回值是 String[],需注意拼接时机
面试题 08.07. 无重复字符串的排列组合 中等 字符串版的 46,可对照体会「有无重复」在代码上的唯一差别
90. 子集 II 中等 同样处理重复元素,但去重判据换成组合式的 i > u,可对比两种写法
60. 排列序列 困难 元素固定为 1~n 无重复,但只求第 k 个,需用阶乘逐位定位而非枚举
LCR 082. 组合总和 II 中等 重复元素的组合版去重,与本题构成「组合 vs 排列」的完整对照