资讯动态

LeetCode第17题精讲:回溯算法与递归思维实战

发布时间:2026/10/17 5:21:14 来源:尧图企业网站定制
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]就是abcphone[7]就是pqrs0和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遍历其中的每个字符执行三步操作把当前字符加入path选择。递归调用dfs(digits, idx 1, path, res)深入下一层。把刚才加入的字符从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。如果输入里出现797有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之后感觉也就那样。第二遍重做时我关掉所有答案从状态树开始画重新推导递归的三要素才真正理解了撤销选择为什么重要。第三遍再碰到它时是在一次几个算法题串讲里我把回溯模板从这道题迁移到了子集和排列题中突然意识到这套框架几乎覆盖了面试中一半以上的枚举类问题。如果让我给你一个具体的刷题建议我会说不要满足于提交通过。用一道题把状态树画熟把边界用例跑通把两种解法都写一遍再把复杂度算明白这比快速刷十道但每道都只写一个解法更有价值。毕竟面试官真正想看到的是你能否把一个复杂问题拆解成清晰的递归结构而不是你记住了多少份标准答案。这道电话号码的字母组合看起来简单却是打磨这种能力的最好起点之一。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑