347 lines
8.7 KiB
Markdown
347 lines
8.7 KiB
Markdown
|
|
---
|
|||
|
|
tags: ["LeetCode", "回溯", "数组", "中等"]
|
|||
|
|
create time: 2026-05-17 10:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 55-全排列
|
|||
|
|
|
|||
|
|
## 题面
|
|||
|
|
|
|||
|
|
> **LeetCode 46. Permutations**
|
|||
|
|
|
|||
|
|
给定一个不含重复数字的数组 `nums`,返回其 **所有可能的全排列**。你可以 **按任意顺序** 返回答案。
|
|||
|
|
|
|||
|
|
**示例 1:**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
输入:nums = [1,2,3]
|
|||
|
|
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**示例 2:**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
输入:nums = [0,1]
|
|||
|
|
输出:[[0,1],[1,0]]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**示例 3:**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
输入:nums = [1]
|
|||
|
|
输出:[[1]]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**提示:**
|
|||
|
|
|
|||
|
|
- `1 <= nums.length <= 6`
|
|||
|
|
- `-10 <= nums[i] <= 10`
|
|||
|
|
- `nums` 中的所有整数 **互不相同**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 思路
|
|||
|
|
|
|||
|
|
> [!question] 💡 思考
|
|||
|
|
|
|||
|
|
手动列出 `[1, 2, 3]` 的所有排列——你会怎么列?
|
|||
|
|
|
|||
|
|
大概率是:先选 1 开头(剩两个位置自由排列),再选 2 开头……每一步从剩余元素中选一个填到当前位置,然后递归处理下一个位置。
|
|||
|
|
|
|||
|
|
这正是回溯算法的经典场景:**枚举一棵决策树的所有完整路径**。
|
|||
|
|
|
|||
|
|
> [!abstract] 🎯 核心洞察
|
|||
|
|
|
|||
|
|
全排列的本质是"从 n 个元素中选 n 个放入序列",分解为 `n` 层决策:
|
|||
|
|
|
|||
|
|
| 决策层 | 任务 | 可选元素 |
|
|||
|
|
|--------|------|---------|
|
|||
|
|
| 第 1 层 | 选第 1 个位置的数 | 全部 n 个 |
|
|||
|
|
| 第 2 层 | 选第 2 个位置的数 | 除去已选的 |
|
|||
|
|
| ... | ... | ... |
|
|||
|
|
| 第 n 层 | 选最后一个 | 仅剩 1 个 |
|
|||
|
|
|
|||
|
|
每层选择数递减:`n × (n-1) × ... × 1 = n!`,所以时间复杂度必然是 O(n!)。
|
|||
|
|
|
|||
|
|
### 回溯框架三要素
|
|||
|
|
|
|||
|
|
任何回溯问题都可以归纳为三个操作:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
选择 → 递归 → 撤销选择(回溯)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- **选择**:做出当前决策,进入下一层。
|
|||
|
|
- **递归**:在已选基础上继续探索。
|
|||
|
|
- **撤销**:回退上一步,尝试其他分支。
|
|||
|
|
|
|||
|
|
维护一个 `path` 记录当前路径,用一个 `used[]` 布尔数组标记哪些元素已被选中——遍历时跳过已用的即可。
|
|||
|
|
|
|||
|
|
### 回溯决策树(以 `nums = [1, 2, 3]` 为例)
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
root["空 []"] --> L1a["选 1\n[1]"]
|
|||
|
|
root --> L1b["选 2\n[2]"]
|
|||
|
|
root --> L1c["选 3\n[3]"]
|
|||
|
|
|
|||
|
|
L1a --> L2a["选 2\n[1,2]"]
|
|||
|
|
L1a --> L2b["选 3\n[1,3]"]
|
|||
|
|
|
|||
|
|
L1b --> L2c["选 1\n[2,1]"]
|
|||
|
|
L1b --> L2d["选 3\n[2,3]"]
|
|||
|
|
|
|||
|
|
L1c --> L2e["选 1\n[3,1]"]
|
|||
|
|
L1c --> L2f["选 2\n[3,2]"]
|
|||
|
|
|
|||
|
|
L2a --> Leaf1["[1,2,3] ✅"]
|
|||
|
|
L2b --> Leaf2["[1,3,2] ✅"]
|
|||
|
|
L2c --> Leaf3["[2,1,3] ✅"]
|
|||
|
|
L2d --> Leaf4["[2,3,1] ✅"]
|
|||
|
|
L2e --> Leaf5["[3,1,2] ✅"]
|
|||
|
|
L2f --> Leaf6["[3,2,1] ✅"]
|
|||
|
|
|
|||
|
|
classDef leaf fill:#90EE90,stroke:#228B22,color:#000;
|
|||
|
|
class Leaf1,Leaf2,Leaf3,Leaf4,Leaf5,Leaf6 leaf;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 两种实现方式
|
|||
|
|
|
|||
|
|
> [!tip] 🔑 方法一:`used` 数组(⭐ 推荐)
|
|||
|
|
|
|||
|
|
每层从头遍历所有元素,通过 `used[]` 跳过已选。这是最标准的回溯模板,清晰易懂。
|
|||
|
|
|
|||
|
|
> [!note] 🔑 方法二:原地交换(进阶)
|
|||
|
|
|
|||
|
|
交换 `nums[i]` 和 `nums[start]` 等价于"选 nums[i] 放在 start 位置",递归后恢复交换即撤销。省去了 `used[]` 和 `path[]`,空间更紧凑,但去重时不如方法一方便。**面试中作为加分项**。
|
|||
|
|
|
|||
|
|
**何时用哪种?**
|
|||
|
|
|
|||
|
|
| 场景 | 推荐 |
|
|||
|
|
|------|------|
|
|||
|
|
| 首次学习 / 面试快速写对 | **方法一**(used 数组) |
|
|||
|
|
| 面试官追问 O(1) 额外空间 | **方法二**(原地交换) |
|
|||
|
|
| 含重复元素的排列(LC 47) | **方法一** + 排序去重 |
|
|||
|
|
|
|||
|
|
- **时间复杂度**:O(n × n!) — 共 n! 条完整路径,每条拷贝需 O(n)
|
|||
|
|
- **空间复杂度**:O(n) — 递归栈深度
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 代码提示
|
|||
|
|
|
|||
|
|
> [!abstract] 📝 回溯通用骨架
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
var result [][]int
|
|||
|
|
|
|||
|
|
var dfs func(path []int)
|
|||
|
|
dfs = func(path []int) {
|
|||
|
|
// Base Case: 路径已满
|
|||
|
|
if len(path) == len(nums) {
|
|||
|
|
result = append(result, append([]int(nil), path...)) // ⚠️ 必须拷贝
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for i := 0; i < len(nums); i++ {
|
|||
|
|
if used[i] {
|
|||
|
|
continue // 剪枝:跳过已选元素
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 做选择
|
|||
|
|
path = append(path, nums[i])
|
|||
|
|
used[i] = true
|
|||
|
|
|
|||
|
|
// 递归
|
|||
|
|
dfs(path)
|
|||
|
|
|
|||
|
|
// 撤销选择
|
|||
|
|
used[i] = false
|
|||
|
|
path = path[:len(path)-1]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dfs(nil)
|
|||
|
|
return result
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 技巧
|
|||
|
|
|
|||
|
|
> [!tip] 🔑 核心模式
|
|||
|
|
|
|||
|
|
全排列是回溯最经典的入门题,掌握后可迁移到大量同类问题:
|
|||
|
|
|
|||
|
|
| 问题类型 | 决策方式 | 代表题目 |
|
|||
|
|
|---------|---------|---------|
|
|||
|
|
| **全排列** | 每层从**全部未用元素**中选 | LC 46(本题) |
|
|||
|
|
| **组合** | 每层从**当前及之后**选(不回头) | LC 77 组合 |
|
|||
|
|
| **子集** | 每个元素**选或不选** | [[回溯/56-子集]] |
|
|||
|
|
|
|||
|
|
> [!step] ⭐ 四步回溯法
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
S1["① 定义签名<br/>需要什么参数?"] --> S2["② Base case<br/>何时停止?"]
|
|||
|
|
S2 --> S3["③ for 循环<br/>有哪些选择?"]
|
|||
|
|
S3 --> S4["④ 递归+回溯<br/>选什么?撤什么?"]
|
|||
|
|
S4 --> DONE["✅"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!danger] ⚠️ 两个常见陷阱
|
|||
|
|
|
|||
|
|
**① Go 切片不拷贝直接存结果**:`path` 指向同一底层数组,不拷贝的话所有结果会被后续回溯覆盖成同一个值。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// ❌ 错误
|
|||
|
|
result = append(result, path)
|
|||
|
|
|
|||
|
|
// ✅ 正确:创建独立副本
|
|||
|
|
tmp := make([]int, len(path))
|
|||
|
|
copy(tmp, path)
|
|||
|
|
result = append(result, tmp)
|
|||
|
|
|
|||
|
|
// ✅ 或者一行搞定
|
|||
|
|
result = append(result, append([]int(nil), path...))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**② 混淆 `for i := range nums` 和 `for i := start; i < len(nums)`**:
|
|||
|
|
- `range nums`(从头遍历 + `used[]`)→ 全排列,允许跳到任意未用元素
|
|||
|
|
- `range start`(从当前位置往后)→ 组合/子集,不允许回头以避免重复
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 代码
|
|||
|
|
|
|||
|
|
> [!success] ✅ 方法一:`used` 数组标记(推荐)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// permute 返回 nums 的所有全排列(使用 used 数组标记法)。
|
|||
|
|
//
|
|||
|
|
// 时间复杂度:O(n * n!) — 共 n! 条完整路径,每条 O(n) 拷贝
|
|||
|
|
// 空间复杂度:O(n) — 递归栈深度为 n
|
|||
|
|
func permute(nums []int) [][]int {
|
|||
|
|
var result [][]int
|
|||
|
|
path := make([]int, 0, len(nums))
|
|||
|
|
used := make([]bool, len(nums))
|
|||
|
|
|
|||
|
|
var dfs func()
|
|||
|
|
dfs = func() {
|
|||
|
|
if len(path) == len(nums) {
|
|||
|
|
tmp := make([]int, len(nums))
|
|||
|
|
copy(tmp, path)
|
|||
|
|
result = append(result, tmp)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for i := 0; i < len(nums); i++ {
|
|||
|
|
if used[i] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
path = append(path, nums[i])
|
|||
|
|
used[i] = true
|
|||
|
|
|
|||
|
|
dfs()
|
|||
|
|
|
|||
|
|
used[i] = false
|
|||
|
|
path = path[:len(path)-1]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dfs()
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!success] ✅ 方法二:原地交换
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// permuteSwap 使用原地交换实现全排列。
|
|||
|
|
//
|
|||
|
|
// 时间复杂度:O(n * n!)
|
|||
|
|
// 空间复杂度:O(n) — 仅递归栈开销
|
|||
|
|
func permuteSwap(nums []int) [][]int {
|
|||
|
|
var result [][]int
|
|||
|
|
|
|||
|
|
var dfs func(start int)
|
|||
|
|
dfs = func(start int) {
|
|||
|
|
if start == len(nums) {
|
|||
|
|
tmp := make([]int, len(nums))
|
|||
|
|
copy(tmp, nums)
|
|||
|
|
result = append(result, tmp)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for i := start; i < len(nums); i++ {
|
|||
|
|
nums[start], nums[i] = nums[i], nums[start]
|
|||
|
|
dfs(start + 1)
|
|||
|
|
nums[start], nums[i] = nums[i], nums[start] // 恢复
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dfs(0)
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!success] ✅ 单元测试
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
import (
|
|||
|
|
"slices"
|
|||
|
|
"testing"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func TestPermute(t *testing.T) {
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
input []int
|
|||
|
|
expect [][]int
|
|||
|
|
}{
|
|||
|
|
{name: "示例1", input: []int{1, 2, 3}, expect: [][]int{{1,2,3},{1,3,2},{2,1,3},{2,3,1},{3,1,2},{3,2,1}}},
|
|||
|
|
{name: "示例2", input: []int{0, 1}, expect: [][]int{{0,1},{1,0}}},
|
|||
|
|
{name: "单元素", input: []int{1}, expect: [][]int{{1}}},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
got := permute(append([]int{}, tt.input...))
|
|||
|
|
slices.SortFunc(got, func(a, b []int) int {
|
|||
|
|
return slices.Compare(a, b)
|
|||
|
|
})
|
|||
|
|
slices.SortFunc(tt.expect, func(a, b []int) int {
|
|||
|
|
return slices.Compare(a, b)
|
|||
|
|
})
|
|||
|
|
if !slices.Equal(got, tt.expect) {
|
|||
|
|
t.Errorf("permute(%v) = %v; want %v", tt.input, got, tt.expect)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!quote] 💬 延伸思考
|
|||
|
|
|
|||
|
|
全排列的决策树是一棵**规则的 n 叉树**——叶子节点数 = n!。这也正是信息论下界:你必须至少访问 n! 个叶子才能列出全部排列,所以 O(n·n!) 已是最优。
|
|||
|
|
|
|||
|
|
> [!summary] 📋 本章要点回顾
|
|||
|
|
|
|||
|
|
> 全排列是回溯的最佳入门题:
|
|||
|
|
>
|
|||
|
|
> 1. **模型简洁**——决策规则对称,便于手绘理解
|
|||
|
|
> 2. **模板通用**——"选择→递归→撤销"可无缝迁移到组合、子集、N 皇后
|
|||
|
|
> 3. **优化阶梯清晰**——从 `used` 数组到原地交换,再到第 K 个排列的数学构造
|
|||
|
|
>
|
|||
|
|
> **口诀**:一进一出两改两回,选了加,回去退。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
> [!example] 🔀 关联变体题
|
|||
|
|
|
|||
|
|
- **LeetCode 47. Permutations II** — 含重复元素的全排列
|
|||
|
|
- **[[回溯/56-子集]]** — 回溯最基础范式,决策树是二叉树(选或不选)
|
|||
|
|
- **[[回溯/57-电话号码的字母组合]]** — 多叉树搜索,每层候选集不同
|
|||
|
|
- **[[技巧/99-下一个排列]]** — 有序生成排列,不需要枚举全部
|