📖 第61课:电话号码字母组合
想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:https://github.com/tingaicompass/AI-Compass
仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。
📖 第61课:电话号码字母组合
模块:回溯算法 | 难度:Medium ⭐⭐
LeetCode 链接:https://leetcode.cn/problems/letter-combinations-of-a-phone-number/
前置知识:第59课(全排列)、第60课(子集)
预计学习时间:25分钟
🎯 题目描述
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。数字到字母的映射与电话按键相同(注意 1 不对应任何字母)。
示例:
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
解释:2对应"abc",3对应"def",需要列出所有两两组合
示例2:
输入:digits = ""
输出:[]
解释:空字符串没有字母组合
示例3:
输入:digits = "2"
输出:["a","b","c"]
解释:只有一个数字,返回该数字对应的所有字母
约束条件:
- 0 <= digits.length <= 4 — 最多4个数字
- digits[i] 是范围 ['2', '9'] 的数字 — 不包含0和1
🧪 边界用例(面试必考)
| 用例类型 | 输入 | 期望输出 | 考察点 |
|---|---|---|---|
| 空字符串 | digits="" | [] | 边界处理 |
| 单个数字 | digits="2" | ["a","b","c"] | 基础功能 |
| 两个数字 | digits="23" | ["ad","ae","af","bd","be","bf","cd","ce","cf"] | 笛卡尔积 |
| 包含7/9 | digits="79" | 4×4=16种组合 | 按键字母数不同 |
| 最大规模 | digits="2222" | 3^4=81种组合 | 性能边界 |
💡 思路引导
生活化比喻
想象你在设置一个密码锁,每个转盘上有几个字母可选。
🐌 笨办法:如果有2个转盘,你先固定第一个转盘在'a',然后转动第二个转盘尝试所有字母;然后固定第一个转盘在'b',再转动第二个转盘...这样非常麻烦,而且容易遗漏组合。
🚀 聪明办法:用一个指针从左到右移动,每到一个转盘,尝试它的所有字母,记录下来,然后递归处理下一个转盘。这就是多叉树回溯的核心思想——每个数字对应的字母数量不同,形成一棵度数不同的多叉树,我们要遍历这棵树的所有从根到叶的路径。
关键洞察
这是一个多叉树的深度优先遍历问题,每层的分支数由当前数字对应的字母数量决定(2-9对应3-4个字母),需要收集所有从根到叶子的路径。
🧠 解题思维链
这一节模拟你在面试中"从零开始思考"的过程。
Step 1:理解题目 → 锁定输入输出
- 输入:字符串 digits,长度0-4,每位是'2'-'9'
- 输出:字符串数组,包含所有可能的字母组合
- 限制:数字不同对应的字母数量不同(2-6,8对应3个字母,7和9对应4个字母)
Step 2:先想笨办法(暴力法)
最直接的想法是用多层嵌套循环:如果输入"23",就写两层for循环遍历所有组合。
- 时间复杂度:O(3^n × 4^m),n是对应3个字母的数字数量,m是对应4个字母的数字数量
- 瓶颈在哪:循环层数不确定(取决于digits长度),无法预先写好代码
Step 3:瓶颈分析 → 优化方向
笨办法的核心问题是"不知道要写几层循环"。
- 核心问题:输入长度是变化的,无法用固定层数的循环解决
- 优化思路:用递归代替循环,每次处理一个数字,递归处理剩余数字
Step 4:选择武器
- 选用:回溯算法(Backtracking) + 多叉树遍历
- 理由:
- 每个数字对应多个字母,构成多叉树的一层
- 要枚举所有组合,本质是遍历从根到叶子的所有路径
- 回溯框架天然适合递归生成排列组合问题
🔑 模式识别提示:当题目出现"生成所有组合"、"枚举所有可能",优先考虑"回溯算法"
🔑 解法一:回溯法(标准解法)
思路
用回溯框架:
- 维护一个映射表 phone_map,数字→字母列表
- 从第一个数字开始,尝试它对应的每个字母
- 选择一个字母后,递归处理下一个数字
- 当处理完所有数字时,记录当前组合
图解过程
示例:digits = "23"
根("")
/ | \
a b c ← 第1层:数字'2'对应"abc"
/|\ /|\ /|\
d e f d e f d e f ← 第2层:数字'3'对应"def"
路径收集:
根→a→d => "ad" ✓
根→a→e => "ae" ✓
根→a→f => "af" ✓
根→b→d => "bd" ✓
... (共9条路径)
回溯过程示意:
Step 1: path="" → 选'a' → path="a"
Step 2: path="a" → 选'd' → path="ad" → 到达叶子,记录"ad" → 回退
Step 3: path="a" → 选'e' → path="ae" → 到达叶子,记录"ae" → 回退
Step 4: path="a" → 选'f' → path="af" → 到达叶子,记录"af" → 回退
Step 5: 回退到根 → 选'b' → path="b"
Step 6: path="b" → 选'd' → path="bd" → ...
空字符串情况:
digits = ""
根节点 → 没有子节点 → 直接返回 [] (空列表)
Python代码
from typing import List
def letterCombinations(digits: str) -> List[str]:
"""
解法一:回溯法
思路:多叉树DFS,每层选择一个字母,递归处理下一个数字
"""
if not digits: # 边界:空字符串返回空列表
return []
# 步骤1:建立数字到字母的映射表
phone_map = {
'2': 'abc',
'3': 'def',
'4': 'ghi',
'5': 'jkl',
'6': 'mno',
'7': 'pqrs',
'8': 'tuv',
'9': 'wxyz'
}
result = []
# 步骤2:定义回溯函数
def backtrack(index: int, path: str):
"""
index: 当前处理到digits的第几个数字
path: 当前已选择的字母组合
"""
# 递归终止条件:处理完所有数字
if index == len(digits):
result.append(path) # 记录完整路径
return
# 获取当前数字对应的字母列表
current_digit = digits[index]
letters = phone_map[current_digit]
# 遍历该数字对应的所有字母(多叉树的所有分支)
for letter in letters:
# 选择:将当前字母加入路径
backtrack(index + 1, path + letter)
# 撤销:Python字符串不可变,path自动回退
# 步骤3:从第0个数字开始回溯
backtrack(0, "")
return result
# ✅ 测试
print(letterCombinations("23")) # 期望输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
print(letterCombinations("")) # 期望输出:[]
print(letterCombinations("2")) # 期望输出:["a","b","c"]
print(letterCombinations("79")) # 期望输出:16种组合(7对应pqrs,9对应wxyz)
复杂度分析
- 时间复杂度😮(3^N × 4^M) — N是对应3个字母的数字个数,M是对应4个字母的数字个数
- 最坏情况:digits="7777"(全是4个字母的数字),生成 4^4=256 种组合
- 具体地说:输入"23"时,第一层3个分支,第二层每个分支3个子分支,共3×3=9次递归调用
- 每次递归构建字符串的成本是O(N),总时间 O(N × 3^N × 4^M)
- 空间复杂度😮(N) — 递归栈深度等于digits长度,最多4层
优缺点
- ✅ 代码简洁清晰,符合回溯框架
- ✅ 自动处理变长输入,不需要写嵌套循环
- ✅ 易于理解,面试时容易写对
- ❌ 字符串拼接在Python中有一定开销(不过在N≤4时可忽略)
🏆 解法二:回溯优化版(列表拼接,最优解)
优化思路
解法一每次递归都进行字符串拼接(path + letter),Python中字符串不可变,每次拼接都会创建新字符串。我们可以:
- 用列表 path 代替字符串,列表的 append/pop 是 O(1)
- 只在最终收集结果时才用
''.join(path)转为字符串
💡 关键想法:用可变数据结构(列表)代替不可变数据结构(字符串),减少中间对象创建
图解过程
示例:digits = "23"
回溯过程(列表版本):
Step 1: path=[] → 选'a' → path=['a']
Step 2: path=['a'] → 选'd' → path=['a','d'] → join→"ad" → pop
Step 3: path=['a'] → 选'e' → path=['a','e'] → join→"ae" → pop
Step 4: path=['a'] → 选'f' → path=['a','f'] → join→"af" → pop
Step 5: path=[] (已pop 'a') → 选'b' → path=['b']
...
优势:每次只修改列表末尾,不创建中间字符串
Python代码
def letterCombinations_v2(digits: str) -> List[str]:
"""
解法二:回溯优化版(列表拼接)
思路:用列表代替字符串拼接,减少中间对象创建
"""
if not digits:
return []
phone_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
result = []
def backtrack(index: int, path: List[str]):
# 递归终止:收集结果
if index == len(digits):
result.append(''.join(path)) # 只在这里拼接字符串
return
# 多叉树遍历
letters = phone_map[digits[index]]
for letter in letters:
path.append(letter) # 选择
backtrack(index + 1, path) # 递归
path.pop() # 撤销
backtrack(0, [])
return result
# ✅ 测试
print(letterCombinations_v2("23")) # 期望输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
print(letterCombinations_v2("")) # 期望输出:[]
print(letterCombinations_v2("2")) # 期望输出:["a","b","c"]
复杂度分析
- 时间复杂度😮(3^N × 4^M) — 与解法一相同,但常数更小
- 列表操作 append/pop 是 O(1)
- 最终join一次的成本是 O(digits长度),相比每次递归拼接,总开销更小
- 空间复杂度😮(N) — 递归栈 + path列表,都是O(N)
⚡ 解法三:迭代法(BFS逐层扩展)
优化思路
不用递归,改用迭代:从空字符串开始,每次读取一个数字,将当前所有组合与该数字的字母拼接,生成新一轮组合。
💡 关键想法:把问题看成BFS逐层扩展,第i层是处理了前i个数字后的所有组合
图解过程
digits = "23"
初始: combinations = [""]
处理数字'2'(对应"abc"):
对 "" 分别拼接 a,b,c
→ combinations = ["a", "b", "c"]
处理数字'3'(对应"def"):
对 "a" 拼接 d,e,f → "ad","ae","af"
对 "b" 拼接 d,e,f → "bd","be","bf"
对 "c" 拼接 d,e,f → "cd","ce","cf"
→ combinations = ["ad","ae","af","bd","be","bf","cd","ce","cf"]
返回 combinations
Python代码
def letterCombinations_v3(digits: str) -> List[str]:
"""
解法三:迭代法(BFS逐层扩展)
思路:每次处理一个数字,将现有组合与新字母拼接
"""
if not digits:
return []
phone_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
combinations = [""] # 初始:空字符串
# 逐个处理每个数字
for digit in digits:
letters = phone_map[digit]
new_combinations = []
# 对现有每个组合,分别拼接当前数字的所有字母
for combination in combinations:
for letter in letters:
new_combinations.append(combination + letter)
combinations = new_combinations # 更新为新一轮组合
return combinations
# ✅ 测试
print(letterCombinations_v3("23")) # 期望输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
print(letterCombinations_v3("")) # 期望输出:[]
print(letterCombinations_v3("2")) # 期望输出:["a","b","c"]
复杂度分析
- 时间复杂度😮(3^N × 4^M) — 与回溯法相同,但无递归开销
- 空间复杂度😮(3^N × 4^M) — 需要存储所有中间组合
🐍 Pythonic 写法
利用 itertools.product 实现笛卡尔积:
from itertools import product
def letterCombinations_pythonic(digits: str) -> List[str]:
"""Pythonic写法:利用itertools.product计算笛卡尔积"""
if not digits:
return []
phone_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
# 获取每个数字对应的字母列表
letter_groups = [phone_map[d] for d in digits]
# itertools.product计算笛卡尔积
# product("abc", "def") → ('a','d'), ('a','e'), ('a','f'), ('b','d'), ...
return [''.join(combo) for combo in product(*letter_groups)]
# ✅ 测试
print(letterCombinations_pythonic("23")) # 期望输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
这个写法用到了:
- 列表推导式:快速构建letter_groups
- itertools.product:计算多个可迭代对象的笛卡尔积
- 解包运算符
*:将列表解包为多个参数传给product
⚠️ 面试建议:先写清晰版本(解法一/二)展示思路,再提Pythonic写法展示语言功底。
面试官更看重你的思考过程(如何从问题分析到回溯框架),而非代码行数。
📊 解法对比
| 维度 | 解法一:回溯(字符串) | 🏆 解法二:回溯(列表,最优) | 解法三:迭代BFS |
|---|---|---|---|
| 时间复杂度 | O(N × 3^N × 4^M) | O(3^N × 4^M) ← 常数更优 | O(3^N × 4^M) |
| 空间复杂度 | O(N) | O(N) ← 递归栈 | O(3^N × 4^M) |
| 代码难度 | 简单 | 简单 | 中等 |
| 面试推荐 | ⭐⭐ | ⭐⭐⭐ ← 首选 | ⭐⭐ |
| 适用场景 | 教学清晰版 | 面试首选,性能最优 | 理解多种思路 |
为什么解法二是最优解:
- 时间复杂度已达理论最优(必须枚举所有组合)
- 空间复杂度O(N)仅存递归栈,不存中间组合
- 用列表操作代替字符串拼接,常数更优
- 代码简洁清晰,面试中最容易写对
面试建议:
- 先口述思路:"这是多叉树遍历,用回溯框架,每层选择一个字母"
- 写出🏆解法二(列表版回溯),边写边解释关键步骤
- 重点强调多叉树特点:"每个数字对应的字母数不同,所以是度数不固定的多叉树"
- 手动测试边界:空字符串、单个数字、包含7/9的情况
- 追问时可以提解法三(迭代)展示多种思路
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:请你解决一下这道题。
你:(审题30秒)好的,这道题要求根据电话键盘的数字生成所有可能的字母组合。让我先分析一下...
首先这是一个枚举所有组合的问题,我的第一想法是用嵌套循环,但输入长度不固定,无法写死循环层数。所以应该用递归的回溯算法。
核心思路是:把问题看成一棵多叉树,每个数字对应的字母构成一层,每层的分支数取决于该数字有几个字母。比如'2'对应"abc"有3个分支,'7'对应"pqrs"有4个分支。我们要遍历从根到叶子的所有路径。
时间复杂度是 O(3^N × 4^M),N是有3个字母的数字个数,M是有4个字母的数字个数,最多4位所以最多256种组合。
面试官:很好,请写一下代码。
你:(边写边说)
def letterCombinations(digits: str) -> List[str]:
if not digits:
return [] # 边界:空字符串
# 建立映射表
phone_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
result = []
def backtrack(index, path):
# 终止条件:处理完所有数字
if index == len(digits):
result.append(''.join(path))
return
# 多叉树遍历:尝试当前数字对应的每个字母
letters = phone_map[digits[index]]
for letter in letters:
path.append(letter) # 选择
backtrack(index + 1, path) # 递归下一层
path.pop() # 撤销选择
backtrack(0, [])
return result
我用列表path来存当前路径,避免字符串拼接的开销。回溯框架的三个要素:选择-递归-撤销,都在这里了。
面试官:测试一下?
你:用示例"23"走一遍...(手动模拟)
- 第一层选'a',第二层分别选'd','e','f',得到"ad","ae","af"
- 回退到第一层选'b',第二层再次遍历,得到"bd","be","bf"
- 同理得到"cd","ce","cf"
- 共9种组合,符合预期
再测边界情况:空字符串直接返回空列表,单个数字"2"返回["a","b","c"],都正确。
高频追问
| 追问 | 应答策略 |
|---|---|
| "时间复杂度能更优吗?" | "不能,因为必须枚举所有组合,3N×4M已经是最优。但可以用列表代替字符串拼接,优化常数项。" |
| "如果输入很长呢?" | "题目限制最多4位,最多256种组合,完全可接受。如果更长(如10位),组合数爆炸式增长(3^10≈59000),需要考虑分批生成或流式处理。" |
| "能不能不用递归?" | "可以,用迭代法:从空字符串开始,每次读一个数字,将现有组合与新字母拼接(解法三)。但递归版本更清晰,面试推荐递归。" |
| "如果要按字典序输出?" | "当前代码已经是字典序,因为我们从小到大遍历字母。如果映射表乱序,可以先对letters排序。" |
🎓 知识点总结
Python技巧卡片 🐍
# 技巧1:列表append/pop代替字符串拼接 — 提升性能
path = []
path.append('a') # O(1)
path.pop() # O(1)
result = ''.join(path) # O(n),但只在最后做一次
# 技巧2:字典映射避免大量if-else
phone_map = {'2': 'abc', '3': 'def', ...}
letters = phone_map[digit] # 直接索引,无需if判断
# 技巧3:itertools.product计算笛卡尔积
from itertools import product
list(product("abc", "def")) # [('a','d'), ('a','e'), ...]
💡 底层原理(选读)
多叉树回溯 vs 二叉树回溯
- 二叉树回溯(如子集问题):每个节点只有"选/不选"两个分支,是固定的二叉树
- 多叉树回溯(如本题):每个节点的分支数不固定,取决于当前数字对应的字母数
- 本题的多叉树度数范围是3-4,如果是全排列,每层的度数递减(第1层n个分支,第2层n-1个分支...)
回溯的本质:深度优先遍历决策树,用递归隐式维护栈,path记录当前路径。撤销操作(pop)是回溯的关键,确保不同路径不互相干扰。
算法模式卡片 📐
- 模式名称:多叉树回溯
- 适用条件:需要枚举所有组合,每步有多个选择(数量不固定)
- 识别关键词:"所有字母组合"、"电话号码"、"生成所有可能"
- 模板代码:
def backtrack_multi_tree(index, path):
# 终止条件
if index == n:
result.append(path[:]) # 记录路径
return
# 获取当前层的所有选择(多叉)
choices = get_choices(index)
for choice in choices:
path.append(choice) # 选择
backtrack_multi_tree(index + 1, path) # 递归
path.pop() # 撤销
易错点 ⚠️
- 忘记处理空字符串 — 要在函数开头加
if not digits: return [] - 字符串拼接性能问题 — Python字符串不可变,
path + letter每次创建新对象,建议用列表 - 结果收集时机错误 — 要在
index == len(digits)时收集,而不是在遍历字母时收集 - 混淆index和digit — index是位置(0,1,2...),digit是具体数字('2','3'...),别用错
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
- 场景1:验证码生成系统 — 根据用户手机号后4位,生成所有可能的字母验证码候选,用于防刷机制
- 场景2:智能输入法 — T9输入法中,用户按下数字键,预测所有可能的单词(需要结合字典树Trie优化)
- 场景3:自动化测试 — 生成所有可能的参数组合,用于穷举测试(类似笛卡尔积)
- 场景4:密码破解 — 已知密码由数字对应字母组成,枚举所有可能密码(暴力破解)
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 22. 括号生成 | Medium | 约束回溯 | 在回溯时加约束:左括号数≤n,右括号数≤左括号数 |
| LeetCode 77. 组合 | Medium | 组合回溯 | 与本题类似,但有剪枝优化空间 |
| LeetCode 39. 组合总和 | Medium | 回溯+剪枝 | 下一课内容,注意元素可重复选取 |
| LeetCode 401. 二进制手表 | Easy | 枚举+回溯 | 枚举所有LED灯的亮灭组合,可用回溯 |
📝 课后小测
试试这道变体题,不要看答案,自己先想5分钟!
题目:假设电话键盘新增了数字'0'和'1',其中'0'对应空格' ','1'对应特殊字符'!@#'。如何修改代码支持这两个新数字?
💡 提示(实在想不出来再点开)
只需要在 phone_map 中添加 '0' 和 '1' 的映射,回溯框架无需改动。
✅ 参考答案
def letterCombinations_extended(digits: str) -> List[str]:
if not digits:
return []
# 扩展映射表
phone_map = {
'0': ' ', # 空格
'1': '!@#', # 特殊字符
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
result = []
def backtrack(index, path):
if index == len(digits):
result.append(''.join(path))
return
letters = phone_map[digits[index]]
for letter in letters:
path.append(letter)
backtrack(index + 1, path)
path.pop()
backtrack(0, [])
return result
# 测试
print(letterCombinations_extended("01"))
# 输出:[' !', ' @', ' #'] (空格+特殊字符的组合)
核心思想:回溯框架的通用性很强,只需修改映射表,递归逻辑完全不变。这就是算法模式的威力——一次掌握,处处适用。
如果这篇内容对你有帮助,推荐收藏 AI Compass:https://github.com/tingaicompass/AI-Compass
更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。

浙公网安备 33010602011771号