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

8.5 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
回溯
字符串
中等
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" 中的一个字母,拼成两位字符串。两层循环就能搞定:

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" 为例)

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] 📝 回溯通用骨架

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 其他回溯

flowchart LR
    CART["笛卡尔积<br/>每层候选集不同<br/>如本题"]
    PERM["全排列<br/>每层从剩余中选<br/>需 used[]"]
    SUB["子集/组合<br/>每层从 start 往后选<br/>start 防回头"]

    CART -. "同属回溯范式" .-> PERM
    PERM -. "同属回溯范式" .-> SUB

[!danger] ⚠️ 一个易错边界条件

输入为空字符串 "" 时应该返回 [] 而非 [""]!

这是面试中最容易翻车的地方。很多初学者看到 len(path) == 0 == len("") 就直接把空字符串加入了结果——但实际上空输入不应该产生任何有效组合。

if digits == "" {
    return []string{}
}

代码

[!success] ✅ 解法:回溯

// 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] ✅ 单元测试

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 — 另一种笛卡尔积变形(状态受限的逐位构造)