vault backup: 2026-05-17 17:24:03
This commit is contained in:
+346
@@ -0,0 +1,346 @@
|
||||
---
|
||||
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-下一个排列]]** — 有序生成排列,不需要枚举全部
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
---
|
||||
tags: ["LeetCode", "回溯", "数组", "位运算", "中等"]
|
||||
create time: 2026-05-17 10:30
|
||||
---
|
||||
|
||||
# 56-子集
|
||||
|
||||
## 题面
|
||||
|
||||
> **LeetCode 78. Subsets**
|
||||
|
||||
给你一个整数数组 `nums`,数组中的元素 **互不相同**。返回该数组所有可能的子集(幂集)。
|
||||
|
||||
解集 **不能** 包含重复的子集。你可以按 **任意顺序** 返回解集。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:nums = [1,2,3]
|
||||
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:nums = [0]
|
||||
输出:[[],[0]]
|
||||
```
|
||||
|
||||
**提示:**
|
||||
|
||||
- `1 <= nums.length <= 10`
|
||||
- `-10 <= nums[i] <= 10`
|
||||
- `nums` 中的所有元素 **互不相同**
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 思考
|
||||
|
||||
假设 `nums = [1, 2, 3]`,手动枚举所有子集——你会怎么列才不会漏、不会重?
|
||||
|
||||
一个直观的做法是:对每个元素,问一个问题——**「选它,还是不选它?」**
|
||||
|
||||
```
|
||||
1 选 or 不选? → 2 选 or 不选? → 3 选 or 不选?
|
||||
```
|
||||
|
||||
每个元素恰好两个抉择,所以一共 `2 × 2 × 2 = 2³ = 8` 种组合,正好对应幂集大小 `2^n`。
|
||||
|
||||
> [!abstract] 🎯 核心洞察
|
||||
|
||||
全排列的决策树是 **n 叉树**(每层有 n, n-1, ..., 1 个选择),而子集的决策树是一棵 **完美二叉树**:
|
||||
|
||||
| 决策点 | 选项 | 分支数 |
|
||||
|--------|------|--------|
|
||||
| 第 i 个元素 | 加入子集 / 不加入子集 | **2** |
|
||||
|
||||
这棵树有 `n` 层,叶子节点共 `2^n` 个——**但注意:每个节点都代表一个有效子集**,不只是叶子!这是子集问题与排列/组合问题的最大区别。
|
||||
|
||||
### 回溯框架三要素
|
||||
|
||||
子集回溯与全排列的关键差异在 **「何时记录答案」**:
|
||||
|
||||
```
|
||||
全排列 / 组合:只有走到叶子(或指定深度)才记录
|
||||
子 集 :每个节点都是答案,边递归边记录
|
||||
```
|
||||
|
||||
维护一个 `path` 记录当前子集,用 `start` 参数控制每层的遍历起点——从 `start` 往后选,不允许回头,天然避免重复子集。
|
||||
|
||||
### 回溯决策树(以 `nums = [1, 2, 3]` 为例)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
root["[] ✅"] --> NO1["不选 1"]
|
||||
root --> YES1["选 1 → [1] ✅"]
|
||||
|
||||
NO1 --> NO2["不选 2"]
|
||||
NO1 --> YES2["选 2 → [2] ✅"]
|
||||
|
||||
YES1 --> NO3["不选 2 → [1] ✅"]
|
||||
YES1 --> YES3["选 2 → [1,2] ✅"]
|
||||
|
||||
NO2 --> NO33["不选 3 → [] ✅"]
|
||||
NO2 --> YES33["选 3 → [3] ✅"]
|
||||
|
||||
YES2 --> NO4["不选 3 → [2] ✅"]
|
||||
YES2 --> YES4["选 3 → [2,3] ✅"]
|
||||
|
||||
NO3 --> NO5["不选 3 → [1] ✅"]
|
||||
NO3 --> YES5["选 3 → [1,3] ✅"]
|
||||
|
||||
YES3 --> NO6["不选 3 → [1,2] ✅"]
|
||||
YES3 --> YES6["选 3 → [1,2,3] ✅"]
|
||||
|
||||
classDef leaf fill:#FFD700,stroke:#DAA520,color:#000;
|
||||
class NO33,YES33,NO4,YES4,NO5,YES5,NO6,YES6 leaf;
|
||||
```
|
||||
|
||||
**关键观察:**
|
||||
|
||||
- 共 `2^(n+1) - 1` 个节点(n 层满二叉树的总结点数),每到一个节点就将 `path` 拷贝入结果
|
||||
- 用 `start` 保证不回头:右子树永远不选当前及之前的元素
|
||||
|
||||
---
|
||||
|
||||
## 方法一:回溯(标准模板)⭐ 推荐
|
||||
|
||||
> [!tip] 🔑 写法要点
|
||||
|
||||
```go
|
||||
var result [][]int
|
||||
|
||||
var dfs func(start int)
|
||||
dfs = func(start int) {
|
||||
// ⚠️ 每个节点都收集答案(区别于排列只在叶子记录)
|
||||
result = append(result, append([]int(nil), path...))
|
||||
|
||||
for i := start; i < len(nums); i++ {
|
||||
path = append(path, nums[i]) // 做选择
|
||||
dfs(i + 1) // 递归:下一个位置从 i+1 开始
|
||||
path = path[:len(path)-1] // 撤销选择
|
||||
}
|
||||
}
|
||||
|
||||
dfs(0)
|
||||
```
|
||||
|
||||
**为什么从 `i+1` 开始而不是像全排列那样从头遍历?**
|
||||
|
||||
- 子集不允许回头 → `[1, 2]` 和 `[2, 1]` 算同一个子集
|
||||
- 固定顺序(从前向后选)即可天然去重,无需 `used[]` 数组
|
||||
- 这等价于从 `n` 个元素中选出 `k` 个的组合思想
|
||||
|
||||
- **时间复杂度**:O(n × 2^n) —— 2^n 个子集,每个拷贝 O(n)
|
||||
- **空间复杂度**:O(n) —— 递归栈深度
|
||||
|
||||
---
|
||||
|
||||
## 方法二:迭代(逐元素扩展法)
|
||||
|
||||
> [!tip] 🔑 核心思想
|
||||
|
||||
从空集出发,每遇到一个新元素,把**已有的所有子集各自加上该元素**作为新子集追加到结果中。
|
||||
|
||||
```
|
||||
初始: [[]]
|
||||
遇到 1: [[] , [1]] ← 已有子集 + 每个加 1
|
||||
遇到 2: [[] , [1], [2], [1,2]] ← 上一步全部 + 每个加 2
|
||||
遇到 3: ... ← 同上规律
|
||||
```
|
||||
|
||||
每次迭代子集数量翻倍:`1 → 2 → 4 → 8 → ... → 2^n`。
|
||||
|
||||
**时间复杂度**:O(n × 2^n)
|
||||
**空间复杂度**:O(1) 额外(不计返回值)
|
||||
|
||||
---
|
||||
|
||||
## 方法三:位运算(枚举掩码)
|
||||
|
||||
> [!note] 🔑 核心思想
|
||||
|
||||
`n` 个元素的幂集大小为 `2^n`,可以用 `0` 到 `2^n - 1` 的二进制数编码:
|
||||
|
||||
- 第 `j` 位为 `1` → 包含 `nums[j]`
|
||||
- 第 `j` 位为 `0` → 不包含 `nums[j]`
|
||||
|
||||
```
|
||||
nums = [1, 2, 3]
|
||||
|
||||
000 → []
|
||||
001 → [1]
|
||||
010 → [2]
|
||||
011 → [1,2]
|
||||
100 → [3]
|
||||
101 → [1,3]
|
||||
110 → [2,3]
|
||||
111 → [1,2,3]
|
||||
```
|
||||
|
||||
**优点**:代码极简,仅两层 for 循环
|
||||
**缺点**:无法提前终止(回溯可以通过剪枝跳过无效分支),面试时通常作为加分项展示多样性。
|
||||
|
||||
**时间复杂度**:O(n × 2^n)
|
||||
**空间复杂度**:O(n)
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
> [!abstract] 📝 回溯通用骨架(子集版)
|
||||
|
||||
```go
|
||||
var result [][]int
|
||||
var path []int
|
||||
|
||||
var dfs func(start int)
|
||||
dfs = func(start int) {
|
||||
// ① Base case 隐式处理——每到一个节点就记录
|
||||
result = append(result, append([]int(nil), path...))
|
||||
|
||||
// ② 从 start 往后枚举,不允许回头
|
||||
for i := start; i < len(nums); i++ {
|
||||
path = append(path, nums[i]) // 做选择
|
||||
dfs(i + 1) // 递归
|
||||
path = path[:len(path)-1] // 撤销选择
|
||||
}
|
||||
}
|
||||
|
||||
dfs(0)
|
||||
return result
|
||||
```
|
||||
|
||||
> [!summary] 📋 子集 vs 排列 vs 组合
|
||||
|
||||
| 问题 | 决策树 | 何时记录 | 遍历方式 | 去重手段 |
|
||||
|------|--------|---------|---------|---------|
|
||||
| **子集** (LC 78) | 二叉树 | 每个节点 | `for i := start` | `start` 防回头 |
|
||||
| **全排列** (LC 46) | n 叉树 | 叶子节点 | `for i := range` + `used[]` | `used[]` 标记 |
|
||||
| **组合** (LC 77) | 二叉树 | 指定深度 | `for i := start` | `start` 防回头 |
|
||||
|
||||
**共同口诀**:
|
||||
|
||||
```
|
||||
做选择 → 递归探索 → 撤销选择
|
||||
```
|
||||
|
||||
子集的特殊之处:
|
||||
|
||||
> 排列和组合只收集「完整方案」;子集收集「每一个中间状态」。
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
> [!success] ✅ 方法一:回溯(⭐ 推荐)
|
||||
|
||||
```go
|
||||
// subsets 返回 nums 的所有子集(幂集)。
|
||||
//
|
||||
// 时间复杂度:O(n * 2^n) — 共 2^n 个子集,每个拷贝 O(n)
|
||||
// 空间复杂度:O(n) — 递归栈深度为 n
|
||||
func subsets(nums []int) [][]int {
|
||||
var result [][]int
|
||||
path := make([]int, 0, len(nums))
|
||||
|
||||
var dfs func(start int)
|
||||
dfs = func(start int) {
|
||||
// 每个节点都是答案,直接记录
|
||||
result = append(result, append([]int(nil), path...))
|
||||
|
||||
for i := start; i < len(nums); i++ {
|
||||
path = append(path, nums[i]) // 做选择
|
||||
dfs(i + 1) // 递归:下一层从 i+1 开始
|
||||
path = path[:len(path)-1] // 撤销选择
|
||||
}
|
||||
}
|
||||
|
||||
dfs(0)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 方法二:迭代(逐元素扩展)
|
||||
|
||||
```go
|
||||
// subsetsIterative 使用迭代法求所有子集。
|
||||
//
|
||||
// 时间复杂度:O(n * 2^n)
|
||||
// 空间复杂度:O(1) 额外(不计返回值)
|
||||
func subsetsIterative(nums []int) [][]int {
|
||||
result := [][]int{{}}
|
||||
|
||||
for _, num := range nums {
|
||||
n := len(result)
|
||||
for i := 0; i < n; i++ {
|
||||
newSubset := append(result[i], num)
|
||||
result = append(result, append([]int(nil), newSubset...))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 方法三:位运算
|
||||
|
||||
```go
|
||||
// subsetsBitManipulation 通过位枚举求所有子集。
|
||||
//
|
||||
// 时间复杂度:O(n * 2^n)
|
||||
// 空间复杂度:O(n)
|
||||
func subsetsBitManipulation(nums []int) [][]int {
|
||||
n := len(nums)
|
||||
result := make([][]int, 0, 1<<n) // 预分配容量
|
||||
|
||||
for mask := 0; mask < (1<<n); mask++ {
|
||||
subset := make([]int, 0)
|
||||
for j := 0; j < n; j++ {
|
||||
if mask&(1<<j) != 0 { // 检查第 j 位是否为 1
|
||||
subset = append(subset, nums[j])
|
||||
}
|
||||
}
|
||||
result = append(result, subset)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 单元测试
|
||||
|
||||
```go
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSubsets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []int
|
||||
expect [][]int
|
||||
}{
|
||||
{name: "示例1", input: []int{1, 2, 3}, expect: [][]int{{},{1},{2},{1,2},{3},{1,3},{2,3},{1,2,3}}},
|
||||
{name: "单元素", input: []int{0}, expect: [][]int{{},{0}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := subsets(append([]int{}, tt.input...))
|
||||
slices.SortFunc(got, func(a, b []int) int {
|
||||
if len(a) != len(b) {
|
||||
return len(a) - len(b)
|
||||
}
|
||||
return slices.Compare(a, b)
|
||||
})
|
||||
slices.SortFunc(tt.expect, func(a, b []int) int {
|
||||
if len(a) != len(b) {
|
||||
return len(a) - len(b)
|
||||
}
|
||||
return slices.Compare(a, b)
|
||||
})
|
||||
if !slices.Equal(got, tt.expect) {
|
||||
t.Errorf("subsets(%v) = %v; want %v", tt.input, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!danger] ⚠️ Go 切片陷阱:存引用还是存值?
|
||||
|
||||
与全排列一样,`path` 指向同一底层数组。如果不创建独立副本直接存入 `result`,后续回溯修改会污染已记录的结果。
|
||||
|
||||
```go
|
||||
// ❌ 危险:存的是引用,后续 path 变化会影响所有已记录的子集
|
||||
result = append(result, path)
|
||||
|
||||
// ✅ 正确:创建独立副本
|
||||
result = append(result, append([]int(nil), path...))
|
||||
```
|
||||
|
||||
> [!tip] 🔑 何时用位运算?
|
||||
|
||||
当 `n ≤ 20` 且需要简洁实现时,位运算是最短的代码路径。但在面试中建议优先写回溯——它展现了你对算法范式的理解,且更容易扩展到变体问题(如含重复元素的情况)。
|
||||
|
||||
> [!quote] 💬 延伸思考
|
||||
|
||||
幂集的大小是指数级 `2^n`。这意味着:
|
||||
|
||||
1. **任何子集算法都不可能低于 O(2^n)** —— 你必须至少列出所有子集
|
||||
2. `n > 20` 时暴力枚举就会超时,必须借助数学性质(如排序后的贪心、前缀和优化等)
|
||||
3. 这也解释了为什么 LeetCode 将本题约束为 `n ≤ 10` —— 留出了大量练习空间
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
> [!summary] 📋 本章要点回顾
|
||||
|
||||
> 子集问题是回溯的入门基石:
|
||||
>
|
||||
> 1. **模型最简**——二叉决策树,每步只需回答「选 or 不选」
|
||||
> 2. **记录时机特殊**——每个节点都是合法子集,而非仅叶子
|
||||
> 3. **三种实现等价**——回溯、迭代扩展、位枚举,各有适用场景
|
||||
> 4. **可迁移到大量变体**:含重复元素 [[回溯/57-子集 II]]、分割回文串、电话号码组合
|
||||
>
|
||||
> **口诀**:一进一出两改两回,选了加,回去退,每个节点都要收。
|
||||
|
||||
---
|
||||
|
||||
> [!example] 🔀 关联变体题
|
||||
|
||||
- **[[回溯/55-全排列]]** — 回溯入门第一题,n 叉树 vs 二叉树
|
||||
- **LeetCode 90. Subsets II** — 含重复元素的子集(需排序 + 同层去重)
|
||||
- **LeetCode 77. Combinations** — 从 n 中选 k 个(固定深度的子集)
|
||||
- **LeetCode 216. Combination Sum III** — 组合之和 III(加和条件过滤)
|
||||
- **LeetCode 40. Combination Sum II** — 组合总和 II(含重复元素 + 目标和)
|
||||
- **[[回溯/57-电话号码的字母组合]]** — 笛卡尔积型回溯,每层候选集不同
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
tags: ["LeetCode", "回溯", "字符串", "中等"]
|
||||
create time: 2026-05-17 11:00
|
||||
---
|
||||
|
||||
# 57-电话号码的字母组合
|
||||
|
||||
## 题面
|
||||
|
||||
> **LeetCode 17. Letter Combinations of a Phone Number**
|
||||
|
||||
给定一个仅包含数字 `2-9` 的字符串,返回所有它能表示的字母组合。答案可以按 **任意顺序** 返回。
|
||||
|
||||
给出数字到字母的映射如下(与电话按键相同)。注意 `1` 不对应任何字母。
|
||||
|
||||
```
|
||||
2: "abc" 3: "def" 4: "ghi" 5: "jkl"
|
||||
6: "mno" 7: "pqrs" 8: "tuv" 9: "wxyz"
|
||||
```
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:digits = "23"
|
||||
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:digits = "2"
|
||||
输出:["a","b","c"]
|
||||
```
|
||||
|
||||
**提示:**
|
||||
|
||||
- `1 <= digits.length <= 4`
|
||||
- `digits[i]` 是范围 `['2', '9']` 的一个数字。
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 思考
|
||||
|
||||
假设输入是 `"23"`,你会怎么得到所有字母组合?
|
||||
|
||||
直观做法:先固定 `'2' → "abc"` 中的一个字母,再固定 `'3' → "def"` 中的一个字母,拼成两位字符串。两层循环就能搞定:
|
||||
|
||||
```go
|
||||
for _, a := range "abc" { // 第1层:选2对应的字母
|
||||
for _, b := range "def" { // 层:选3对应的字母
|
||||
result = append(result, string(a)+string(b))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
但如果输入是 `"234"` 呢?需要三层嵌套……`"2345"` 就是四层……输入长度不固定,没法手写固定层数的循环。
|
||||
|
||||
这时就该回溯算法登场了——用递归模拟任意深度的嵌套循环。
|
||||
|
||||
> [!abstract] 🎯 核心洞察
|
||||
|
||||
本题的决策树是一棵 **多叉树**,每层的分支数取决于当前数字对应几个字母:
|
||||
|
||||
| 数字 | 对应字母 | 分支数 |
|
||||
|------|---------|--------|
|
||||
| 2 | a, b, c | 3 |
|
||||
| 3 | d, e, f | 3 |
|
||||
| 4 | g, h, i | 3 |
|
||||
| 5 | j, k, l | 3 |
|
||||
| 6 | m, n, o | 3 |
|
||||
| 7 | p, q, r, s | **4** |
|
||||
| 8 | t, u, v | 3 |
|
||||
| 9 | w, x, y, z | **4** |
|
||||
|
||||
以 `digits = "23"` 为例,决策过程:
|
||||
|
||||
```
|
||||
先选 2 的字母(a/b/c),再选 3 的字母(d/e/f) → 拼成结果
|
||||
```
|
||||
|
||||
每个数字是一个决策层,该层有若干候选字母可选,选完一层进入下一层,直到穷尽所有数字。
|
||||
|
||||
### 回溯框架三要素
|
||||
|
||||
回溯通用骨架在本题中的具体化:
|
||||
|
||||
```
|
||||
选择某数字对应的一个字母 → 递归处理下一个数字 → 换该数字的下一个字母
|
||||
```
|
||||
|
||||
维护一个 `path` 记录当前拼接的字母序列。与全排列不同,这里不需要 `used[]` ——因为每个数字只访问一次,按索引顺序推进即可。
|
||||
|
||||
### 回溯决策树(以 `digits = "23"` 为例)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
root["空 []"] --> A["选 a"]
|
||||
root --> B["选 b"]
|
||||
root --> C["选 c"]
|
||||
|
||||
A --> AD["选 d<br/>✅"]
|
||||
A --> AE["选 e<br/>✅"]
|
||||
A --> AF["选 f<br/>✅"]
|
||||
|
||||
B --> BD["选 d<br/>✅"]
|
||||
B --> BE["选 e<br/>✅"]
|
||||
B --> BF["选 f<br/>✅"]
|
||||
|
||||
C --> CD["选 d<br/>✅"]
|
||||
C --> CE["选 e<br/>✅"]
|
||||
C --> CF["选 f<br/>✅"]
|
||||
|
||||
classDef leaf fill:#90EE90,stroke:#228B22,color:#000;
|
||||
class AD,AE,AF,BD,BE,BF,CD,CE,CF leaf;
|
||||
```
|
||||
|
||||
> [!note] 🔍 关键观察
|
||||
|
||||
注意到这是一棵 **"逐层展开"** 的树——不像全排列那样每层都从全部元素中选,而是第 `i` 层只能选 `digits[i]` 对应的那些字母。一旦选完最后一层(深度等于 `digits` 长度),路径就是一条完整的答案。
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
> [!abstract] 📝 回溯通用骨架
|
||||
|
||||
```go
|
||||
var digitMap = map[byte]string{
|
||||
'2': "abc", '3': "def", '4': "ghi", '5': "jkl",
|
||||
'6': "mno", '7': "pqrs", '8': "tuv", '9': "wxyz",
|
||||
}
|
||||
|
||||
var result []string
|
||||
|
||||
var dfs func(path []byte)
|
||||
dfs = func(path []byte) {
|
||||
// Base case: 已选满 digits 的长度
|
||||
if len(path) == len(digits) {
|
||||
result = append(result, string(path))
|
||||
return
|
||||
}
|
||||
|
||||
// 找出当前层(第 len(path) 个数字)的候选字母
|
||||
ch := digits[len(path)]
|
||||
for _, letter := range digitMap[ch] {
|
||||
path = append(path, byte(letter)) // 做选择(range string → rune,需转 byte)
|
||||
dfs(path) // 递归
|
||||
path = path[:len(path)-1] // 撤销选择
|
||||
}
|
||||
}
|
||||
|
||||
// 边界:空输入直接返回空数组
|
||||
if digits == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
dfs(nil)
|
||||
return result
|
||||
```
|
||||
|
||||
> [!tip] 🔑 设计细节
|
||||
|
||||
为什么 Base Case 是 `len(path) == len(digits)` 而不是传一个 `index` 参数?
|
||||
|
||||
两种方式等价,但利用 `len(path)` 作为隐式索引更简洁——不需要额外传参,也不需要回退时改变 index。代价是每次用 `digits[len(path)]` 做一次切片访问,这在 Go 中是 O(1) 操作,毫无影响。
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 核心模式
|
||||
|
||||
本题属于 **"笛卡尔积"** 类型的回溯——每层独立选择,最终结果是各层候选集的乘积:
|
||||
|
||||
| 问题类型 | 决策方式 | 代表题目 |
|
||||
|---------|---------|---------|
|
||||
| **字母组合** | 每层从**当前数字对应的字母集**中选 | LC 17(本题) |
|
||||
| **全排列** | 每层从**全部未用元素**中选 | LC 46 |
|
||||
| **子集** | 每层**选或不选** | LC 78 |
|
||||
|
||||
> [!summary] 📋 笛卡尔积 vs 其他回溯
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CART["笛卡尔积<br/>每层候选集不同<br/>如本题"]
|
||||
PERM["全排列<br/>每层从剩余中选<br/>需 used[]"]
|
||||
SUB["子集/组合<br/>每层从 start 往后选<br/>start 防回头"]
|
||||
|
||||
CART -. "同属回溯范式" .-> PERM
|
||||
PERM -. "同属回溯范式" .-> SUB
|
||||
```
|
||||
|
||||
> [!danger] ⚠️ 一个易错边界条件
|
||||
|
||||
**输入为空字符串 `""` 时应该返回 `[]` 而非 `[""]`!**
|
||||
|
||||
这是面试中最容易翻车的地方。很多初学者看到 `len(path) == 0 == len("")` 就直接把空字符串加入了结果——但实际上空输入不应该产生任何有效组合。
|
||||
|
||||
```go
|
||||
if digits == "" {
|
||||
return []string{}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
> [!success] ✅ 解法:回溯
|
||||
|
||||
```go
|
||||
// letterCombinations 返回 digits 能表示的所有字母组合。
|
||||
//
|
||||
// 时间复杂度:O(4^k * k) — k 为 digits 长度,最多 4 个字母/位,拼接结果 O(k)
|
||||
// 空间复杂度:O(k) — 递归栈深度为 k,k ≤ 4
|
||||
func letterCombinations(digits string) []string {
|
||||
if digits == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
digitMap := [10]string{
|
||||
"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz",
|
||||
}
|
||||
|
||||
var result []string
|
||||
path := make([]byte, 0, len(digits))
|
||||
|
||||
var dfs func()
|
||||
dfs = func() {
|
||||
if len(path) == len(digits) {
|
||||
result = append(result, string(path))
|
||||
return
|
||||
}
|
||||
|
||||
// 取出当前层对应数字的字母集合
|
||||
letters := digitMap[digits[len(path)]-'0']
|
||||
for i := 0; i < len(letters); i++ {
|
||||
path = append(path, letters[i]) // 做选择
|
||||
dfs() // 递归进入下一层
|
||||
path = path[:len(path)-1] // 撤销选择
|
||||
}
|
||||
}
|
||||
|
||||
dfs()
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 单元测试
|
||||
|
||||
```go
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLetterCombinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect []string
|
||||
}{
|
||||
{name: "示例1", input: "23", expect: []string{"ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"}},
|
||||
{name: "示例2", input: "2", expect: []string{"a", "b", "c"}},
|
||||
{name: "单字符7", input: "7", expect: []string{"p", "q", "r", "s"}},
|
||||
{name: "含空串", input: "", expect: []string{}},
|
||||
{name: "双7", input: "77", expect: []string{"pp", "pq", "pr", "ps", "qp", "qq", "qr", "qs", "rp", "rq", "rr", "rs", "sp", "sq", "sr", "ss"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := letterCombinations(tt.input)
|
||||
slices.Sort(got)
|
||||
slices.Sort(tt.expect)
|
||||
if !slices.Equal(got, tt.expect) {
|
||||
t.Errorf("letterCombinations(%q) = %v; want %v", tt.input, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
> [!summary] 📋 本章要点回顾
|
||||
|
||||
> 电话号码的字母组合是回溯中 **"笛卡尔积型"** 的代表题:
|
||||
>
|
||||
> 1. **决策特征鲜明**——每层的选择集由当前数字决定,不同于排列的全量选择和子集的选/不选
|
||||
> 2. **实现最简回溯**——无需 `used[]`、无需 `start`,一个 `path` 走天下
|
||||
> 3. **边界处理关键**——空输入必须特判,否则会得到错误的 `[""]`
|
||||
> 4. **复杂度清晰**——4 叉为上限,`k ≤ 4` 的约束下枚举规模极小
|
||||
>
|
||||
> **口诀**:不进不出只改不回,选了加,回去退,每层候选看数字。
|
||||
|
||||
---
|
||||
|
||||
> [!example] 🔀 关联变体题
|
||||
|
||||
- **[[回溯/55-全排列]]** — 每层从全部未用元素中选,需 `used[]`
|
||||
- **[[回溯/56-子集]]** — 二叉决策树,选或不选
|
||||
- **LeetCode 980. Unique Paths III** — 带障碍和步数约束的回溯探索
|
||||
- **LeetCode 22. Generate Parentheses** — 另一种笛卡尔积变形(状态受限的逐位构造)
|
||||
Reference in New Issue
Block a user