17. 电话号码的字母组合 (Medium)

专题归类: 08-回溯算法 LeetCode 链接: https://leetcode.cn/problems/letter-combinations-of-a-phone-number/


在线做题: 写 Python 代码并运行 · Java 跳转 LeetCode

题目描述

给定一个仅包含数字 2-9 的字符串 digits,返回所有它能表示的字母组合。答案可以按任意顺序返回。

数字到字母的映射与电话按键相同:

  • 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'] 的一个数字

题目详细分析

数据范围含义:

  • digits.length <= 4:最多 4 位数字,最坏情况是 7 或 9(对应 4 个字母),4^4 = 256 种组合,完全在可枚举范围内。
  • 输入可能为空字符串,此时返回空列表,这是必须处理的边界情况。

核心约束:

  • 每个数字对应一组字母,从每组中选一个字母进行组合。
  • 组合结果的长度必须等于输入数字的个数。
  • 数字 1 不在映射范围内,题目保证输入只有 2-9。

与全排列的区别:

  • 全排列中每一层的选择列表相同(未使用的全部元素),而这里每一层的选择列表由当前数字决定,每层不同。

小白版直白理解

就像老式手机键盘,按 “23” 时:

  • 按 2 可以选 a/b/c
  • 按 3 可以选 d/e/f

你要把所有可能的字母组合列出来:a 搭配 d/e/f 得到 ad、ae、af,b 搭配 d/e/f 得到 bd、be、bf,c 搭配 d/e/f 得到 cd、ce、cf。

就像到了自助餐厅,第 1 道菜有 3 种选择,第 2 道菜有 3 种选择,你把所有搭配方式都试一遍。


解题思路

思路一:回溯法(推荐)

核心思想: 逐位处理每个数字,当前数字对应的每个字母都是一个分支。这是一个多叉树的遍历问题——每层的分支数取决于当前数字对应几个字母。

决策树可视化(以 “23” 为例):

                   ""
            /       |       \
          a         b        c
        / | \     / | \    / | \
       d  e  f   d  e  f  d  e  f
       ad ae af  bd be bf cd ce cf
def letterCombinations(digits):
    if not digits:
        return []
 
    # 数字到字母的映射表
    mapping = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }
 
    res = []
 
    def backtrack(idx, path):
        # 终止条件:处理完所有数字
        if idx == len(digits):
            res.append(''.join(path))
            return
 
        # 获取当前数字对应的所有字母
        letters = mapping[digits[idx]]
        for ch in letters:
            path.append(ch)           # 做选择
            backtrack(idx + 1, path)  # 递归处理下一个数字
            path.pop()                # 撤销选择
 
    backtrack(0, [])
    return res

思路二:队列迭代法(BFS)

核心思想: 使用队列逐位生成组合。每处理一个数字,将当前队列中的所有字符串与当前数字的每个字母组合,生成新的队列。

def letterCombinations(digits):
    if not digits:
        return []
 
    mapping = {
        '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
        '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
    }
 
    from collections import deque
    queue = deque([''])  # 初始队列包含一个空字符串
 
    for digit in digits:
        letters = mapping[digit]
        # 处理当前层的所有字符串
        for _ in range(len(queue)):
            s = queue.popleft()
            for ch in letters:
                queue.append(s + ch)
 
    return list(queue)

时间复杂度: O(4^n),空间复杂度:O(4^n)(队列中最多同时存放 O(4^n) 个元素)。


易错点

  1. 空输入处理digits = "" 应该返回 [] 而不是 [""]。需在开头做特判。
  2. 数字与字符串混淆:映射表的 key 是字符串 '2' 而不是整数 2,用 digits[idx] 获取的也是字符。
  3. 回溯终止条件idx == len(digits) 时记录结果,此时 path 的长度也等于 len(digits)
  4. 数字 7 和 9 对应 4 个字母:不要遗漏 7→pqrs 和 9→wxyz 的第 4 个字母。
  5. 索引递增:递归调用 backtrack(idx + 1, ...) 而不是 backtrack(idx++, ...) 或忘记递增。

框架提炼

多叉树回溯模板(每层选择列表不同):

def backtrack(层数, 路径):
    if 层数 == 总层数:
        记录结果
        return
 
    当前层选择列表 = get_choices(层数)
    for 选择 in 当前层选择列表:
        路径.append(选择)
        backtrack(层数 + 1, 路径)
        路径.pop()

与标准回溯的区别:

  • 标准回溯(全排列)每层的选择列表相同,通过 visited 排除已选。
  • 本题每层的选择列表都由当前数字决定,层与层之间不同。
  • 没有”剪枝”需求,因为所有路径都是合法的。

关联题目

  • 46-全排列 — 理解”每层选择列表相同 vs 不同”的区别,巩固回溯通用模板。
  • 22-括号生成 — 同样是约束回溯,但括号生成多了左右括号数量的约束条件。
  • 39-组合总和 — 组合类回溯,每层选择列表与约束条件有所不同。
  • 93-复原IP地址 — 类似的字符串分割+组合问题,回溯方法一脉相承。