☰
电话号码的字母组合:回溯DFS/BFS模板与复杂度详解
2026/10/15 6:37:36 网站建设 项目流程

最近在刷 Hot100 的时候,第 17 题“电话号码的字母组合”给我留下的印象特别深。题目本身不难,但卡住的点非常多:有人以为就是多层循环嵌套,有人写回溯却忘了撤销,还有人连“空字符串输入应该返回什么”都要和面试官反复确认。这道题表面上是“数字映射字母”,内核其实是组合枚举和递归状态管理,是后面一堆回溯题的基础。

所以这篇文章我不打算只贴一个答案,而是把题目背后的思路、代码落地、边界情况、复杂度陷阱,以及从这题能迁移出去的算法能力全部拆开讲。无论你是准备面试刷题,还是刚接触回溯想找个切入点,这篇内容都能直接复用。

1. 题目到底在问什么:从输入输出看本质

1.1 原始题面解读

题面很简短:给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。数字到字母的映射和九宫格老式手机一样:

  • 2 -> abc
  • 3 -> def
  • 4 -> ghi
  • 5 -> jkl
  • 6 -> mno
  • 7 -> pqrs
  • 8 -> tuv
  • 9 -> wxyz

比如输入"23",输出就是:["ad","ae","af","bd","be","bf","cd","ce","cf"]。

第一眼看,这像是“查表 + 枚举”的问题。但真正动手写的时候会发现,输入字符串长度是不固定的,可能是"2",也可能是"234567"。如果写死嵌套循环,长度一变代码就要重写,所以必须换一种能动态扩展层数的思路。

1.2 为什么要做这道题

这道题在 Hot100 里的位置非常微妙。它比“两数之和”那种纯哈希题难一点,又比“全排列”“子集”那种完整回溯题简单一点,属于典型的“桥梁题”。做了这道题,你会自然理解递归树长什么样,也会知道“路径”和“选择列表”这两个回溯概念到底怎么回事。

另外一个容易被忽略的价值是:这道题的输入规模很小,2-9 每个数字最多映射 4 个字母,最终组合数量上限是4^n。但它能帮你检验对复杂度分析的理解。很多人一看到递归就只会说“指数级”,却说不清为什么是 O(3^m × 4^n),这个细节在面试里是加分点。

2. 解题思路拆解:为什么第一反应是回溯

2.1 树形结构与递归模型

把输入"234"画成递归树,就能很直观地看到组合的产生过程:

  • 第一层处理数字2,有a/b/c三个分支;
  • 第二层处理数字3,在每一个上游分支下再分出d/e/f;
  • 第三层处理数字4,继续扩展g/h/i。

这就是一棵三叉树,每一层的选择范围由当前数字的映射字母表决定。递归函数天然适合这种“每一层做同一件事,只是参数不同”的结构,所以回溯成了首选方案。

2.2 回溯的“选择-递归-撤销”三板斧

回溯题的核心套路可以浓缩成三步:

  1. 选择:从当前数字的映射字母里挑一个字符,拼到当前路径上;
  2. 递归:把索引往后移一位,处理下一个数字;
  3. 撤销:递归返回后,把刚才拼上去的字符删掉,恢复现场。

撤销那一步看起来多余,实际是灵魂。如果不撤销,循环到下一个字母时,路径里还残留上一个字母,结果就会从"ad"变成"adbe"这种乱七八糟的东西。

2.3 另一种视角:BFS 队列层扩

回溯是 DFS 的思路,但也可以用 BFS 实现。用一个队列,初始放一个空字符串,然后每处理一个数字,就把队头的每个字符串和该数字的所有映射字母拼接,再放回队列。处理完所有数字后,队列里就是全部答案。

BFS 的代码有时比 DFS 更直观,尤其适合“按层生成”的场景。但缺点是需要额外维护队列,内存上比回溯的递归栈稍高。两种方法建议都写一遍,能帮你理解“递归深度优先”和“迭代广度优先”的本质区别。

3. 代码落地:从第一版到能跑的版本

3.1 数字到字母映射表

映射表最简单的写法就是用一个数组,下标直接对应数字:

phone = [ "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" ]

数组下标 0 和 1 留空,因为题目输入里只有 2-9。这样phone[2]就是"abc",phone[7]就是"pqrs"。比字典更清爽,访问也快。

3.2 回溯主流程实现

先看最经典的 DFS 版本:

def letter_combinations(digits: str): if not digits: return [] phone = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"] result = [] path = [] def dfs(index: int): # 递归出口:已经处理完所有数字 if index == len(digits): result.append("".join(path)) return letters = phone[int(digits[index])] for ch in letters: path.append(ch) # 选择 dfs(index + 1) # 递归 path.pop() # 撤销 dfs(0) return result

重点看递归出口。index == len(digits)表示所有数字都被处理过,此时path里存的是一条完整路径,需要转成字符串后加入结果集。join这一步很关键,因为path是字符列表,直接append(path)会把列表本身存进去,最后得到一堆['a','d']而不是"ad"。

3.3 BFS 实现对比

再看 BFS 版本:

def letter_combinations_bfs(digits: str): if not digits: return [] phone = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"] queue = [""] for digit in digits: letters = phone[int(digit)] next_queue = [] for cur in queue: for ch in letters: next_queue.append(cur + ch) queue = next_queue return queue

这里有一个小优化:每一层生成新队列next_queue,而不是直接在原队列上pop和append。直接原地操作容易让循环次数变混乱,因为队列长度在迭代过程中会变化。用临时队列承载下一层结果,代码更清晰,也不容易出 bug。

对于"23",BFS 的过程是:

  • 初始队列[""]
  • 处理2后队列变成["a", "b", "c"]
  • 处理3后,每个元素拼接def,最终得到 9 个结果

这个思路非常适合口述给面试官,因为它的层级推进非常明确。

4. 常见问题与性能细节

4.1 空输入到底返回什么

这是最容易踩的坑。输入空字符串时,逻辑上没有任何数字,所以应该返回一个空列表[],而不是[""]。

为什么[""]不对?因为它代表“存在一个组合,空字符串是其中一个结果”,但“没有数字”和“数字映射出的字符串为空”是两回事。题目要求的是字母组合,数字都没有,自然就没有组合。

实现上,建议在函数入口直接判断:

if not digits: return []

4.2 不是所有数字都有字母:0 和 1 的处理

题目明确说输入仅包含 2-9,所以常规解法不用处理 0 和 1。但如果你的工具函数要被别人调用,还是建议在映射表里留空,并在dfs里加一个跳过逻辑:

letters = phone[int(digits[index])] if not letters: dfs(index + 1) return

这样即使输入里混入了 1,也不会报错。实际面试时主动提这一点,能体现考虑问题的全面性。

4.3 复杂度计算与剪枝

假设输入字符串长度是 n,其中 m 个数字映射 3 个字母,另外 n-m 个数字映射 4 个字母,那么组合总数是3^m * 4^(n-m)。时间和空间复杂度都是这个数。

很多人会忽略空间复杂度里的递归栈。最坏情况下递归深度是 n,所以额外空间是 O(n)。如果用小顶堆、优先级队列之类的结构来优化,反而不合适,因为这道题必须枚举所有结果,组合数本身就有指数爆炸的性质,不存在低于O(3^m × 4^n)的解。

至于剪枝,这道题几乎不需要剪枝,因为每一条路径都要走到最后。真正需要剪枝的是像“组合总和”“N 皇后”那种有终止条件的题。

4.4 边界条件速查表

输入预期输出说明
""[]无数字,无组合
"2"["a","b","c"]单个数字直接展开
"23"9 个组合3×3
"27"12 个组合3×4
"234"27 个组合3×3×3
"79"16 个组合4×4

这个表还可以当验证用例,写完代码直接对着跑一遍,能快速确认基础逻辑没写错。

5. 实战扩展:这题背后的能力怎么迁移

5.1 子集、排列、组合模板化

电话号码字母组合做完之后,你会发现它和“全排列”“子集”“组合总和”非常像,区别主要在于:

  • 选择范围不同:全排列每次要从没选过的元素里选,子集可选可不选,这题只能从当前数字的映射字母里选;
  • 终止条件不同:全排列和这题都是“选满所有位”结束,子集是“每个元素都决定过后”结束;
  • 是否需要撤销:这题和全排列都需要撤销,因为同一条路径上的状态要反复复用。

推荐把“选择-递归-撤销”模板背下来,遇到新题先在纸上画递归树,搞清楚“这一层有哪些选择”“递归到哪一层结束”“是否需要恢复现场”,基本就能套上框架。

5.2 从手机键盘到路径搜索

这道题的递归树模型还能迁移到更复杂的场景,比如在网格里找路径、括号生成、单词搜索。它们本质上都是“在每个状态点枚举下一步可能的选择,沿着一条路走到底,不行就回头”。

比如括号生成,每一层可以选择放左括号或右括号,但右括号必须有左括号兜底,所以多了两个约束条件。和电话号码组合一对比,差别就在“选择合法性判断”上。

我个人的建议是:不要只刷题,要尝试把每一道回溯题都画成树,再总结不同题之间“选择逻辑”和“终止逻辑”的差异。画完三到五道之后,再看新题会轻松很多。

最后分享一个小技巧。很多公司面试时会让现场写这道题,千万别一上来就写 DFS,先和面试官确认输入范围、是否只有 2-9、空输入返回什么。这几句沟通既能让代码边界更明确,也是展示你思考完整性的时候。代码量不大,但细节确实不少,值得反复手写几遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询