📖 第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) + 多叉树遍历
  • 理由:
    1. 每个数字对应多个字母,构成多叉树的一层
    2. 要枚举所有组合,本质是遍历从根到叶子的所有路径
    3. 回溯框架天然适合递归生成排列组合问题

🔑 模式识别提示:当题目出现"生成所有组合"、"枚举所有可能",优先考虑"回溯算法"


🔑 解法一:回溯法(标准解法)

思路

用回溯框架:

  1. 维护一个映射表 phone_map,数字→字母列表
  2. 从第一个数字开始,尝试它对应的每个字母
  3. 选择一个字母后,递归处理下一个数字
  4. 当处理完所有数字时,记录当前组合

图解过程

示例: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中字符串不可变,每次拼接都会创建新字符串。我们可以:

  1. 用列表 path 代替字符串,列表的 append/pop 是 O(1)
  2. 只在最终收集结果时才用 ''.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"]

这个写法用到了:

  1. 列表推导式:快速构建letter_groups
  2. itertools.product:计算多个可迭代对象的笛卡尔积
  3. 解包运算符 *:将列表解包为多个参数传给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)仅存递归栈,不存中间组合
  • 用列表操作代替字符串拼接,常数更优
  • 代码简洁清晰,面试中最容易写对

面试建议:

  1. 先口述思路:"这是多叉树遍历,用回溯框架,每层选择一个字母"
  2. 写出🏆解法二(列表版回溯),边写边解释关键步骤
  3. 重点强调多叉树特点:"每个数字对应的字母数不同,所以是度数不固定的多叉树"
  4. 手动测试边界:空字符串、单个数字、包含7/9的情况
  5. 追问时可以提解法三(迭代)展示多种思路

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题。

:(审题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()                  # 撤销

易错点 ⚠️

  1. 忘记处理空字符串 — 要在函数开头加 if not digits: return []
  2. 字符串拼接性能问题 — Python字符串不可变,path + letter 每次创建新对象,建议用列表
  3. 结果收集时机错误 — 要在 index == len(digits) 时收集,而不是在遍历字母时收集
  4. 混淆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 学习资料都在这里,后续复习和拓展会更省时间。

posted @ 2026-04-06 18:45  汀、人工智能  阅读(1)  评论(0)    收藏  举报