
👋 大家好,欢迎来到我的技术博客!
💻 作为一名热爱 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 去重核心思想:同层去重 + 排序
与组合问题类似,排列问题的去重也依赖于:
- 排序:将相同元素聚集在一起;
- 同层去重:在同一递归层(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 优化技巧
- 提前剪枝:在循环中尽早跳过无效选择;
- 使用原地交换(非推荐):可省去
used数组,但去重更复杂; - 避免深拷贝:在某些场景可用
path.toArray(),但 List 更通用。
八、回溯 vs 其他方法:为何选择回溯?🔄
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 回溯 | 逻辑清晰,易于去重,通用性强 | 递归开销,最坏复杂度高 | 所有排列/组合问题 |
| 迭代(Heap’s Algorithm) | 无递归,空间优 | 难以处理重复元素 | 无重复全排列 |
| 库函数(如 Python itertools) | 代码简短 | 无法自定义去重逻辑 | 快速原型 |
💡 在算法面试中,回溯是唯一被广泛接受的通用解法。
九、总结:排列去重的黄金法则 🧠
面对排列问题,可按以下步骤思考:
-
判断是否有重复元素?
- 无重复:用
used数组控制唯一性; - 有重复:先排序 + 同层去重。
- 无重复:用
-
去重条件怎么写?
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
-
注意细节?
- 深拷贝结果;
- 撤销选择;
- 排序不能忘。
通用代码模板(含去重)
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;
}
}
十、扩展资源与练习 📚
- LeetCode 全排列题单(✅ 可访问)
- Visualgo - 排列生成可视化(✅ 交互式学习)
- 《算法(第4版)》第 4.2 章:回溯与排列
🔗 特别推荐 Permutations on Brilliant.org(✅ 可访问),有清晰的数学解释。
结语:顺序之美,去重之智 🎯
排列问题不仅是算法训练的基石,更是对“顺序”与“唯一性”的深刻思考。通过回溯,我们系统地探索所有可能;通过去重,我们优雅地剔除冗余。
掌握 !used[i-1] 这一去重条件,你便掌握了处理重复排列的钥匙。愿你在算法的道路上,既能穷尽可能,又能精准去重,最终写出既正确又高效的代码。🌟
🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨
全排列与去重算法详解
1055

被折叠的 条评论
为什么被折叠?



