
第一次看到“电话号码的字母组合”这道题是我在准备技术面试的初期。当时觉得这题目太直白了电话键盘上 2 对应 abc3 对应 def输入“23”就是把两组字母拼起来写个嵌套循环不就完了真到动手写代码才发现digits 的长度是不固定的可能只有 1 位也可能有 10 位我总不能写 10 层 for 循环吧。这才是这道题真正的门槛——它用一道看似“入门”的字符串枚举题考察你对递归、回溯和状态还原的理解程度。很多刷题的同学都会背回溯模板但你问他“为什么递归完要 pop 一下”“空字符串该返回空列表还是包含一个空字符串的列表”“时间复杂度到底是 O(2^n) 还是 O(3^n)”一下子就卡住了。这篇文章我就从自己的做题和面试复盘经历出发把“电话号码的字母组合”彻底拆开讲透先拆解题目背后的考点再对比回溯法和 BFS 逐层扩展两种解法然后带你把代码从头到尾跑一遍最后整理我在实际面试和写工程代码时踩过的坑。适合刚接触回溯的同学作为第一道入门题也适合已经刷过不少题的人对照检查自己有没有理解偏差。1. 题目到底在考什么先别急着写代码1.1 一道“入门题”背后的三重考点“电话号码的字母组合”这道题看起来就是把数字映射成一个字母集合再把所有组合列出来。但真正要考察的并不是“你会不会遍历”而是三个更底层的能力。第一重是穷举能力。所有组合的数量会随着输入长度指数增长输入是“23”只有 9 个结果输入是“23456789”结果数量直接变成几十万。你要保证不遗漏、不重复地把所有结果枚举出来这要求对“选择”和“状态”有清晰认识。第二重是递归抽象能力。字符串长度不固定意味着你不能靠写死循环层数来解决。递归的核心是把“处理完某一位”之后的剩余问题交给一个同样逻辑、参数变化的函数去处理。你能不能在头脑里把一个长度为 n 的问题转化为“处理一位 处理剩余 n-1 位”的子问题是能不能写出干净递归的关键。第三重是边界与复杂度意识。输入为空怎么办输入包含 0 或 1 怎么办结果量爆炸时空间怎么算这些细节在真正的面试评价里往往比“能不能默写回溯模板”更具区分度。很多人把题做出来了却在空字符串返回值上挂了特别可惜。1.2 数字到字母的映射键盘布局就是你的字典这道题的基础映射关系来自传统电话键盘。数字 2 到 6 各对应 3 个字母7 和 9 分别对应 4 个字母pqrs 和 wxyz8 对应 3 个字母。题目里不会出现 0 和 1但你在写映射表的时候依然要留好位置。有一个容易忽略的细节1 和 0 在旧键盘上也有对应符号但在大部分编码题设定里是忽略不计的。我建议用一个长度为 10 的数组下标直接对应数字字符比如mapping[2] abc。这样代码可读性好也不容易在字典 key 上犯拼写错误。用数组还是用哈希表我个人推荐数组。因为电话号码的字符范围就是 0 到 9连续且有限数组访问是 O(1)而且避免了哈希函数开销。更重要的是面试时你写一个长度 10 的数组面试官一眼就能看出你对数据结构的选择是有意识的不是随便抄来的。2. 两种解法路线DFS 回溯与 BFS 逐层扩展2.1 回溯法把选择路径画出来回溯法的核心思想可以概括成一句话一条路走到黑再退回来试另一条路。放到这道题里就是先从第一位数字的候选字母里选一个再进入下一位选一个直到所有数字都处理完把当前组合记录到结果里然后回退上一个选择尝试下一个候选字母。具体过程可以这样理解输入“23”第一位有 a、b、c 三个候选。先选 a接着第二位有 d、e、f 三个候选。选 d 得到“ad”记录退回第二位试 e得到“ae”再退回试 f得到“af”第二位全试完退回第一位选 b……最后得到所有 9 个组合。为什么需要“退回”这一步是因为我们在递归过程中复用了同一个“路径”变量。如果不回退路径就会一直累积变成“adbefc”这样的东西。你可以把路径变量想象成一个可擦写的记事本每尝试一个新的分支就用橡皮擦掉上一次写的末位再写上下一个候选。这个“擦除”操作就是回溯里最关键的一步。我曾经在给朋友讲这道题时做过一个类比你在一座迷宫里找出口手里拿一根绳子做标记。走到死胡同就往回走走回岔路口把绳子上标记的那条分支拆掉再挂上一个新标记。拆标记的过程不是浪费而是保证下一次探索不会背着上一次的冗余信息。2.2 BFS/队列法用“结果集”一层层长出来回溯法是深度优先的思路先纵向深入再横向扩展。另一种写法是用广度优先的思路把已经生成的部分组合保存在一个列表里每处理一个新数字就把列表里的每个元素扩展成多个新元素。用输入“23”举例初始结果集是[]。处理数字 2把空字符串分别拼上 a、b、c结果集变成[a, b, c]。处理数字 3遍历这个结果集里的每个元素再分别拼上 d、e、f就得到[ad, ae, af, bd, be, bf, cd, ce, cf]。这种思路更容易理解代码也更短。但要注意一个细节如果你想用“在同一个列表上原地扩展”的写法绝不能直接for prefix in res然后又res.append(prefix ch)因为列表在不断变长循环会永远走不完。比较稳妥的做法是每层都新建一个临时列表处理完再整体赋值回去。2.3 两种方案对比与选型建议对比维度DFS 回溯法BFS 逐层扩展法核心思想深度优先 状态回退广度优先 结果集迭代理解门槛需要理解递归和撤销操作更接近日常循环思维空间占用递归栈深度 O(n)每层结果集都要留存扩展能力容易加剪枝适配更多回溯题剪枝逻辑会变得比较复杂面试推荐度推荐优先掌握适合作为辅助思路展示我个人的建议是面试的时候优先讲回溯法。原因不是回溯更快而是它是一整套通用方法论学会了以后可以直接迁移到全排列、组合总和、N 皇后等问题上。BFS 逐层扩展虽然直观但在面试追问“如果我想剪枝怎么办”的时候实现起来往往不如回溯清晰。3. 实战手把手写出可运行的代码3.1 前置约定与边界条件在写任何实现之前先定义清楚题目约定。输入是一个字符串由数字 2 到 9 组成输出是所有可能的字母组合顺序没有强制要求但一般按映射表顺序返回更自然。空字符串输入应该返回空列表[]而不是包含一个空字符串的列表[]。这是最容易被忽略的边界条件。为什么要特别强调这个因为从数学上讲digits 时结果集应该是“一个空组合”但题目期望的是“没有任何组合”。这就好像从不打电话自然不应该有任何拨号结果而不是拨出了一个空号码。很多人在面试时用了if not digits: return []结果被测试用例打脸。如果输入中出现了 0 或 1题目规定之外的字符我建议做一层防御性处理。最懒也稳妥的方式是提前校验if not all(ch in 23456789 for ch in digits): return []。当然如果在 LeetCode 这种明确约束环境下这行不写也能过但写在工程代码里能让你的函数更健壮。3.2 Python 递归回溯实现下面是我最常用的 Python 写法尽量保持语义直白。这里用path列表保存当前组合用res保存最终结果。def letterCombinations(digits: str): if not digits: return [] mapping [, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz] result [] path [] def dfs(index: int): if index len(digits): result.append(.join(path)) return letters mapping[int(digits[index])] for ch in letters: path.append(ch) dfs(index 1) path.pop() dfs(0) return result我来逐行解释一下这段代码在干什么。dfs(0)表示从第 0 位开始处理。进入函数后先判断index是否等于len(digits)如果是说明所有数字已经处理完把当前path里的字符拼接成字符串加入结果。如果还没处理完就取出当前数字对应的所有候选字母逐一尝试。关键就在循环体里先把字母加入 path然后递归处理下一位等递归返回后再把刚加入的字母弹出。弹出操作不执行的话path 会越积越长最终结果也会完全错误。这个“后进先出”的顺序是整个回溯算法的灵魂。3.3 JavaScript 版本与运行过程追踪如果你主要用 JavaScript 刷题实现思路完全一样只是语法上要把int(digits[index])换成parseInt(digits[index])或Number(digits[index])。var letterCombinations function (digits) { if (!digits.length) return []; const mapping [, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz]; const result []; const path []; function dfs(index) { if (index digits.length) { result.push(path.join()); return; } const letters mapping[Number(digits[index])]; for (const ch of letters) { path.push(ch); dfs(index 1); path.pop(); } } dfs(0); return result; };我们实际走一遍letterCombinations(23)的过程。初始调用dfs(0)index 为 0digits[0] 是 2letters 是 abc。循环先取 apath 变为[a]调用dfs(1)。此时 index 为 1digits[1] 是 3letters 是 def。取 dpath 变为[a, d]调用dfs(2)。index 等于 2即 len(digits)于是把 ad 加入结果。回到dfs(1)的循环执行path.pop()path 变回[a]接着 for 循环取下一个字母 e得到 ae…… 依此类推最后得到[ad, ae, af, bd, be, bf, cd, ce, cf]。你会发现结果顺序和键盘映射表的字母顺序完全一致。这是因为回溯循环天然是按照候选字母的先后顺序展开的不会出现乱序。3.4 关于传值还是传引用的“顺手优化”有经验的面试官可能会追问你为什么我们要用 path 加 pop而不是直接给递归函数传一个拼接好的新字符串如果你给递归函数传的是dfs(index 1, path ch)在 Python 里字符串是不可变对象每次拼接都会新创建一个字符串对象。这个写法更简洁也不用写 pop看起来更不容易出错。但它带来的问题是每递归一层就要创建新字符串如果输入长度很大会产生大量的临时对象浪费内存和时间。那是不是必须用path.appendpath.pop的写法也未必。就这道题而言输入长度通常不超过十几位两种写法性能差距很小。但面试官想看到的是你对“传值拷贝”和“原地修改”两种策略有意识。回溯的通用模板里我们经常需要维护一些集合状态比如标记某个元素是否被使用过这种状态往往是数组或哈希表传值拷贝代价极高所以养成“追加—递归—撤销”的习惯对后面刷组合总和、全排列等问题会很有帮助。4. 测试用例与常见问题排查实录4.1 必须覆盖的几组测试用例我刷题有个习惯写代码前先想清楚测试用例写完代码第一时间跑边界。这道题我建议至少覆盖下面几组输入期望结果检验点[]空输入处理2[a, b, c]单数字输出23[ad,ae,af,bd,be,bf,cd,ce,cf]常规双数字79结果长度为 16验证 7 和 9 有四个字母222结果长度为 27全为三字母数字的组合数我特别强调79这个用例。因为 7 对应 pqrs、9 对应 wxyz都是四个字母很多人写映射表的时候一不小心漏掉一个字母结果输出数量只有 12 而不是 16。看到结果数不对第一反应应该是去检查映射表而不是检查回溯逻辑。另外如果你输入是234结果数量等于 3 * 3 * 3 27。如果输入是2347结果数量等于 3 * 3 * 3 * 4 108。你可以用这个数量关系快速验证代码是否漏掉了一些分支。4.2 面试中常见的几个坑第一个坑是空字符串返回值。前面已经说过要返回[]而不是[]。这个错误即使是有经验的候选人也会犯因为递归终止条件写顺手了就容易把所有场景都统一成返回一个空组合。第二个坑是结果收集时没有拷贝或拼接。在 Python 中如果你直接把path添加进result由于path是同一个对象后续的 append 和 pop 操作会不断修改它最后result里所有元素都会被修改成同一个最终状态。正确的处理是用.join(path)生成一个新字符串再添加进去。第三个坑是 BFS 原地扩展导致的死循环。这个我真的踩过。当时想省一个临时列表的创建直接在原列表里边遍历边 append结果程序跑起来就停不下来。后来排查才意识到列表长度一直在变化循环永远到不了头。这种问题非常隐蔽如果面试时出现会给人留下“基础不扎实”的印象。第四个坑是复杂度分析含混不清。很多人背了一句话“时间复杂度是 O(3^n)”但忽略了数字 7 和 9 对应 4 个字母。更严谨的说法是假设有 m 个数字对应 3 个字母n 个数字对应 4 个字母那么总组合数为 3^m * 4^n时间复杂度为 O(3^m * 4^n)。空间复杂度也有两层含义回溯过程本身只需要 O(len(digits)) 的递归栈空间但输出结果本身的空间是 O(组合数 * 结果长度)这是任何算法都躲不开的下界。4.3 结果顺序问题在 LeetCode 和大部分面试场景中输出顺序没有硬性要求。但如果你用回溯法按固定映射表顺序遍历得到的自然就是字典序。这是加分项面试官如果问“你的结果是有序的吗”你可以很笃定地解释原因。如果你用 BFS 逐层扩展法结果顺序其实也是字典序因为每一层都是按候选字母顺序拼接到旧前缀后面的。但如果你用了集合Set来存储中间结果顺序就无法保证了。所以我一般不建议在结果存储上使用无序结构纯属给自己添麻烦。5. 从这道题延伸出去回溯模板与工程思维5.1 变体一如果 0 和 1 有映射关系有些改编题会把 1 映射成标点符号把 0 映射成空格。这时候你只需要修改映射表回溯逻辑完全不用动。这也说明了为什么要把映射表单独抽出来而不是写死在循环条件里。代码里数据与逻辑分离后面无论是换键盘布局还是扩展字符集都很方便。5.2 变体二如果加了限制条件假如题目改成“组合中不能出现连续相同的字母”那回溯代码里只需要在递归入口加一个判断当path非空且path[-1] ch时跳过这个字母。这个操作叫剪枝本质是在进入下一层之前提前终止某些注定不合格的分支。再比如“每个数字只能用一次且同一个字母不能在组合里重复出现”那就需要维护一个使用标记集合在递归进入之前检查退出之后撤销。这些都是回溯模板的常规操作。我建议你把这道题当模板题去练多想一想“如果条件变了我要在哪个环节动手”会比单纯背代码更有收获。5.3 从算法题到真实工程数据量大时怎么办如果把这道题放到真实工程场景比如电话号码枚举、批量短信模板生成、批量外呼号码组合输入长度可能到 10 位甚至更多结果集可能几十万乃至上百万。这时候一次性把所有组合全部装入内存就可能出现内存峰值过高的问题。一种工程优化是使用生成器惰性求值。还是同样的回溯骨架只是把result.append(.join(path))改成yield .join(path)然后把递归调用改成yield from dfs(index 1)。这样每次只生成一个结果调用方可以边消费边丢弃内存占用大幅下降。def letterCombinationsGenerator(digits: str): if not digits: return mapping [, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz] path [] def dfs(index: int): if index len(digits): yield .join(path) return for ch in mapping[int(digits[index])]: path.append(ch) yield from dfs(index 1) path.pop() yield from dfs(0)需要注意的是生成器版本虽然在内存上更友好但如果你不小心在某一步把生成器转成列表优化就白做了。工程里一般配合 for 循环消费每拿到一个结果就做一次处理或写入这样内存曲线会平滑很多。最后再分享一个实际建议遇到这道题先在纸上写出映射表再想想空输入的情况然后再动手写代码。我见过太多候选人代码一行不差却在空字符串返回上被扣分太可惜。把“选择—递归—撤销”这三个动作练成肌肉记忆之后你再去看组合总和、全排列、N 皇后这些题会发现它们其实是同一个骨架。区别只是候选列表变复杂了剪枝条件变多了。这道“电话号码的字母组合”就是你把回溯思维焊进脑子的最佳起点。