全排列 II:去重的技巧与实现
1. 引言:排列问题的坑
你有没有遇到过这样的问题?
当我们在做全排列(Permutation)的时候,如果输入的数组中包含重复元素,生成的排列中就会出现大量重复项。这样不仅浪费计算资源,还让结果变得冗余。例如,给定数组 [1, 1, 2]
,如果不去重,可能会得到:
[1, 1, 2]
[1, 2, 1]
[1, 1, 2] # 重复!
[1, 2, 1] # 重复!
[2, 1, 1]
[2, 1, 1] # 重复!
显然,这种重复的排列是无意义的。如何高效去重,是 全排列 II(Permutations II) 这个问题的核心挑战。
今天,我们就来聊聊 如何在回溯算法(Backtracking)中优雅地去重,让我们的排列结果既高效又准确。
2. 经典回溯法:全排列的基本实现
我们先来看看不去重的全排列回溯算法。
回溯算法的核心思路是:
- 从头开始尝试排列,每次选择一个数字放入当前排列。
- 标记已经使用过的数字,防止重复选择。
- 递归进入下一层,直到排列长度达到原数组长度。
- 回溯撤销选择,尝试其他可能的排列。
基本代码如下:
def permute(nums):
def backtrack(path, used):
# 终止条件:排列完成
if len(path) == len(nums):
res.append(path[:]) # 复制当前排列
return
for i in range(len(nums)):
if not used[i]:
used[i] = True
path.append(nums[i])
backtrack(path, used)
path.pop() # 回溯
used[i] = False
res = []
backtrack([], [False] * len(nums))
return res
# 测试
print(permute([1, 1, 2]))
这个算法的复杂度是 O(n!),但它会生成重复的排列。如何去重呢?
3. 关键优化:去重技巧
3.1 使用排序+剪枝
为了避免重复,我们需要确保 相同的数字不会在同一层被重复使用。
关键思路
- 先排序:让相同的元素相邻,方便去重。
- 跳过重复元素:如果前一个元素和当前元素相同,并且前一个元素还未被使用,就跳过当前元素。
优化后的代码
def permute_unique(nums):
def backtrack(path, used):
if len(path) == len(nums):
res.append(path[:])
return
for i in range(len(nums)):
# 剪枝去重的关键:
if used[i]:
continue # 跳过已经使用的数字
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue # 跳过重复元素
used[i] = True
path.append(nums[i])
backtrack(path, used)
path.pop()
used[i] = False
nums.sort() # 先排序,让相同的数字相邻
res = []
backtrack([], [False] * len(nums))
return res
# 测试
print(permute_unique([1, 1, 2]))
运行结果
[[1, 1, 2], [1, 2, 1], [2, 1, 1]]
3.2 为什么能去重?
- 排序后,相同的数字相邻,我们可以方便地判断是否跳过。
not used[i-1]
条件 确保了我们不会选择一个“前面相同但未使用”的元素,避免了重复排列。- 回溯过程中保证顺序,不会破坏排列的唯一性。
4. 复杂度分析
这个优化算法的时间复杂度仍然是 O(n!),但由于剪枝减少了递归次数,实际执行效率会明显提升。
如果 nums
长度为 n
,假设去重后有效排列数为 m
,那么:
- 最坏情况(无重复):复杂度仍然是
O(n!)
。 - 最优情况(大量重复):复杂度会接近
O(m * n)
,远优于O(n!)
。
在实际应用中,去重优化带来的加速效果非常明显,尤其是当输入数组包含大量重复元素时。
5. 进阶优化:使用哈希表去重
另一种方法是使用 set
记录已经使用的元素,避免重复排列。
def permute_unique_set(nums):
def backtrack(path):
if len(path) == len(nums):
res.append(path[:])
return
used_set = set()
for i in range(len(nums)):
if used[i] or nums[i] in used_set:
continue # 跳过重复元素
used[i] = True
used_set.add(nums[i]) # 记录已经使用的元素
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
res = []
used = [False] * len(nums)
backtrack([])
return res
虽然 set
方法也能去重,但它比排序+剪枝的方法稍慢,因为 set
操作增加了额外的时间开销。因此,在大多数情况下,排序+剪枝是更优的选择。
6. 结语
全排列 II 是一个经典的回溯问题,其中去重是核心难点。我们介绍了两种去重方法:
- 排序 + 剪枝(推荐):通过排序让相同元素相邻,然后在回溯中跳过重复项。
- 哈希表去重:使用
set
记录已经选择的数字,防止重复。
这两种方法都能有效去重,但排序+剪枝是主流解法,效率更高。如果你在面试或竞赛中遇到这个问题,赶紧用这个方法拿下高分!