☰
LeetCode第17题精讲:回溯算法与递归思维实战
2026/10/17 5:21:11 网站建设 项目流程

1. 从一道面试高频题说起:为什么"简单题"反而最考验功底

有不少人在初次遇到LeetCode第17题"电话号码的字母组合"时,第一反应是:这不就是把数字对应的字母循环排列组合一下吗,能有多难?结果动手一写就卡住了——不是不会写循环,而是不知道要写多少层循环。这种"看似简单、一写就卡"的体验,恰好说明了这道题的价值所在:它考的不是某个偏门API或高深的数学技巧,而是递归、状态枚举、回溯思想这些最基础也最核心的算法素养。

我第一次认真做这道题的时候,是在准备一次面试冲刺阶段。当时刷题已经刷过不少经典题,但看到"电话号码的字母组合"这个标题,还是下意识地把它归到了"字符串水题"里。直到模拟面试时被问到"如果输入长度不是固定的,你怎么确定循环层数",我才猛然意识到自己对这个问题的理解还停留在"能AC就行"的层面。从那个时候起,我开始把这道题当作回溯法入门的标杆题来重做,反复拆解它的状态树、递归轨迹和边界条件,收获比想象中大得多。

这道题的核心任务很直接:给定一个仅包含数字2-9的字符串(在原始题目中通常不含0和1),返回所有它能表示的字母组合,映射关系就是电话键盘上每个数字对应的那几个字母。输出顺序随意,但组合必须完整。比如输入"23",输出就是["ad","ae","af","bd","be","bf","cd","ce","cf"]这9个组合。

如果你正准备面试、正在学算法,或者刷了不少题但总觉得自己对回溯的理解停留在"背模板"的阶段,这篇文章能帮你把这题吃透:从为什么暴力嵌套循环行不通,到回溯函数的三要素设计,再到复杂度推导、边界处理、迭代解法对照,最后聊聊这题的面试变体和思维迁移。刷题不只是为了AC,而是通过一道题建立起一类题的解决框架,这才是"简单题"背后真正的含金量。

2. 为什么嵌套循环不是答案:从"变长循环层数"到状态树思维

2.1 暴力法的根本困境:循环层数由输入长度决定

有人会想:输入是"23",那就两层循环;输入是"234",那就三层循环;输入是"2345",那就四层循环。听起来似乎可行,但问题在于,输入的长度是变化的。你不能为长度3写一个三层循环的解法,为长度4再写一个四层循环的解法,因为调用方可能传入任意长度的数字串。

这不是"多写几个if分支"能解决的问题。即便你用switch来处理不同长度的情况,输入的极限长度是无法预估的。这道题的输入限制可能不超过4位,但算法题考察的是思路的一般性。如果面试官把输入长度放宽到10位、20位,你的解决方案还能成立吗?用写死的嵌套循环显然不行。

这里有一个关键认知:循环层数必须和"枚举维度"对应。动态规划里的多重循环是固定的维度(比如背包体积、物品数量都是确定的),而这题里枚举的维度就是数字串的长度——它是变量,不是常量。凡是遇到"枚举层数随输入数据规模变化"的情况,通常都需要用递归来替代多重循环。这不是巧合,而是递归天然支持"未知层级"的展开:每次递归深入一层,路径的深度动态累积,直到达到目标深度。

2.2 把组合过程画成一棵树

与其硬想循环怎么写,不如把组合的生成过程可视化。假设输入是"23",第一个数字是2,它对应'a'、'b'、'c';第二个数字是3,它对应'd'、'e'、'f'。每一个组合的生成,都等价于:从第一层选择一个字母,再从第二层选择一个字母。

把这个过程画成树:根节点是空的,第一层有三个分支(对应2的三个字母),第二层每个分支下面又有三个子分支(对应3的三个字母)。从根到每一个叶子节点的路径,就对应一个完整的字母组合。整棵树有多少个叶子,就有多少种组合。

这就是状态树。它直观回答了一个问题:为什么这题叫"组合"问题,本质上是"路径枚举"问题。组合生成不是凭空去"算"一个结果出来,而是在这棵树上遍历所有从根到叶子的路径。既然是在树上遍历,那自然就想到深度优先搜索(DFS)或者广度优先搜索(BFS)。回溯法,本质上就是在深度优先遍历状态树时,通过"撤销选择"来回到上一个岔路口,继续探索其他分支。

2.3 回溯的直觉:走迷宫与撤销选择

很多人开始学回溯时,总是把"回溯"理解得很玄乎。其实用一个迷宫类比就够了:你在一个岔路口选择了一条路走进去,走到尽头发现不是出口,你需要退回这个岔路口,再走另一条路。这个"退回去"的动作就是回溯。放到状态树里,每进入一层递归就相当于沿着一个分支往下走,每结束一次递归回到上一层,就相当于退回岔路口。

这里最关键的动作是"恢复现场"(也叫撤销选择)。如果你在一条路径上往数组里加了一个字符,探索完这条路径后,必须把这个字符弹出来,否则下一组分支的起点就会带着之前路径的残留字符,生成出错误的组合。这一点是回溯的核心,也是很多人写回溯代码时最容易因为"忘了弹栈"而踩坑的地方。

有了上述认知,代码结构就已经很清晰了:递归里做"选择—递归—撤销"三步,递归到最深层(处理完最后一位数字)时收集路径。

3. 回溯函数的三要素拆解:从零手写干净可扩展的解法

3.1 前置准备:键位映射表与结果容器

在写回溯函数之前,先把键位映射准备好。常见的写法是直接用数组,下标对应数字,比用字典更轻量。因为题目输入是字符串,每个字符转成数字后直接作为下标取映射即可。

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

这样phone[2]就是"abc",phone[7]就是"pqrs",0和1是空字符串(题目本来就不包含它们,留着占位就行)。有读者可能会问,为什么不用字典?因为数组的下标天然就是数字本身,省去一次键值查询和类型转换,而且代码读起来更直白。在算法题里,能用数组表达的就别用字典,这是我在实测中总结的一个小偏好,能让代码更清爽。

结果容器用一个列表res来收集所有组合,这没什么好说的。唯一需要注意的是,如果输入为空字符串,题目要求返回空列表,而不是包含一个空串的列表。这个细节后面会专门讲到。

3.2 回溯函数参数设计:该传什么、不该传什么

递归函数的参数设计,决定了代码的健壮性和可读性。我习惯把参数设计成这几个:

  • digits:原始输入数字串,全程不变,作为全局数据传入。
  • idx:当前处理到第几位数字(也可以叫pos或depth)。
  • path:当前拼接出的部分组合,用List存储还是用String存储,后面会讲。
  • res:收集最终结果的列表。

其中,idx是递归深度的标志,当idx == len(digits)时,说明已经处理完了所有数字,此时path里存的就是一个完整的组合。这里有个设计上的抉择:是否需要显式传递idx?答案是必须的,否则递归无法知道自己当前处于第几层。你也可以用剩余子串suffix来替代idx,但那样每次都要做子串切片,时间和空间开销更大。用idx是最省成本的方式。

3.3 终止条件与结果收集:什么时候该把path加入res

终止条件是递归的出口,处理不好就会出现无限递归或者漏掉结果。这题的终止条件非常符合直觉:当idx等于digits的长度,说明每一位数字都已经处理完毕,此时把path转换成字符串并加入res。

一个常见的疑问是:为什么在终止条件里收集结果,而不是在递归调用之前收集?因为在深度优先遍历中,一个完整的组合只出现在从根到叶子的整条路径结束时。如果你在每一层都收集,就会得到一堆不完整的前缀,那显然不是题目要求的结果。这就像拼积木,必须等所有模块都拼好了,才算完成一个成品。

还有一点容易被忽略:当digits为空时,idx == len(digits)在第一次调用时就成立,于是会加入一个空字符串。但题目要求返回空列表,所以需要进行特殊处理。我的做法是,在进入回溯函数之前直接判断,若digits为空,立即返回空列表,这样既干净又避免了在递归逻辑里写额外分支。

3.4 核心递归逻辑:选择、深入、撤销三件套

当idx小于数字串长度时,取出当前数字对应的字母串letters,遍历其中的每个字符,执行三步操作:

  1. 把当前字符加入path(选择)。
  2. 递归调用dfs(digits, idx + 1, path, res)(深入下一层)。
  3. 把刚才加入的字符从path中移除(撤销选择)。

这三步缺一不可。选择与撤销构成了一对平衡操作:每次递归返回后,path的长度必须和当前深度保持一致,否则后续分支就会基于一个"被污染"的路径继续生长。你可以试着想一下:如果不做撤销,第一轮循环把'a'加进去,递归完所有以'a'开头的组合后,'a'还留在path里,下一轮再加'b',就变成了"ab",这显然是错的。

写到这里,很多人会问:字符是直接加在列表里,那最后一行的''.join(path)是什么意思?因为path是列表,每个元素是单字符,拼成字符串时需要把它们连接起来,用join一次性完成即可。如果直接把path定义为字符串,虽然也可以直接拼接,但字符串是不可变对象,每次path + char都会生成一个新字符串,在递归路径多的时候会产生较多临时对象。用列表加join的方式,在回溯场景下更高效、更符合撤销操作的要求。

下面是完整的Python实现,这是我在实际刷题中最常用、也最推荐使用的结构:

from typing import List class Solution: def letterCombinations(self, digits: str) -> List[str]: if not digits: return [] phone = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"] res = [] def dfs(idx: int, path: List[str]) -> None: if idx == len(digits): res.append("".join(path)) return letters = phone[int(digits[idx])] for ch in letters: path.append(ch) dfs(idx + 1, path) path.pop() dfs(0, []) return res

这段代码有几个细节值得专门说明。第一,dfs闭包直接引用了外层digits、phone和res,所以参数列表可以精简到只传idx和path,这让代码更紧凑。第二,letters可能是空字符串(比如输入里出现'0'或'1'),此时for循环什么都不执行,这个分支就自然被跳过了,不会报错也不会产生死循环。第三,path.pop()必须在递归返回后立刻执行,哪怕后面还有代码,也要确保它在当前循环的末尾出现。

4. 复杂度、边界情况与实测表现:刷题前必须想清楚的三件事

4.1 时间复杂度:为什么答案数量本身就是上界

很多文章直接把时间复杂度写成O(3^m × 4^n),其中m是输入中对应3个字母的数字个数,n是输入中对应4个字母的数字个数,但这个式子是怎么来的,值得展开讲一下。假设输入串长度为L,其中某些数字(如2、3、4、5、6、8)对应3个字母,另一些数字(如7、9)对应4个字母。最终组合的总数就是每个数字可选字母数的乘积,即3的m次方乘以4的n次方。

而生成这每一个组合,都需要O(L)的时间来拼接字符串、写入结果列表,所以总时间复杂度应当写作这两个量级的乘积:O(3^m × 4^n × L)。在很多题解里,因为L本身就是m加n,且m、n的比例可以合并进指数表达式,所以简写为O(3^m × 4^n)也说得过去,但严格来讲,把生成单个字符串的L时长也纳入更严谨。

这在面试中是一个极好的加分点。当面试官问时间复杂度时,如果你能直接说出"答案总数就是组合数的下界,任何解法都不可能低于这个复杂度",会展示出对问题本质的理解。因为这道题的输出本身就要求返回所有组合,所以最优解的时间复杂度至少是组合总数,不可能通过"剪枝"把复杂度降到更低。理解了这一点,你就不会在优化方向上走弯路。

4.2 空间复杂度:递归栈的深度其实很浅

空间复杂度主要由两部分构成:递归调用时函数栈的深度,以及结果列表的存储空间。递归深度最多等于数字串长度L,因此栈空间是O(L)。结果列表占用的空间自然是O(3^m × 4^n × L),但通常不把输出计入空间复杂度,所以标准答案是O(L)。如果面试官要求把输出也算进去,那你需要如实说明输出空间是组合总数级别。

这道题的递归深度是由输入长度决定的,而输入长度在题目中通常比较小(比如不超过4),所以栈空间根本不用担心。但我在实测中会故意用长输入(比如10个'2')来测试,也不会栈溢出,Python递归默认深度限制是1000,而这个输入长度只有10,距离上限还很远。真正需要警惕的是,如果题意扩展为处理任意长度数字串,并且长度达到几百,递归深度就会逼近甚至超过Python默认的递归上限,这时可以考虑用迭代解法(后面会讲到)。

4.3 边界用例清单:这几种情况最容易出错

我在实际编写和测试这道题时,整理出了下面几个值得优先验证的边界情况,它们也是面试官最常用来"卡人"的测试用例。

  • 空字符串输入:digit = "",应返回空列表[]。
  • 单个数字输入:如digit = "2",应返回["a", "b", "c"]。
  • 包含数字0或1的输入:如digit = "0"或"10"。原始题目约定输入只含2-9,但有的变体或面试官临时扩展时会让0、1不带任何字母,代码需要安全跳过它们。
  • 只含0和1的输入:如digit = "01",此时没有任何可用字母,应返回空列表。我的代码里,for循环对空字符串天然不执行,所以这个逻辑是对的。
  • 包含非数字字符的输入:题目通常不会出现这种情况,但如果你在实际项目中复用这段逻辑,最好在入口处加一层过滤或校验,避免解析异常。

这里我遇到过的一个真实坑是:把空输入时返回值写成了[""]。这在很多类似题目里是合理的(比如某些需要返回根路径的场景),但这道题明确期待空列表,所以务必在入口处做判断。还有个坑是,有人会把收集结果的条件写成idx == len(digits) - 1,这样会在最后一个数字处理完之前就收集结果,导致每个组合都缺少最后一位字母,不是少了一个字符,而是整串都不完整,调试时会觉得很诡异。

4.4 实测与自测思路:不要只盯着AC

一道题AC之后,我建议你再多跑几个用例,而不是立刻跳到下一题。我自己的习惯是,在本地环境里跑一遍上面列出的边界用例,并且顺手打几条调试日志,看看path在每层递归中的变化。比如输入"23"时,前两次完整路径应该是:第一个循环里,path依次变成['a']、['a','d'],收集"ad"后,弹掉'd'回到['a'],再添加'e'变成['a','e']。这个"进栈—收集—出栈—换分支"的节奏如果能在脑子里跑通,说明你真的理解了回溯的执行过程。

可视化工具也有帮助,你可以把状态树画在纸上,然后对照代码一行一行走一遍,看看代码的递归深度是否和树的层数一一对应。这个过程虽然慢,但对建立递归直觉特别有效,尤其是对于从没接触过DFS的新手,比刷十道题都强。

5. 队列迭代法:BFS路径展开与两种解法的实战对比

5.1 用队列逐层组合的思路

回溯法是深度优先遍历,先走到树的最深处,再回头。那自然有人会问:广度优先能解吗?答案是能,而且写起来往往更直观。思路很简单:用一个队列,初始时放入一个空字符串,然后依次遍历digits中的每一个数字,每次把队列中现有的所有字符串弹出,在它们后面分别拼上当前数字对应的所有字母,再把拼好的新字符串重新入队。处理完所有数字后,队列里剩下的字符串就是全部组合。

举个例子,输入"23":队列初始为[""];处理数字2后,弹出空串,拼上a、b、c,队列变为["a","b","c"];处理数字3时,依次弹出"a",拼上d、e、f得到"ad","ae","af",再弹出"b"得到"bd","be","bf",再弹出"c"得到"cd","ce","cf",最终队列就是那9个组合。

这正是广度优先遍历状态树的路径:每一轮处理一个层级的节点,把上一层的部分组合扩展到下一层。它不需要显式地"撤销选择",因为每次都是基于队列里已有的字符串创建新的字符串,原字符串出队后就不再被修改,天然不会有路径污染问题。

5.2 Python队列BFS实现

Python标准库的collections提供了双端队列deque,直接用它即可。如果没有deque,用列表模拟也能行,但pop(0)在列表头部弹出一个元素是O(n)操作,在数据量变大时会拖慢速度,所以还是推荐deque。

from typing import List from collections import deque class Solution: def letterCombinations(self, digits: str) -> List[str]: if not digits: return [] phone = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"] q = deque([""]) for digit in digits: letters = phone[int(digit)] if not letters: continue for _ in range(len(q)): cur = q.popleft() for ch in letters: q.append(cur + ch) return list(q)

这段代码里有一个很关键的细节:for _ in range(len(q))必须在弹出元素之前把队列当前长度记录下来。因为循环体内会不断往队列追加新字符串,如果直接写for cur in q或者用变化的len作为范围,会导致新入队的字符串也在同一轮被处理,结果就是层与层混淆,组合会提前多展开一层。这个坑我在第一次写时踩过,调试了很久才发现问题出在循环范围上。

5.3 两种解法的取舍:什么时候用回溯,什么时候用队列

从功能上看,两种解法的输出完全一致,但各有侧重。回溯法的代码结构更通用,几乎是所有组合枚举类问题的标准模板,改一改就能解决子集、排列、组合总和等经典题。迭代BFS的思路更贴近自然语言的"一层一层拼上去",对新手来说更好理解,但它依赖于队列存储所有中间状态,空间占用比回溯法略高。我在测试中输入"23456789"时,deque里同时存了大量中间字符串,内存占用明显高于递归版,虽然对这道题的小数据量来说不会造成问题,但值得注意。

还有一个更实际的区别:递归有深度限制。如果输入数字串很长(比如几百位),递归版可能抛出递归深度异常,而迭代版完全不受影响。不过,这种规模下组合数量早已爆炸,即便迭代法不会因深度而崩溃,结果列表也存不下,所以这点在实际应用中意义不大。面试时我通常首选回溯法,因为面试官更希望看到你能驾驭递归和状态树;如果你能主动在最后补充"这里的递归深度不会超过输入长度,所以栈空间可控,也可以用队列迭代实现同等的效果",会显得考虑更周全。

5.4 时间复杂度的再审视:为什么两种解法一样快

很多初学者以为BFS比DFS快或者更慢,但在枚举全部组合的问题里,两者的时间复杂度是同一级别的。因为它们都必须遍历状态树的所有叶子节点,唯一区别是访问顺序不同。深度优先用递归栈存放路径,广度优先用队列存放路径;访问节点数量相同,生成字符串的数量也相同,所以复杂度完全一致。这个结论可以推而广之:在"必须枚举全部结果"的问题里,DFS和BFS通常没有效率上的本质差别,差别主要在空间占用和代码可读性上。

6. 从这题到一类题:面试变体、模板迁移与进阶思考

6.1 变体一:只问组合数量,不问具体组合

面试官为了考察你是否真懂了这题,经常会加一个追问:不生成组合,只计算有多少种组合,能不能更快?这其实非常简单——只需把每个数字对应的字母数相乘。输入"23"时,数字2有3个字母,数字3有3个字母,组合数就是9。如果输入里出现"79",7有4个字母,9有4个字母,答案就是16。这个解法的时间复杂度降到了O(L),因为你根本不需要展开每个组合。

这个变体的意义不是炫技,而是让你意识到:有些问题看起来要求"枚举",但只要调整一下目标(只计数不输出),复杂度就可能从指数级降到线性级。面试时能快速识别这种变化,会给你加分不少。

def count_combinations(digits: str) -> int: phone = ["", "", 3, 3, 3, 3, 3, 4, 3, 4] total = 1 for ch in digits: total *= phone[int(ch)] return total

这里把phone数组的值直接存成字母数量,代码只用一个循环乘出来即可,非常干脆。

6.2 变体二:输出顺序的"玄机"

题目说输出顺序任意,但实际评测中,不同语言的遍历顺序不同,可能导致结果列表顺序不同。如果你的初始化列表里数字顺序固定,每个数字的字母按升序排列,那么DFS的遍历顺序天然就是字典序。比如"23"输出为"ad, ae, af, bd, be, bf, cd, ce, cf",这正好是字典序。

如果你想让结果按"第一个数字优先级最高,其次第二个数字"的顺序来,那就是DFS的自然输出顺序。反过来,如果你想按某种自定义顺序(比如从数字键较小者开始、字母顺序逆序),只需要调整letters的遍历顺序就行。这也说明了回溯法的一个灵活之处:输出顺序完全取决于你递归时遍历子节点的顺序,跟算法本身无关。

6.3 变体三:从字母组合到电话号码词典检索

这个变体在真实场景中更常用:给你一本单词词典,以及一串输入的数字,要求找出所有由该数字串编码的单词。这就是老式手机T9输入法的实际逻辑。解决办法可以把每个字母映射回它所属的数字,对词典中每个单词做一次映射,若映射结果等于输入数字串,就命中。

这个变体相比原题不再枚举所有组合,而是反过来从词典里过滤,复杂度由词典大小和单词长度决定。面试中如果你能把这个变体和原题串起来讲,会展示出对问题的深刻理解:一个是"编码到解码"的枚举,一个是"解码到编码"的检索。两个方向恰好互补。我在模拟面试中就遇到过这样的追问:"如果现在给你一个包含常见英文单词的列表,以及数字串4663,怎么快速找出对应单词?"能用上这个变体的思路,当场就能给出方案。

6.4 从这题到子集、组合、排列:一套回溯模板走天下

第17题的价值不仅在于它本身,更在于它背后的回溯模板几乎可以无缝迁移到其他高频题。子集问题求的是所有子集,组合问题求固定长度的组合,排列问题求所有顺序,它们都有一个统一框架:确定递归的每一层能选择哪些元素,做选择,递归,撤销选择。区别只在于每层的选择范围和终止条件。

以子集问题为例,它的终止条件是"收集当前所有路径节点",而不仅仅收集叶子;组合问题则是"路径长度到达目标值k时收集"。排列问题要求每个元素只能用一次,需要一个visited数组来标记哪些已经用过。这些变体都比"电话号码的字母组合"复杂一点,但如果你能用这道题把基础模板刻进脑子里,后面那些题的代码就是在模板上改条件而已。

6.5 一个容易被忽视的优化点:如何避免重复组合

输入数字串里如果出现重复数字(比如"22"),由于每个数字对应的字母集合是固定的,不同的分支之间不存在互相重复的情况——第一个2选'a',第二个2选'a'得到"aa",第一个2选'a',第二个2选'b'得到"ab",没有任何两条路径会得到完全相同的组合。所以这道题天然不需要去重。

但如果题目扩展为"每个数字不能重复使用"或者引入其他约束,重复组合就会成为问题。这时你通常需要排序加同层去重,或者用一个集合来记录已经生成过的组合。知道了这个对比,你在面试中就能主动说明"原始题目不需要去重,但如果有扩展约束,我会用某某方法处理",展示出举一反三的能力。

6.6 实战复盘:我在面试现场如何取舍这题的多种写法

在真实的面试场景中,我通常会先画出状态树,确认输入为空时返回值,然后优先给出回溯法。代码控制在20行左右,写完立刻口头过一遍测试用例,重点解释path.pop()为什么必须放在递归之后。如果面试官追问"还有没有其他解法",我再补充队列迭代法,并顺带提一句"两种解法的复杂度一致,只是遍历顺序不同"。这样的答题节奏,能让我从容地展示出从建模到实现、从边界处理到复杂度分析的完整思路。

如果时间充裕,我还会主动提出变体一(只计数)和变体三(词典检索),因为这两个话题能引导面试官看到你不仅仅会背模板,而是理解了枚举问题的本质,以及递归和检索两种不同路径的异同。

7. 最后再分享一点个人体会:这题刷三遍会有三种收获

第一遍刷这题时,我照着题解把代码抄了下来,AC之后感觉也就那样。第二遍重做时,我关掉所有答案,从状态树开始画,重新推导递归的三要素,才真正理解了"撤销选择"为什么重要。第三遍再碰到它时,是在一次几个算法题串讲里,我把回溯模板从这道题迁移到了子集和排列题中,突然意识到这套框架几乎覆盖了面试中一半以上的枚举类问题。

如果让我给你一个具体的刷题建议,我会说:不要满足于提交通过。用一道题把状态树画熟,把边界用例跑通,把两种解法都写一遍,再把复杂度算明白,这比快速刷十道但每道都只写一个解法更有价值。毕竟面试官真正想看到的,是你能否把一个复杂问题拆解成清晰的递归结构,而不是你记住了多少份标准答案。这道"电话号码的字母组合"看起来简单,却是打磨这种能力的最好起点之一。

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

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

立即咨询