刷过LeetCode的同学应该都绕不开这道“电话号码的字母组合”。它排在第17位,题面短、一眼能看懂,但它真正教你的不是字符串拼接,而是一整套回溯遍历的思路。很多人在这一步就把“回溯模板”刻进了脑子里,后面做全排列、子集、组合总和的时候才发现,原来好多难题的底子早就打在这道简单题里了。这篇文章我会把这道题从题意、原理、代码到坑点完整拆一遍,并把类似的题目串起来讲,适合零基础刚接触回溯的同学,也适合刷到中等题开始卡壳、想回头巩固模板的人。
1. 题目到底在问什么
1.1 数字键与字母映射规则
老式手机上,数字键下方都印着一组字母:2对应abc,3对应def,4对应ghi,5对应jkl,6对应mno,7对应pqrs,8对应tuv,9对应wxyz。这道题说的是,给一个只含数字2到9的字符串,你需要把所有可能的字母组合都列出来。
举个例子,输入是"23",那就是先看数字2,它可能是a、b、c中间的某一个;再看数字3,它可能是d、e、f中间的某一个。两段拼接起来,一共3乘3等于9种组合:ad、ae、af、bd、be、bf、cd、ce、cf。
所以这个题并不涉及什么高深的数据结构,本质就是让你“把每一层的选择挨个排列一遍”。如果你玩过那种选择分支的互动小说,或者填过下拉联动的表单,应该能很快理解这种感觉:第一级选省份,第二级选城市,把所有省市的组合都列出来。
有些同学可能会问,0和1怎么办?题目已经明确限制是2到9,所以不用处理0和1的特殊映射。不过也有变体题会加入0和1,这时候一般让它们映射成空字符串或者特殊符号,面试时如果题目没说明,最好主动问一句。
1.2 为什么说它是回溯的“敲门砖”
这道题被放进Hot 100,不是因为它难,而是因为它太适合作为回溯算法的入口。回溯算法的核心是“做选择、递归、撤销选择”。电话号码的字母组合正好把这个过程展示得非常直白:每一位数字是一个决策层,每一层你从对应的字母里选一个,走到最后就得到一条完整路径,然后折返回来换一种选法。
如果你直接上循环求解,会发现一个问题:输入数字的个数不确定,两层循环、三层循环没法写死。比如"23"用两重循环能解决,"234"就得三重循环,数字一长,循环嵌套的层数就跟着变长,这显然不可行。回溯通过递归来处理“层数不确定”的问题,每一层递归对应一个数字位置,天然适配这种场景。
很多人说回溯难,其实是没抓住它的骨架。在我看来,回溯就是一个带“反悔”功能的深度优先搜索:往前走,走到头记录答案,再退回上一步尝试别的分支。电话号码字母组合这一步迈出去,后面的全排列、组合总和、N皇后其实都在复用同一个思维模型。
2. 解法一:回溯算法,推荐新手先掌握
2.1 回溯的决策树思维
先建立直觉。以"23"为例,你脑子里可以画一棵树:根节点是空字符串,第一层有a、b、c三个分支,第二层每个分支下面又各挂d、e、f三个分支。
回溯的过程就是从根一路走到叶子,比如先走a,再走d,得到"ad";然后回头,从d换成e,得到"ae";再换成f,得到"af";a的分支全部走完,回到根节点,换到b,重复同样的操作。
这里的“回头”就是回溯里的“撤销选择”。很多人写回溯老是忘记撤销这一步,结果路径被上一分支的遗留数据污染,输出一堆奇奇怪怪的重复组合。我会在2.3里单独讲这个点,因为它太重要了。
决策树的层数等于输入数字的长度,每层节点的分支数等于该数字对应字母的个数。整棵树的叶子节点数就是答案的总数。这样想,题目的结构就非常清楚:你要做的不是一次性构建出所有字符串,而是沿着一条路径累积字符,遇到叶子就保存,然后逐层返回。
2.2 标准代码与逐行拆解
先上我最推荐的版本,用Python写:
def letterCombinations(digits: str) -> List[str]: if not digits: return [] phone = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' } result = [] path = [] def backtrack(index: int) -> None: if index == len(digits): result.append(''.join(path)) return current_digit = digits[index] for char in phone[current_digit]: path.append(char) backtrack(index + 1) path.pop() backtrack(0) return result这段代码里,backtrack(index)表示当前要处理第index位数字。递归终止条件非常关键:当index等于digits的长度时,说明所有数字都已经处理完毕,这时候path里面存的就是一个完整组合,立刻join成字符串存入result。
循环内部做的事情是:
- 取出当前数字对应的所有候选字母;
- 把其中一个字母加到
path末尾; - 调用
backtrack(index + 1)处理下一位数字; - 从
path末尾把这个字母弹出去。
你可能会问,path.pop()这行代码到底在干什么?它就是在模拟“走回头路”。假设当前path已经走到了["a", "d"],记录完"ad"之后,如果不把"d"弹出,下一个循环试图加"e"时,path就会变成["a", "d", "e"],这完全不是我们想要的。弹出"d"后,path恢复成["a"],再加"e"才能得到["a", "e"]。
另外有同学喜欢这样写参数:backtrack(index + 1, path + [char]),这样就不需要显式pop了,因为它每次递归都会产生一个新列表,不影响父层的path。这种写法更简洁,但内存开销稍大,因为每次递归都会创建新列表。个人建议初学阶段还是写成显式append和pop,对理解回溯的过程更有帮助。
2.3 撤销选择为什么必须做
从2.2的代码里能看出,path.pop()和path.append(char)是严格配对出现的。这个配对关系,是整个回溯算法的命脉。
我见过不少同学把path.pop()删掉,运行结果里出现了"add""ade"这种长度超过2的字符串,或者出现大量重复内容。原因很简单:没有撤销,path就会一直累积,下一次递归看到的起点就不对了。
为了加深理解,可以做一个对比实验:给"23"跑一遍没有path.pop()的代码,你会得到["ad", "ade", "adef"...]这样的结果,完全乱套。这就像你在一棵树上爬,爬完一根树枝后不退回分叉口,直接从半空中跳去另一根树枝,路径自然就串了。
如果你用的是每次创建新列表的写法,比如backtrack(index + 1, path + [char]),其实也做了“撤销”,只是通过“复制旧列表再追加”的方式完成的,父层的path从未被修改,所以不需要显式恢复。两种思路殊途同归,但理解append和pop的配对关系,对后面解子集、排列问题非常关键。
3. 解法二:队列BFS迭代写法
3.1 逐层扩展的想法来源
回溯是深度优先的思路,一条路走到底再回来。但其实这道题也可以用广度优先的思路:初始队列里放一个空字符串,然后读取第一个数字,把空字符串弹出,拼上该数字对应的所有字母,得到一批长度为1的字符串;再把这一批当成新的队列基础,读取第二个数字,弹出每一个长度为1的字符串,分别拼上第二个数字对应的所有字母,得到一批长度为2的字符串。如此逐层扩展,直到所有数字都处理完。
这个方法很像“面包屑发酵”:一开始只有一块面团,每经过一层数字,每一块面团都被揉进新的字母,变成好几块。因为是一层一层推进的,所以天然不需要递归,也就不存在栈溢出的顾虑。
从思维模型上讲,BFS解法更容易被没有接触过递归的人理解:它就是把“组合”这件事当成“逐位追加”来操作。每处理一位数字,你就把所有当前结果从头扫一遍,给每个结果追加新字母。这个操作逻辑非常直观,特别适合作为第一遍理解题目时的辅助方法。
不过要提醒一点:BFS解法在结果顺序上和DFS未必完全一样,但对这道题来说,题目只要求返回所有组合,没有指定顺序,所以用什么遍历方式都可以。
3.2 两种迭代代码实现与对比
第一种是用collections.deque实现标准队列:
from collections import deque def letterCombinations(digits: str) -> List[str]: if not digits: return [] phone = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' } q = deque(['']) for digit in digits: size = len(q) for _ in range(size): prefix = q.popleft() for char in phone[digit]: q.append(prefix + char) return list(q)这里有个关键细节:进入循环前先记录size = len(q),只对当前层级的字符串个数做处理。如果不记录这个size,而直接写for _ in range(len(q)),因为循环过程中队列在持续变长,len(q)是动态变化的,会导致当前层还没处理完,新扩展出来的字符串就被当成下一层拿去继续拼接,结果直接出错。
第二种写法是直接用列表推导式,每处理一个数字就生成一个新列表:
def letterCombinations(digits: str) -> List[str]: if not digits: return [] phone = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' } result = [''] for digit in digits: result = [ prefix + char for prefix in result for char in phone[digit] ] return result列表推导式版本非常简洁,每个循环结束后result就更新为“处理完当前数字后的全部组合”。自己不太推荐在面试时第一时间写这个版本,它虽然短,但内层循环的嵌套关系如果不熟练,容易在现场一紧张就写错。我更推荐先把deque版本写清楚,再用列表推导式做优化。
两种写法的时间复杂度一样,但列表推导式会创建很多临时列表,内存表现不如deque版本稳定。如果你追求极致简洁,可以用推导式;如果追求可读性和稳定性,推荐deque版本。
4. 复杂度分析和容易踩的坑
4.1 时间与空间复杂度到底是多少
看到这个题,很多分析会说“时间复杂度是O(4的n次方)”,这其实是一个方便理解的简写,但不完全精确。严格来说,输入数字中大部分对应3个字母,7和9对应4个字母。设m为对应3个字母的数字个数,n为对应4个字母的数字个数,总组合数是3的m次方乘以4的n次方。每个组合生成过程中,需要做一次字符串拼接或join,时间复杂度约等于输入长度,也就是m加n。所以总时间复杂度是乘上这个长度的。
但如果用大O上界来看,全部数字都假设是4个字母,那组合数最多是4的N次方,N为数字个数。把每个组合构建成本也考虑进去,整体上界可以写成O(N乘以4的N次方),或者更宽松地记为O(4的N次方)。面试时你可以这样说:“最坏情况下每个数字映射4个字母,所以组合数量级是4的N次方,构建每个组合还需要线性时间。”然后补充一句“7和9才是4个字母,严格讲是3的m次方乘4的n次方”,这样给人的感觉就是你真的懂,而不是背结论。
空间复杂度方面,递归解法需要递归栈,深度等于数字数量,所以是O(N)。除此之外,结果集本身占用的空间没有计入额外空间复杂度,通常认为额外空间是O(N)。如果用迭代解法,队列里最多会同时存在某一层的所有中间结果,最大规模也接近组合总数,所以最坏情况下额外空间会比递归大一些,这点在数据量非常大的时候才值得纠结,刷题阶段先清楚原理即可。
4.2 代码实现中最常见的几个错误
第一个错误是空输入处理。很多新手一上来就写:
if not digits: return [""]然后发现输出是[""],而题目要求返回空列表[]。为什么会错?因为空字符串理论上也算一个组合,但这里没有对应的数字,所以结果应该是“没有任何组合”,不是“一个空组合”。这个细节在LeetCode上会直接判错,务必记得单独判断空输入。
第二个错误是忘了撤销选择,或者撤销位置写错。撤销操作必须紧跟在递归调用之后,在同一个循环体内。如果你把path.pop()放到了循环外面,那它实际上只会在一整个for循环结束后执行一次,根本无法做到“每个分支都恢复原状”。
第三个错误是join的时机。如果你在path长度为1的时候就尝试''.join(path),得到的是单个字母,不是完整结果。必须在index == len(digits)也就是所有数字处理完时才拼接。
第四个错误是循环里修改正在迭代的列表。BFS解法里特别容易犯这个错:
for prefix in result: for char in phone[digit]: result.append(prefix + char)这个写法会把result长度不停变长,循环永远无法结束,或者处理到后来新增的字符串,导致重复拼接。解决办法就是3.2里说的,先记录size,或者直接创建新列表。
4.3 边界用例与输入健壮性
除了空输入,还有几个边界用例值得测一下:
- 输入"2":只对应一个数字,结果应该是["a", "b", "c"]。这个用例可以验证单个数字的处理是否正常。
- 输入"23":标准用例,结果应该包含9个组合,且组合长度都是2。
- 输入"7":对应pqrs四个字母,结果应该是["p","q","r","s"]。这个用例能验证4个字母的数字是否被正确映射。
- 输入"79":包含两个4字母数字,组合数是4乘以4等于16个。
如果你的映射表不小心把7和9写成了3个字母,这类用例会直接暴露问题。另外,题目的数字范围是2到9,所以映射表不需要为0和1准备键值。但是如果你写了一个防御性版本,遇到未出现在映射表里的字符,应该跳过还是报错?这取决于题目约定。在标准LeetCode版本里,输入保证合法,所以不用处理。但如果是扩展场景,我个人倾向于返回空列表或者直接抛异常,避免静默产生错误结果。
5. 从这题延伸到整个回溯专题
5.1 一套模板打天下
很多文章会总结“回溯模板”,我自己刷了上百道回溯题后,发现最核心的框架就是这三步:
- 判断终止条件,记录结果;
- 遍历当前层的所有候选;
- 递归下一层,然后撤销选择。
套用到电话号码字母组合,就是:终止条件为处理完所有数字;候选为当前数字对应的字母;递归后弹出字母。
再比如全排列问题,候选是“还没用过的数字”,所以需要额外维护一个used数组来标记哪些数字已经用过;终止条件是路径长度等于数组长度。括号生成则是候选只有左右括号,但需要凭借额外条件判断当前是否还能放右括号。子集问题的终止条件更微妙,大多数解法是在每次递归进入时都记录当前路径,而不是等到底层才记录。
理解了这套模板,你会发现电话号码字母组合其实是所有回溯题里面最简单的“骨架”:没有去重、没有排序、没有剪枝、没有全局状态冲突。它纯粹就是为了让你把“递归加撤销”的肌肉记忆建立起来。所以我的建议是,第一遍刷题时不要一上来就去看什么优化技巧,老老实实把模板写三遍,写到不用思考自然浮现为止。
5.2 面试里怎么展示这道题
这道题在面试中通常是作为“热身题”出现的。面试官不太会期待你写一个多惊艳的优化版本,更看重你能否清晰讲出自己的思路。我会在面试现场这样组织回答:“这题可以看成一棵多叉树的深度优先遍历。每层处理一个数字,分支是数字对应的字母。我用一个path记录当前路径,走到叶子就记录答案,然后回溯到上一个节点继续。”
接下来,我会主动补充复杂度和边界条件:“空输入返回空列表;最坏复杂度是4的n次方量级,因为7和9对应4个字母;递归栈深度是n。” 如果面试官追问能不能不用递归,我就会拿出deque版本的BFS解法,并解释逐层扩展的思路。这样一套组合拳下来,基本能覆盖面试官想考察的点:思路清晰度、代码整洁度、对复杂度分析的理解,以及是否掌握多种实现方式。
5.3 实际工作中的应用场景
虽然刷题和业务开发看上去是两个世界,但“枚举所有组合”这件事在现实里确实会用到。比如电话外呼系统里,如果用户只提供了部分号码,比如某个位置确实不知道是几,系统可能想生成所有可能的候选号码再逐一验证。又比如OCR识别结果中,某个字符有多个候选识别值,需要生成所有可能的文本组合让下游进一步处理。再比如按模板生成短信内容,每个槽位有几种可选文案,想自动生成全量组合做测试。
这些场景本质和电话号码字母组合一模一样:多层嵌套的可选集合,需要穷举所有排列路径。虽然真实项目里很少直接手写递归,而是会用更函数式的组合库,比如Python的itertools.product一步搞定,但理解底层的枚举逻辑,能帮助你判断什么时候该暴搜、什么时候该用动态规划剪枝。从这个角度看,这道题的价值不在于它本身多复杂,而在于它是一把打开“组合枚举”大门的钥匙。
我在实际刷题过程中最大的体会是,回溯算法入门期千万不能贪多,把一道简单题吃透,比草草过十道题有用得多。电话号码的字母组合恰好就是这样一道值得反复咀嚼的题目:今天用递归写一遍,明天用队列写一遍,后天再把模板套到全排列上。几轮下来,你会发现自己对“选择、递归、撤销”这套节奏有了肌肉般的本能反应。后面再遇到组合总和、单词搜索这类题,就不会一上来就慌神了,因为你已经见过回溯最简单的样子。