LeetCode 46. 全排列(回溯算法入门模板)

LeetCode 46. 全排列(回溯算法入门模板)

一、题目描述

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

示例

python 复制代码
输入:nums = [1,2,3]

输出:
[
 [1,2,3],
 [1,3,2],
 [2,1,3],
 [2,3,1],
 [3,1,2],
 [3,2,1]
]

二、看到题目先想到什么?

题目要求:

  • 把数组里的数字全部用上;
  • 每个数字只能使用一次;
  • 求所有可能情况。

这种「求所有可能」的问题,通常都可以往**回溯算法(Backtracking)**上想。

回溯算法本质上就是:

尝试一种选择 → 继续往下走 → 走不通或者走到底后撤销选择 → 再尝试下一种选择。

有点像走迷宫:

text 复制代码
走一步
   ↓
还能走?
   ↓
继续走
   ↓
走到底
   ↓
退回来
   ↓
换条路继续走

三、核心思想:深度优先搜索(DFS)

例如:

python 复制代码
nums = [1,2,3]

第一位可以选:

text 复制代码
1
2
3

如果第一位选了 1

text 复制代码
1
├──2
│   └──3
└──3
    └──2

如果第一位选了 2

text 复制代码
2
├──1
│   └──3
└──3
    └──1

如果第一位选了 3

text 复制代码
3
├──1
│   └──2
└──2
    └──1

最终得到:

text 复制代码
123
132
213
231
312
321

四、回溯算法三大核心变量

1、res

保存最终结果。

python 复制代码
res = []

2、path

当前正在构造的排列。

例如:

python 复制代码
[1]
[1,2]
[1,2,3]
python 复制代码
path = []

3、used

记录数字是否已经被使用。

python 复制代码
used = [False] * len(nums)

例如:

python 复制代码
nums = [1,2,3]

used = [True,False,True]

表示:

text 复制代码
1 已使用
2 未使用
3 已使用

五、回溯的固定模板

python 复制代码
def backtrack():
    if 满足结束条件:
        保存结果
        return

    for 选择列表:
        做选择
        backtrack()
        撤销选择

这四步必须记住:

text 复制代码
做选择
递归
撤销选择
继续下一种选择

六、代入本题

第一步:选择一个数字

例如:

python 复制代码
path = []

选择:

python 复制代码
1

变成:

python 复制代码
path = [1]

并且:

python 复制代码
used[0] = True

第二步:递归

继续选择下一个数字。

例如:

python 复制代码
path = [1]

可以选择:

text 复制代码
2
3

第三步:走到底

得到:

python 复制代码
path = [1,2,3]

长度已经等于:

python 复制代码
len(nums)

说明生成了一个完整排列。

保存:

python 复制代码
res.append(path[:])

第四步:回溯

退回来:

python 复制代码
path.pop()
used[i] = False

继续尝试其他数字。


七、完整代码

python 复制代码
from typing import List

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        res = []
        path = []
        used = [False] * len(nums)

        def backtrack():
            # 终止条件
            if len(path) == len(nums):
                res.append(path[:])
                return

            # 枚举所有数字
            for i in range(len(nums)):
                # 已经使用过
                if used[i]:
                    continue

                # 做选择
                used[i] = True
                path.append(nums[i])

                # 递归
                backtrack()

                # 撤销选择
                path.pop()
                used[i] = False

        backtrack()
        return res

八、执行过程演示

以:

python 复制代码
nums = [1,2,3]

为例:

text 复制代码
[]
│
├──1
│   ├──2
│   │   └──3
│   └──3
│       └──2
│
├──2
│   ├──1
│   │   └──3
│   └──3
│       └──1
│
└──3
    ├──1
    │   └──2
    └──2
        └──1

最终:

python 复制代码
[
 [1,2,3],
 [1,3,2],
 [2,1,3],
 [2,3,1],
 [3,1,2],
 [3,2,1]
]

九、为什么要存副本?

很多同学会这样写:

python 复制代码
res.append(path)

这是错误的。

因为:

python 复制代码
path

是一个列表对象。

后面回溯时:

python 复制代码
path.pop()

会修改原来的列表。

最终结果可能变成:

python 复制代码
[[], [], [], [], [], []]

或者:

python 复制代码
[[3,2,1],
 [3,2,1],
 [3,2,1]]

正确写法:

python 复制代码
res.append(path[:])

或者:

python 复制代码
res.append(list(path))

十、高频易错点

1、忘记回溯

错误:

python 复制代码
path.append(nums[i])
backtrack()

少了:

python 复制代码
path.pop()

会导致路径越来越长。


2、忘记恢复状态

错误:

python 复制代码
used[i] = True
backtrack()

少了:

python 复制代码
used[i] = False

后面的数字无法再次使用。


3、保存结果没用副本

错误:

python 复制代码
res.append(path)

正确:

python 复制代码
res.append(path[:])

4、终止条件写错

正确:

python 复制代码
if len(path) == len(nums):

因为只有路径长度等于数组长度时,才生成一个完整排列。


十一、复杂度分析

时间复杂度

text 复制代码
O(n × n!)

原因:

  • 一共有 n! 个排列;
  • 每个排列保存时需要复制 n 个元素。

空间复杂度

text 复制代码
O(n)

主要来自:

  • 递归栈;
  • path;
  • used数组。

(结果数组不算额外空间。)


十二、回溯题万能模板

python 复制代码
res = []
path = []

def backtrack():

    if 满足结束条件:
        res.append(path[:])
        return

    for 选择列表:

        if 当前选择不合法:
            continue

        # 做选择
        path.append(选择)

        # 递归
        backtrack()

        # 撤销选择
        path.pop()

十三、一句话总结

回溯算法的核心就是:做选择 → 递归 → 撤销选择

全排列题目的本质:

枚举每一个位置能放什么数字,走到底得到一种排列,然后回退继续尝试其他可能。

掌握这道题之后,后面的:

  • 组合(77)
  • 子集(78)
  • 组合总和(39)
  • N 皇后(51)

都会发现是同一个回溯模板的变形。