回溯法生成排列
核心思路是:在递归深度 时,决定由哪个未使用的输入位置来填充输出的第 位。
这里的关键在于跟踪“位置”而不仅仅是“值”。如果只跟踪值,当输入包含重复元素时,很难正确区分哪些是重复的排列,哪些是合法的。通过标记位置的使用状态,我们可以精确控制元素的复用逻辑。
def unique_permutations(values: list[int]) -> list[list[int]]:
values = sorted(values)
used = [False] * len(values)
path: list[int] = []
result: list[list[int]] = []
def visit() -> None:
if len(path) == len(values):
result.append(path.copy())
return
for i, value in enumerate(values):
if used[i]:
continue
# 剪枝关键:如果当前值与前一个值相同,且前一个值未被使用,则跳过
# 这保证了相同值的元素必须按顺序被选取,避免生成重复排列
if i > 0 and value == values[i - 1] and not used[i - 1]:
continue
used[i] = True
path.append(value)
visit()
path.pop()
used[i] = False
visit()
return result
去重原理:
首先对输入数组排序,将相同的值聚集在一起。在每一层决策中,如果当前值与前一个值相同,且前一个值尚未被使用(not used[i - 1]),则跳过当前值。
这条规则强制相同值的元素必须按照它们在数组中的顺序被选取。例如,对于两个相同的 1,必须先选第一个 1 才能选第二个 1。这样就剪掉了所有因相同值交换位置而产生的对称分支,同时保留了所有本质不同的排列。
复杂度分析
- 输出规模:若 个值互不相同,共有 种排列,每种长度为 。生成并存储所有结果需要 的时间和空间。
- 额外开销:回溯过程本身需要 的空间来维护路径(
path)、使用状态(used)以及递归栈。
如果不需要一次性获取所有结果,可以使用生成器(Generator)逐个产出排列。这样可以避免存储完整的输出列表,节省内存,但无法改变生成所有排列所需的时间下界。
重复值跟踪与空排列
输入 [1,1,2] 时,根层可选第一个 1 或 2,但跳过第二个 1。选了第一个 1 后,第二个 1 就可用了:接着选它得到 [1,1,2],接着选 2 则得到 [1,2,1]。根层从 2 开始的分支得到 [2,1,1]。因此保留了必需的两份 1,只去掉可互换的位置身份。
每次递归路径都增长一位,最多 n 位,故必然终止。空输入返回 [[]],而非 []:零个元素恰有一种排列。若各值重数为 m₁,…,mᵣ,不同输出数为 n!/(m₁!…mᵣ!)。代码在每个内部节点仍扫描 n 个位置,不能据此断言运行时间总与不同输出数成正比。所有值相同时只有一个输出,但扫描工作为 Θ(n²)。
assert unique_permutations([1, 1, 2]) == [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
assert unique_permutations([]) == [[]]