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

308 lines
8.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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** — 另一种笛卡尔积变形(状态受限的逐位构造)