Files
interview/16-LeetCode Hot 100/电话号码的字母组合.md
yasinshaw 5c1c974e88 docs: 改进LeetCode二叉树题目解题思路
按照改进方案,为以下6个二叉树题目增强了解题思路的详细程度:

1. 二叉树的中序遍历
   - 增加"思路推导"部分,解释递归到迭代的转换
   - 详细说明迭代法的每个步骤
   - 增加执行过程演示和多种解法

2. 二叉树的最大深度
   - 增加"思路推导",对比DFS和BFS
   - 详细解释递归的基准情况
   - 增加多种解法和变体问题

3. 从前序与中序遍历序列构造二叉树
   - 详细解释前序和中序的特点
   - 增加"思路推导",说明如何分治
   - 详细说明切片边界计算

4. 对称二叉树
   - 解释镜像对称的定义
   - 详细说明递归比较的逻辑
   - 增加迭代解法和变体问题

5. 翻转二叉树
   - 解释翻转的定义和过程
   - 详细说明多值赋值的执行顺序
   - 增加多种解法和有趣的故事

6. 路径总和
   - 详细解释路径和叶子节点的定义
   - 说明为什么使用递减而非累加
   - 增加多种解法和变体问题

每个文件都包含:
- 完整的示例和边界条件分析
- 详细的算法流程和图解
- 关键细节说明
- 常见错误分析
- 复杂度分析(详细版)
- 执行过程演示
- 多种解法
- 变体问题
- 总结

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:33:57 +08:00

20 KiB
Raw Blame History

电话号码的字母组合 (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 = ""
输出:[]

示例 3

输入digits = "2"
输出:["a","b","c"]

约束条件

  • 0 <= digits.length <= 4
  • digits[i] 是范围 ['2', '9'] 的一个数字。

思路推导

暴力解法分析

第一步:直观思路 - 嵌套循环

def letterCombinations_brute(digits):
    if not digits:
        return []

    # 数字到字母的映射
    mapping = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }

    # 对于 "23",需要两层循环
    result = []
    for letter1 in mapping[digits[0]]:
        for letter2 in mapping[digits[1]]:
            result.append(letter1 + letter2)

    return result

问题分析:

  • 不知道输入长度,无法确定嵌套层数
  • 代码无法通用化
  • 时间复杂度O(4^n)最坏情况每个数字对应4个字母

优化思考 - 如何通用化?

核心观察:

  1. 问题本质:在每个数字对应的字母集中选择一个,组合成字符串
  2. 与排列组合的关系:这是一个"笛卡尔积"问题
  3. 递归思路:处理完当前数字后,递归处理下一个数字

为什么用回溯?

  • 需要遍历所有可能的组合
  • 每个数字的选择是独立的
  • 可以通过递归自然地表达嵌套结构

为什么这样思考?

1. 树形结构视角

digits = "23" 的组合树:

          ""
         / \
        a   b   c
       /|\ /|\ /|\
      d e f d e f d e f

结果:["ad","ae","af","bd","be","bf","cd","ce","cf"]

2. 递归的三个要素

- 终止条件:处理完所有数字 (index == len(digits))
- 选择列表:当前数字对应的所有字母
- 路径:已选择的字母组合

解题思路

方法一:回溯法(推荐)

**核心思想:**使用回溯算法遍历所有可能的字母组合。每次递归处理一个数字,尝试该数字对应的所有字母。

算法步骤:

  1. 建立数字到字母的映射表
  2. 如果输入为空,直接返回空数组
  3. 使用回溯函数生成组合:
    • 当前索引等于 digits 长度时,将当前组合加入结果
    • 否则,遍历当前数字对应的所有字母,递归处理下一个数字

详细算法流程

步骤1建立数字到字母的映射

phoneMap = {
    '2': "abc",
    '3': "def",
    '4': "ghi",
    '5': "jkl",
    '6': "mno",
    '7': "pqrs",
    '8': "tuv",
    '9': "wxyz"
}

Q: 为什么用映射而不是数组?

A: 数字是字符类型('2'-'9'),直接映射更直观。用数组需要 digit - '0' - 2 转换。

步骤2设计回溯函数

def backtrack(index):
    # 终止条件:处理完所有数字
    if index == len(digits):
        result.append("".join(current))
        return

    # 获取当前数字对应的所有字母
    digit = digits[index]
    letters = phoneMap[digit]

    # 遍历所有字母,做选择
    for letter in letters:
        current.append(letter)      # 做选择
        backtrack(index + 1)         # 递归
        current.pop()                # 撤销选择

Q: 为什么需要撤销选择?

A: 因为 current 是共享的列表,不撤销会影响下一次递归。举例:

不撤销的情况:
- 选择 'a' → current=['a']
- 选择 'd' → current=['a','d'],加入结果
- 回溯后 current=['a','d'],而不是 ['a']
- 下一次选择 'e' → current=['a','d','e'],错误!

步骤3处理边界情况

if digits == "":
    return []

关键细节说明

细节1为什么用列表而不是字符串拼接

# 方法1字符串拼接简单但效率低
def backtrack(index, current_str):
    if index == len(digits):
        result.append(current_str)
        return
    for letter in phoneMap[digits[index]]:
        backtrack(index + 1, current_str + letter)

# 方法2列表拼接高效
def backtrack(index, current_list):
    if index == len(digits):
        result.append("".join(current_list))
        return
    for letter in phoneMap[digits[index]]:
        current_list.append(letter)
        backtrack(index + 1, current_list)
        current_list.pop()

对比:

  • 字符串拼接每次创建新字符串O(n) 时间
  • 列表操作append/pop 是 O(1),只在最后 join 一次

细节2为什么映射用 byte 而不是 string

// Go 中 byte 更高效
phoneMap := map[byte]string{
    '2': "abc",  // digits[i] 是 byte 类型
    '3': "def",
    // ...
}

细节3如何处理空字符串输入

# 边界情况
if digits == "":
    return []  # 返回空数组,而不是 [""]

边界条件分析

边界1空字符串

输入digits = ""
输出:[]
原因:没有数字,无法生成组合

边界2单个数字

输入digits = "2"
输出:["a","b","c"]
过程:只需遍历 '2' 对应的字母

边界3包含 7 或 94个字母

输入digits = "79"
输出16 个组合4×4
注意7和9对应4个字母其他对应3个

复杂度分析(详细版)

时间复杂度:

- 设 m 是对应 3 个字母的数字个数2,3,4,5,6,8
- 设 n 是对应 4 个字母的数字个数7,9
- 总组合数3^m × 4^n
- 每个组合的构建O(len(digits))
- **总时间复杂度O(len(digits) × 3^m × 4^n)**

特殊情况:
- 最好:全是 2-6,8 → O(3^n)
- 最坏:全是 7,9 → O(4^n)
- 平均O(3.5^n)

空间复杂度:

- 递归栈深度O(len(digits))
- 存储结果O(3^m × 4^n)
- **空间复杂度O(len(digits))**(不计结果存储)

### 方法二:队列迭代法

**核心思想:**使用队列逐层构建所有可能的组合。每次处理一个数字,将队列中所有组合与该数字对应的所有字母组合。

**算法步骤:**
1. 建立数字到字母的映射表
2. 初始化队列为空字符串
3. 对于每个数字:
   - 取出队列中所有现有组合
   - 将每个组合与当前数字对应的所有字母拼接
   - 将新组合放回队列
4. 返回队列中的所有组合

### 方法三:递归分治法

**核心思想:**将问题分解为子问题。对于 `digits = "23"`,先处理 `"2"` 得到 `["a","b","c"]`,再处理 `"3"` 得到 `["d","e","f"]`,最后组合所有可能。

## 代码实现

### Go 实现(回溯法)

```go
package main

import (
	"fmt"
)

func letterCombinations(digits string) []string {
	if digits == "" {
		return []string{}
	}

	// 数字到字母的映射
	phoneMap := map[byte]string{
		'2': "abc",
		'3': "def",
		'4': "ghi",
		'5': "jkl",
		'6': "mno",
		'7': "pqrs",
		'8': "tuv",
		'9': "wxyz",
	}

	result := []string{}
	current := []byte{}

	var backtrack func(index int)
	backtrack = func(index int) {
		if index == len(digits) {
			// 将当前组合加入结果
			result = append(result, string(current))
			return
		}

		// 获取当前数字对应的所有字母
		letters := phoneMap[digits[index]]
		for i := 0; i < len(letters); i++ {
			// 选择当前字母
			current = append(current, letters[i])
			// 递归处理下一个数字
			backtrack(index + 1)
			// 撤销选择(回溯)
			current = current[:len(current)-1]
		}
	}

	backtrack(0)
	return result
}

// 测试用例
func main() {
	// 测试用例1
	digits1 := "23"
	fmt.Printf("输入: %s\n", digits1)
	fmt.Printf("输出: %v\n", letterCombinations(digits1))

	// 测试用例2
	digits2 := ""
	fmt.Printf("\n输入: %s\n", digits2)
	fmt.Printf("输出: %v\n", letterCombinations(digits2))

	// 测试用例3
	digits3 := "2"
	fmt.Printf("\n输入: %s\n", digits3)
	fmt.Printf("输出: %v\n", letterCombinations(digits3))

	// 测试用例4: 最长输入
	digits4 := "9999"
	fmt.Printf("\n输入: %s\n", digits4)
	fmt.Printf("输出长度: %d\n", len(letterCombinations(digits4)))
}
func letterCombinationsIterative(digits string) []string {
	if digits == "" {
		return []string{}
	}

	phoneMap := map[string]string{
		"2": "abc",
		"3": "def",
		"4": "ghi",
		"5": "jkl",
		"6": "mno",
		"7": "pqrs",
		"8": "tuv",
		"9": "wxyz",
	}

	// 初始化队列
	queue := []string{""}

	for _, digit := range digits {
		letters := phoneMap[string(digit)]
		newQueue := []string{}

		// 取出队列中所有组合,与当前字母组合
		for _, combination := range queue {
			for i := 0; i < len(letters); i++ {
				newCombination := combination + string(letters[i])
				newQueue = append(newQueue, newCombination)
			}
		}

		queue = newQueue
	}

	return queue
}

执行过程演示

digits = "23" 为例:

初始状态result=[], current=[], index=0

处理第1个数字 '2' (index=0):
  letters = "abc"

  选择 'a':
    current=['a'], index=1
    └─ 处理第2个数字 '3'
        letters = "def"

        选择 'd': current=['a','d'], index=2 → 加入结果 ["ad"]
        选择 'e': current=['a','e'], index=2 → 加入结果 ["ad","ae"]
        选择 'f': current=['a','f'], index=2 → 加入结果 ["ad","ae","af"]

  选择 'b':
    current=['b'], index=1
    └─ 处理第2个数字 '3'
        选择 'd': current=['b','d'], index=2 → ["ad","ae","af","bd"]
        选择 'e': current=['b','e'], index=2 → ["ad","ae","af","bd","be"]
        选择 'f': current=['b','f'], index=2 → ["ad","ae","af","bd","be","bf"]

  选择 'c':
    current=['c'], index=1
    └─ 处理第2个数字 '3'
        选择 'd': current=['c','d'], index=2 → ["ad","ae","af","bd","be","bf","cd"]
        选择 'e': current=['c','e'], index=2 → ["ad","ae","af","bd","be","bf","cd","ce"]
        选择 'f': current=['c','f'], index=2 → ["ad","ae","af","bd","be","bf","cd","ce","cf"]

最终结果:["ad","ae","af","bd","be","bf","cd","ce","cf"]

常见错误

错误1忘记处理空字符串

错误写法:

func letterCombinations(digits string) []string {
    result := []string{}
    // 直接开始回溯,没有检查空字符串
    backtrack(0, digits, &result)
    return result
}

正确写法:

func letterCombinations(digits string) []string {
    if digits == "" {
        return []string{}  // 返回空数组
    }
    result := []string{}
    backtrack(0, digits, &result)
    return result
}

**原因:**空字符串应该返回空数组,而不是开始回溯。

错误2撤销选择时索引错误

错误写法:

for i := 0; i < len(letters); i++ {
    current = append(current, letters[i])
    backtrack(index + 1, digits, result)
    current = current[:len(current)]  // 错误!没有真正删除
}

正确写法:

for i := 0; i < len(letters); i++ {
    current = append(current, letters[i])
    backtrack(index + 1, digits, result)
    current = current[:len(current)-1]  // 正确:删除最后一个元素
}

原因:current[:len(current)] 不会删除元素,current[:len(current)-1] 才会。

错误3没有复制 current 就加入结果

错误写法:

if index == len(digits) {
    result = append(result, string(current))  // 如果 current 是共享的
    return
}

正确写法:

if index == len(digits) {
    temp := make([]byte, len(current))
    copy(temp, current)
    result = append(result, string(temp))
    return
}

**原因:**如果 current 是共享的切片,后续修改会影响已加入结果的数据。

队列迭代法

  • 时间复杂度: O(3^m × 4^n)

    • 与回溯法相同,需要遍历所有可能的组合
  • 空间复杂度: O(3^m × 4^n)

    • 需要存储所有中间结果和最终结果

进阶问题

Q1: 如果数字字符串包含 '0' 和 '1',应该如何处理?

A: '0' 和 '1' 不对应任何字母,可以跳过或返回空字符串。

// Go 版本:跳过 0 和 1
func letterCombinationsWithZero(digits string) []string {
    if digits == "" {
        return []string{}
    }

    phoneMap := map[byte]string{
        '0': "",
        '1': "",
        '2': "abc",
        // ... 其他映射
    }

    // 在回溯时,如果当前数字没有对应字母,直接跳过
    var backtrack func(index int)
    backtrack = func(index int) {
        if index == len(digits) {
            if len(current) > 0 { // 确保至少有一个字母
                result = append(result, string(current))
            }
            return
        }

        letters := phoneMap[digits[index]]
        if letters == "" {
            // 跳过没有字母的数字
            backtrack(index + 1)
        } else {
            for i := 0; i < len(letters); i++ {
                current = append(current, letters[i])
                backtrack(index + 1)
                current = current[:len(current)-1]
            }
        }
    }

    backtrack(0)
    return result
}

Q2: 如果要求结果按字典序排序,应该如何实现?

A: 在生成所有组合后,使用排序算法对结果进行排序。

import "sort"

func letterCombinationsSorted(digits string) []string {
    result := letterCombinations(digits)
    sort.Strings(result)
    return result
}

Q3: 如果只要求返回第 k 个组合(从 1 开始),应该如何优化?

A: 可以直接计算第 k 个组合,无需生成所有组合。

func getKthCombination(digits string, k int) string {
    if digits == "" || k <= 0 {
        return ""
    }

    phoneMap := map[byte]string{
        '2': "abc",
        '3': "def",
        '4': "ghi",
        '5': "jkl",
        '6': "mno",
        '7': "pqrs",
        '8': "tuv",
        '9': "wxyz",
    }

    result := make([]byte, len(digits))
    k-- // 转换为从 0 开始

    for i := 0; i < len(digits); i++ {
        letters := phoneMap[digits[i]]
        count := len(letters)

        // 计算当前位置应该选择哪个字母
        index := k % count
        result[i] = letters[index]

        // 更新 k
        k /= count
    }

    return string(result)
}

P7 加分项

1. 深度理解:回溯法的本质

回溯法 = 暴力搜索 + 剪枝

  • **暴力搜索:**遍历所有可能的解空间
  • **剪枝:**在搜索过程中跳过不可能的解

回溯法的三个关键要素:

  1. **路径:**已经做出的选择
  2. **选择列表:**当前可以做的选择
  3. **结束条件:**到达决策树底层,无法再做选择

回溯法框架:

func backtrack(路径, 选择列表) {
    if 满足结束条件 {
        result = append(result, 路径)
        return
    }

    for 选择 in 选择列表 {
        // 做选择
        路径.add(选择)
        backtrack(路径, 选择列表)
        // 撤销选择
        路径.remove(选择)
    }
}

2. 实战扩展:通用组合问题

例子:生成所有有效的 IP 地址

LeetCode 93: 给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。

func restoreIpAddresses(s string) []string {
    result := []string{}
    if len(s) < 4 || len(s) > 12 {
        return result
    }

    current := []string{}

    var backtrack func(start int)
    backtrack = func(start int) {
        // 已经有 4 段,且用完了所有字符
        if len(current) == 4 {
            if start == len(s) {
                result = append(result, strings.Join(current, "."))
            }
            return
        }

        // 尝试取 1-3 个字符
        for i := 1; i <= 3 && start+i <= len(s); i++ {
            segment := s[start : start+i]

            // 检查是否有效的 IP 段
            if (i > 1 && segment[0] == '0') || // 不能有前导 0
               (i == 3 && segment > "255") {    // 不能大于 255
                continue
            }

            current = append(current, segment)
            backtrack(start + i)
            current = current[:len(current)-1]
        }
    }

    backtrack(0)
    return result
}

3. 变形题目

变形1带权重的字母组合

每个数字对应字母,但字母有不同的权重(频率),要求按权重排序返回组合。

变形2键盘路径

给定两个数字,返回从第一个数字的字母到第二个数字的字母的所有路径。

变形3有效单词组合

给定数字字符串和单词列表,返回所有能组成的有效单词组合。

func letterCombinationsValidWords(digits string, wordList []string) []string {
    allCombinations := letterCombinations(digits)
    wordSet := make(map[string]bool)
    for _, word := range wordList {
        wordSet[word] = true
    }

    result := []string{}
    for _, combo := range allCombinations {
        if wordSet[combo] {
            result = append(result, combo)
        }
    }

    return result
}

4. 优化技巧

优化1提前终止

如果当前组合不可能形成有效解,提前终止递归。

func letterCombinationsPrune(digits string) []string {
    // 预先计算每个数字的字母数量
    letterCount := map[byte]int{
        '2': 3, '3': 3, '4': 3, '5': 3,
        '6': 3, '7': 4, '8': 3, '9': 4,
    }

    // 计算总组合数
    totalCombinations := 1
    for _, digit := range digits {
        totalCombinations *= letterCount[digit]
    }

    // 如果组合数过多,可以提前返回
    if totalCombinations > 10000 {
        return []string{} // 或者返回部分结果
    }

    return letterCombinations(digits)
}

优化2并行处理

对于长数字字符串,可以并行处理不同分支。

func letterCombinationsParallel(digits string) []string {
    if len(digits) <= 2 {
        return letterCombinations(digits)
    }

    // 分割任务
    mid := len(digits) / 2
    leftDigits := digits[:mid]
    rightDigits := digits[mid:]

    // 并行处理
    leftCh := make(chan []string, 1)
    rightCh := make(chan []string, 1)

    go func() {
        leftCh <- letterCombinations(leftDigits)
    }()

    go func() {
        rightCh <- letterCombinations(rightDigits)
    }()

    leftCombinations := <-leftCh
    rightCombinations := <-rightCh

    // 合并结果
    result := []string{}
    for _, left := range leftCombinations {
        for _, right := range rightCombinations {
            result = append(result, left+right)
        }
    }

    return result
}

5. 实际应用场景

  • 短信验证码: 生成验证码的所有可能组合
  • 密码破解: 暴力破解基于数字密码的字母组合
  • 自动补全: 输入部分数字时,提示所有可能的单词
  • 数据压缩: 使用数字编码代替字母组合

6. 面试技巧

面试官可能会问:

  1. "回溯法和递归有什么区别?"
  2. "如何优化空间复杂度?"
  3. "如果输入非常长,如何处理?"

回答要点:

  1. 回溯法是递归的一种特殊形式,强调在搜索过程中撤销选择
  2. 使用迭代法可以减少递归栈空间
  3. 考虑分治、并行处理或者只返回部分结果

7. 相关题目推荐

  • LeetCode 17: 电话号码的字母组合(本题)
  • LeetCode 22: 括号生成
  • LeetCode 39: 组合总和
  • LeetCode 46: 全排列
  • LeetCode 77: 组合
  • LeetCode 78: 子集
  • LeetCode 93: 复原 IP 地址