# 电话号码的字母组合 (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']` 的一个数字。 ## 思路推导 ### 暴力解法分析 **第一步:直观思路 - 嵌套循环** ```python 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:建立数字到字母的映射** ```python 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:设计回溯函数** ```python 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:处理边界情况** ```python if digits == "": return [] ``` ### 关键细节说明 **细节1:为什么用列表而不是字符串拼接?** ```python # 方法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 // Go 中 byte 更高效 phoneMap := map[byte]string{ '2': "abc", // digits[i] 是 byte 类型 '3': "def", // ... } ``` **细节3:如何处理空字符串输入?** ```python # 边界情况 if digits == "": return [] # 返回空数组,而不是 [""] ``` ### 边界条件分析 **边界1:空字符串** ``` 输入:digits = "" 输出:[] 原因:没有数字,无法生成组合 ``` **边界2:单个数字** ``` 输入:digits = "2" 输出:["a","b","c"] 过程:只需遍历 '2' 对应的字母 ``` **边界3:包含 7 或 9(4个字母)** ``` 输入: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))) } ``` ```go 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:忘记处理空字符串 ❌ **错误写法:** ```go func letterCombinations(digits string) []string { result := []string{} // 直接开始回溯,没有检查空字符串 backtrack(0, digits, &result) return result } ``` ✅ **正确写法:** ```go func letterCombinations(digits string) []string { if digits == "" { return []string{} // 返回空数组 } result := []string{} backtrack(0, digits, &result) return result } ``` **原因:**空字符串应该返回空数组,而不是开始回溯。 ### 错误2:撤销选择时索引错误 ❌ **错误写法:** ```go for i := 0; i < len(letters); i++ { current = append(current, letters[i]) backtrack(index + 1, digits, result) current = current[:len(current)] // 错误!没有真正删除 } ``` ✅ **正确写法:** ```go 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 就加入结果 ❌ **错误写法:** ```go if index == len(digits) { result = append(result, string(current)) // 如果 current 是共享的 return } ``` ✅ **正确写法:** ```go 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 // 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:** 在生成所有组合后,使用排序算法对结果进行排序。 ```go import "sort" func letterCombinationsSorted(digits string) []string { result := letterCombinations(digits) sort.Strings(result) return result } ``` ### Q3: 如果只要求返回第 k 个组合(从 1 开始),应该如何优化? **A:** 可以直接计算第 k 个组合,无需生成所有组合。 ```go 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. **结束条件:**到达决策树底层,无法再做选择 **回溯法框架:** ```go func backtrack(路径, 选择列表) { if 满足结束条件 { result = append(result, 路径) return } for 选择 in 选择列表 { // 做选择 路径.add(选择) backtrack(路径, 选择列表) // 撤销选择 路径.remove(选择) } } ``` ### 2. 实战扩展:通用组合问题 #### 例子:生成所有有效的 IP 地址 **LeetCode 93:** 给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。 ```go 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:有效单词组合 给定数字字符串和单词列表,返回所有能组成的有效单词组合。 ```go 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:提前终止 如果当前组合不可能形成有效解,提前终止递归。 ```go 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:并行处理 对于长数字字符串,可以并行处理不同分支。 ```go 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 地址