数据结构与算法 - 排列问题:全排列与去重的处理技巧

全排列与去重算法详解

在这里插入图片描述

👋 大家好,欢迎来到我的技术博客!
💻 作为一名热爱 Java 与软件开发的程序员,我始终相信:清晰的逻辑 + 持续的积累 = 稳健的成长
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕数据结构与算法这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!


数据结构与算法 - 排列问题:全排列与去重的处理技巧 🔄

在算法的世界里,排列(Permutation) 是一类基础而重要的组合问题。它要求我们对一组元素进行重新排序,生成所有可能的顺序组合。与“组合”不同,排列关注元素的顺序——[1,2][2,1] 被视为两个不同的解。

排列问题看似简单,但一旦引入重复元素,情况就变得复杂起来:如何避免生成重复的排列?如何在保证正确性的同时提升效率?这些问题正是面试和竞赛中的高频考点。

而解决排列问题最经典、最通用的方法,就是 回溯算法(Backtracking)。通过“递归 + 选择 + 撤销选择”的框架,我们可以系统地遍历所有可能的排列。但要处理重复元素,还需引入去重技巧——这正是本文的核心。

本文将从最基础的无重复全排列出发,逐步深入到含重复元素的全排列(Permutations II),并通过大量 Java 代码示例mermaid 可视化图表真实可访问的外站资源链接,全面剖析排列问题的实现逻辑与去重策略。

无论你是准备 LeetCode 面试,还是学习算法课程,相信本文都能为你提供清晰的思维路径和实用的编码模板。🎯


一、什么是排列问题?🤔

排列问题的核心是:对 n 个元素进行重新排序,生成所有可能的序列

数学上,n 个互不相同元素的全排列数量为 n!(n 的阶乘)。例如:

  • [1,2,3] 的全排列有 3! = 6 种:
    [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]
    

但当数组中存在重复元素时,总排列数会少于 n!。例如:

  • [1,1,2] 的有效排列只有 3 种:
    [1,1,2], [1,2,1], [2,1,1]
    
    而不是 3! = 6 种,因为两个 1 无法区分。

💡 关键挑战:如何在回溯过程中避免生成重复排列


二、基础回溯框架:无重复全排列 🧱

2.1 问题描述(LeetCode 46)

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。

2.2 回溯思路

  • 路径(path):当前已选的元素序列;
  • 选择列表:尚未使用的元素;
  • 结束条件path.size() == nums.length
  • 选择操作:从未使用元素中选一个加入 path
  • 撤销操作:从 path 中移除,并标记为未使用。

由于元素无重复,我们只需确保每个元素只被使用一次即可。

2.3 Java 实现

import java.util.*;

public class PermutationsNoDup {
    List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> permute(int[] nums) {
        boolean[] used = new boolean[nums.length];
        backtrack(nums, new ArrayList<>(), used);
        return result;
    }

    private void backtrack(int[] nums, List<Integer> path, boolean[] used) {
        // 终止条件:路径长度等于数组长度
        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, path, used);

            // 撤销选择(关键!)
            path.remove(path.size() - 1);
            used[i] = false;
        }
    }

    public static void main(String[] args) {
        PermutationsNoDup sol = new PermutationsNoDup();
        int[] nums = {1, 2, 3};
        System.out.println(sol.permute(nums));
        // 输出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
    }
}

2.4 执行流程可视化(mermaid)

graph TD
    A[path=[], used=[F,F,F]] --> B[选1]
    B --> C[path=[1], used=[T,F,F]]
    C --> D[选2]
    D --> E[path=[1,2], used=[T,T,F]]
    E --> F[选3 → [1,2,3] ✅]
    F --> G[回溯: 移除3, used[2]=F]
    E --> H[无更多 → 回溯]
    H --> I[移除2, used[1]=F]
    C --> J[选3]
    J --> K[path=[1,3], used=[T,F,T]]
    K --> L[选2 → [1,3,2] ✅]
    L --> M[回溯...]
    C --> N[回溯: 移除1]
    A --> O[选2]
    O --> P[...]
    A --> Q[选3]
    Q --> R[...]

🔑 used 数组是控制元素唯一性的关键。它确保每个元素在每条路径中只出现一次。


三、挑战升级:含重复元素的全排列(Permutations II)⚠️

3.1 问题描述(LeetCode 47)

给定一个可能包含重复数字的数组 nums,返回所有不重复的全排列。

例如:nums = [1,1,2]
正确输出:[[1,1,2], [1,2,1], [2,1,1]]
错误输出(含重复):会包含多个 [1,1,2] 等。

3.2 重复来源分析

重复排列的产生,是因为相同的元素在不同位置被“独立选择”,导致生成了语义上相同的序列。

例如 [1,1,2]

  • 第一次选 1(索引0),第二次选 1(索引1)→ [1,1,2]
  • 第一次选 1(索引1),第二次选 1(索引0)→ [1,1,2](重复!)

虽然索引不同,但值相同,结果无法区分。

3.3 去重核心思想:同层去重 + 排序

与组合问题类似,排列问题的去重也依赖于:

  1. 排序:将相同元素聚集在一起;
  2. 同层去重:在同一递归层(for 循环内),若当前元素与前一个相同,且前一个未被使用,则跳过当前元素。

✅ 关键判断:if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;

3.4 为什么是 !used[i-1]

这是理解排列去重的最关键点

  • used[i-1] == false:说明前一个相同元素在同一层已被跳过或已回溯,当前选择会导致重复;
  • used[i-1] == true:说明前一个相同元素在上一层已被选中,当前选择是合法的(如 [1,1,2] 中第二个 1)。

🌰 举例:nums = [1,1,2](已排序)

  • 第一层:选第一个 1(used[0]=true)→ 合法;
  • 回溯后,第一层:i=1,nums[1]==nums[0]used[0]==false → 跳过;
  • 这样就避免了“先选第二个 1”的重复路径。

3.5 Java 实现

import java.util.*;

public class PermutationsWithDup {
    List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> permuteUnique(int[] nums) {
        Arrays.sort(nums); // 排序是去重前提
        boolean[] used = new boolean[nums.length];
        backtrack(nums, new ArrayList<>(), used);
        return result;
    }

    private void backtrack(int[] nums, List<Integer> path, boolean[] used) {
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            if (used[i]) continue; // 已使用,跳过

            // 同层去重:当前元素与前一个相同,且前一个未使用
            if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
                continue;
            }

            // 做出选择
            path.add(nums[i]);
            used[i] = true;

            // 递归
            backtrack(nums, path, used);

            // 撤销选择
            path.remove(path.size() - 1);
            used[i] = false;
        }
    }

    public static void main(String[] args) {
        PermutationsWithDup sol = new PermutationsWithDup();
        int[] nums = {1, 1, 2};
        System.out.println(sol.permuteUnique(nums));
        // 输出: [[1,1,2], [1,2,1], [2,1,1]]
    }
}

3.6 去重逻辑图解

nums = [1,1,2] 为例:

graph TD
    A[path=[], used=[F,F,F]] --> B[选1 (i=0)]
    A --> C[选1 (i=1)]:::skip
    A --> D[选2 (i=2)]
    B --> E[path=[1], used=[T,F,F]]
    E --> F[选1 (i=1)] --> G[选2 → [1,1,2]]
    E --> H[选2 (i=2)] --> I[选1 → [1,2,1]]
    D --> J[path=[2], used=[F,F,T]]
    J --> K[选1 (i=0)] --> L[选1 → [2,1,1]]
    J --> M[选1 (i=1)]:::skip2

    classDef skip fill:#ffcccc,stroke:#ff0000,stroke-dasharray: 5 5;
    classDef skip2 fill:#ffcccc,stroke:#ff0000,stroke-dasharray: 5 5;

    click C "https://leetcode.com/problems/permutations-ii/" _blank
  • 红色虚线节点:被 i>0 && nums[i]==nums[i-1] && !used[i-1] 跳过;
  • 有效路径只有 3 条,对应 3 个唯一排列。

🔗 你可以在 LeetCode 47 - Permutations II 查看官方题解(✅ 可正常访问)。


四、深入理解:!used[i-1] vs used[i-1] 🤔

有些资料中会看到另一种写法:

if (i > 0 && nums[i] == nums[i-1] && used[i-1]) continue; // ❌ 错误?

这其实是另一种去重逻辑,但效果相同。让我们分析:

4.1 !used[i-1]跳过“未使用的前一个相同元素”

  • 语义:如果前一个相同元素还没被用,说明它会在后续被选,当前选会导致重复;
  • 实际效果:优先使用靠前的相同元素

4.2 used[i-1]跳过“已使用的前一个相同元素”

  • 语义:如果前一个相同元素已被用,说明当前是“第二个相同元素”,允许;
  • 但若写成 && used[i-1],则会跳过合法情况

✅ 正确的另一种写法其实是:

if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue; // 推荐

或等价地(通过调整逻辑):

if (i > 0 && nums[i] == nums[i-1] && used[i-1]) { /* 不跳过 */ }
else if (i > 0 && nums[i] == nums[i-1]) continue;

但前者更清晰。

4.3 为什么 !used[i-1] 是标准写法?

因为它直接表达了“前一个相同元素未被使用,说明当前选择是冗余的”这一逻辑,符合直觉,且被广泛采用。

🔗 更多讨论可参考 GeeksforGeeks - Permutations with Duplicates(✅ 可访问)。


五、常见错误与陷阱 🚫

5.1 错误1:忘记排序

// ❌ 未排序,去重失效
public List<List<Integer>> permuteUnique(int[] nums) {
    // missing Arrays.sort(nums);
    backtrack(...);
}

✅ 必须先排序,才能通过 nums[i] == nums[i-1] 判断相邻重复。

5.2 错误2:去重条件写反

// ❌ 错误:用了 used[i-1] 而非 !used[i-1]
if (i > 0 && nums[i] == nums[i-1] && used[i-1]) continue;

这会导致漏解。例如 [1,1,2] 可能只输出 [[2,1,1]]

5.3 错误3:使用 Set 去重(低效)

// ❌ 不推荐
Set<List<Integer>> set = new HashSet<>();
set.add(new ArrayList<>(path));
  • 时间复杂度高(List 的 hashCode 计算昂贵);
  • 无法提前剪枝,仍会生成所有重复路径;
  • 面试中会被认为“未掌握本质”。

✅ 正确做法:在生成过程中避免重复


六、进阶:字符串的全排列(含重复字符)🔤

排列问题不仅限于整数数组,也常用于字符串。

6.1 问题描述

给定一个字符串 s(可能含重复字符),返回其所有不重复的全排列。

例如:s = "aab"["aab", "aba", "baa"]

6.2 解题思路

  • 将字符串转为字符数组;
  • 排序;
  • 使用与 Permutations II 相同的回溯+去重逻辑。

6.3 Java 实现

import java.util.*;

public class StringPermutations {
    List<String> result = new ArrayList<>();

    public List<String> permutation(String s) {
        char[] chars = s.toCharArray();
        Arrays.sort(chars);
        boolean[] used = new boolean[chars.length];
        backtrack(chars, new StringBuilder(), used);
        return result;
    }

    private void backtrack(char[] chars, StringBuilder path, boolean[] used) {
        if (path.length() == chars.length) {
            result.add(path.toString());
            return;
        }

        for (int i = 0; i < chars.length; i++) {
            if (used[i]) continue;
            if (i > 0 && chars[i] == chars[i - 1] && !used[i - 1]) continue;

            path.append(chars[i]);
            used[i] = true;
            backtrack(chars, path, used);
            path.deleteCharAt(path.length() - 1); // 撤销
            used[i] = false;
        }
    }

    public static void main(String[] args) {
        StringPermutations sol = new StringPermutations();
        System.out.println(sol.permutation("aab"));
        // 输出: [aab, aba, baa]
    }
}

💡 使用 StringBuilder 提升字符串拼接效率。


七、性能分析与优化 ⚡

7.1 时间复杂度

  • 无重复O(n × n!)
    (n! 个排列,每个排列需 O(n) 时间复制)
  • 有重复O(n × n!) 最坏情况,但实际因剪枝而更快。

7.2 空间复杂度

  • 递归栈深度:O(n)
  • used 数组:O(n)
  • 结果存储:O(n × n!)

7.3 优化技巧

  1. 提前剪枝:在循环中尽早跳过无效选择;
  2. 使用原地交换(非推荐):可省去 used 数组,但去重更复杂;
  3. 避免深拷贝:在某些场景可用 path.toArray(),但 List 更通用。

八、回溯 vs 其他方法:为何选择回溯?🔄

方法优点缺点适用场景
回溯逻辑清晰,易于去重,通用性强递归开销,最坏复杂度高所有排列/组合问题
迭代(Heap’s Algorithm)无递归,空间优难以处理重复元素无重复全排列
库函数(如 Python itertools)代码简短无法自定义去重逻辑快速原型

💡 在算法面试中,回溯是唯一被广泛接受的通用解法


九、总结:排列去重的黄金法则 🧠

面对排列问题,可按以下步骤思考:

  1. 判断是否有重复元素

    • 无重复:用 used 数组控制唯一性;
    • 有重复:先排序 + 同层去重
  2. 去重条件怎么写

    • if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
  3. 注意细节

    • 深拷贝结果;
    • 撤销选择;
    • 排序不能忘。

通用代码模板(含去重)

public List<List<Integer>> permuteUnique(int[] nums) {
    Arrays.sort(nums);
    boolean[] used = new boolean[nums.length];
    backtrack(nums, new ArrayList<>(), used);
    return result;
}

private void backtrack(int[] nums, List<Integer> path, boolean[] used) {
    if (path.size() == nums.length) {
        result.add(new ArrayList<>(path));
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;

        path.add(nums[i]);
        used[i] = true;
        backtrack(nums, path, used);
        path.remove(path.size() - 1);
        used[i] = false;
    }
}

十、扩展资源与练习 📚

🔗 特别推荐 Permutations on Brilliant.org(✅ 可访问),有清晰的数学解释。


结语:顺序之美,去重之智 🎯

排列问题不仅是算法训练的基石,更是对“顺序”与“唯一性”的深刻思考。通过回溯,我们系统地探索所有可能;通过去重,我们优雅地剔除冗余。

掌握 !used[i-1] 这一去重条件,你便掌握了处理重复排列的钥匙。愿你在算法的道路上,既能穷尽可能,又能精准去重,最终写出既正确又高效的代码。🌟


🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jinkxs

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值