Files
leetcode-go/回溯/57-电话号码的字母组合.md
T

308 lines
8.5 KiB
Markdown
Raw Normal View History

2026-05-17 17:24:04 +08:00
---
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** — 另一种笛卡尔积变形(状态受限的逐位构造)