1590 words
8 minutes
Leetcode Hot 100 全排列

Leetcode Hot 100 继续刷,这一题来到回溯题里的经典老演员:全排列

这类题的气质很统一:

  • 要你列出所有可能结果
  • 每一步都要做选择
  • 选完还得撤回来

翻译成人话就是:

回溯一出场,排列组合先别慌。

这题本身不绕,但非常适合把回溯模板练扎实。 尤其是 Python 里那个老坑:

加入答案时必须用 vals[:] 拷贝,不能直接塞 vals

不然回头一改,前面存进去的结果也会跟着串味,像一锅被反复回锅的排列炒饭。

题目链接#

LeetCode 46. 全排列

题目描述#

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

你可以按任意顺序返回答案。

全排列题目截图

解题思路:回溯枚举每一个位置放什么数#

全排列的本质,就是把数组里的每个数都安排到排列中的某个位置上。

如果数组长度是 n,那我们就可以把构造答案看成一个“逐位置填数”的过程:

  • 0 位放谁
  • 1 位放谁
  • 2 位放谁
  • 直到第 n - 1 位也放完

每一层递归都负责确定一个位置上的数字。

需要哪些辅助结构#

这题里用两个数组就够了。

1. flag#

用于记录某个数是否已经被使用。

flag = [False] * n

如果 flag[idx] == True,表示 nums[idx] 已经进了当前排列, 这一轮就不能再选它了。

2. vals#

用于保存当前正在构造的排列结果。

vals = [0] * n

比如当前已经填到第 2 位,vals 可能长这样:

[2, 1, 0]

这里只是举例,最后一位可能还没正式定下来。

代码实现#

class Solution(object):
def permute(self, nums):
"""
:type nums: List[int]
:rtype: List[List[int]]
"""
ans = []
n = len(nums)
if n == 0:
return []
flag = [False] * n
vals = [0] * n
def dfs(i):
if i == n:
ans.append(vals[:])
return
for idx, item in enumerate(nums):
if flag[idx] == False:
vals[i] = item
flag[idx] = True
dfs(i + 1)
flag[idx] = False
dfs(0)
return ans

回溯过程怎么理解#

定义 dfs(i) 表示:

当前要去确定排列中的第 i 个位置。

递归终止条件#

i == n 时,说明长度为 n 的排列已经全部填完。

这时候就得到了一组完整答案:

ans.append(vals[:])

然后返回上一层,继续尝试其他选择。

当前层要做什么#

在第 i 层,我们要从 nums 里找一个还没被使用过的数,放到 vals[i] 上。

for idx, item in enumerate(nums):
if flag[idx] == False:
vals[i] = item
flag[idx] = True
dfs(i + 1)
flag[idx] = False

这一段就是标准回溯三连:

  1. 做选择
  2. 递归进入下一层
  3. 撤销选择

简写成一句话就是:

选一个,钻下去;回来后,擦干净,再选下一个。

为什么必须写 ans.append(vals[:])#

这题里最容易翻车的,不是回溯逻辑, 而是 Python 列表引用这个老六。

如果你写成:

ans.append(vals)

表面上看像是把当前排列加入答案, 实际上加入的是 同一个列表对象的引用

后面递归继续修改 vals 时, ans 里之前保存的那些结果也会一起被改掉。

最后很可能出现这种场面:

  • 你以为收集了很多排列
  • 实际上全都指向同一个 vals
  • 最终答案长得整整齐齐,全员撞脸

所以这里必须切片拷贝:

ans.append(vals[:])

可以理解成:

在回溯现场拍一张快照存起来,别把活体直接丢进仓库。

为什么这里不用恢复 vals[i]#

你这版代码回溯时只恢复了:

flag[idx] = False

却没有写:

vals[i] = 0

这其实没问题。

因为下一次循环选别的数时,vals[i] 会被新的值直接覆盖。 只要“这个数是否被用过”的状态被正确恢复,逻辑就不会乱。

真正必须恢复的是使用标记, 因为别的分支还要重新使用这个数。

举个例子#

假设:

nums = [1, 2, 3]

第一层:确定第 0 位#

可以选:

  • 1
  • 2
  • 3

假设先选 1,那么当前:

vals = [1, 0, 0]

flag1 对应的位置会被标记成已使用。

第二层:确定第 1 位#

此时还能选:

  • 2
  • 3

如果再选 2,就变成:

vals = [1, 2, 0]

第三层:确定第 2 位#

只剩 3 可选:

vals = [1, 2, 3]

这时把它加入答案。

然后递归返回,撤销 3 的使用状态; 再返回上一层,撤销 2 的使用状态; 继续试其他分支。

整个过程就是一棵标准搜索树。

复杂度分析#

设数组长度为 n

时间复杂度#

全排列一共有 n! 种结果, 每一种结果在加入答案时都要复制一个长度为 n 的数组。

所以时间复杂度为:

O(n \times n!)

空间复杂度#

递归深度最多为 n, 辅助数组 flagvals 也都是长度 n

所以不考虑最终答案存储时,额外空间复杂度为:

O(n)

如果把返回结果也算上, 那答案本身就要占 O(n × n!) 的空间。

这不是算法抠门不够,是排列题天生就这么能生。

这题的关键点#

1. 回溯模板要熟#

  • 做选择
  • 递归下一层
  • 撤销选择

这个节奏是排列、组合、子集这类题的统一鼓点。

2. 用 flag 防止重复使用#

每个数字在一个排列里只能出现一次, 所以必须记录它当前是否已经被选走。

3. Python 里结果要拷贝#

ans.append(vals[:])

这一句不是细节,是考点。 也是很多人 AC 路上的第一块香蕉皮。

小结#

这道题就是标准回溯模板题。

思路很直接:

  • 每一层决定当前位置放哪个数
  • 已经使用过的数字不能再选
  • 当所有位置都填满时,收集答案

如果只记一句话,那就是:

全排列就是“逐位填数 + 使用标记 + 回溯撤销”,Python 里别忘了切片拷贝。

模板题别嫌朴素,回溯题海里很多花样,最后都得回到这套骨架上。 把它练熟,后面见到排列、组合、子集,心里就不会先打鼓,只会先开路。🦐

Leetcode Hot 100 全排列
https://ssonnyboy.github.io/yoroziya/posts/leetcode-hot-100-permutations/
Author
biabuluo
Published at
2026-04-09
License
CC BY-NC-SA 4.0