
你有没有过这种体验一道题在力扣上标着“中等难度”评论区清一色写着“回溯模板题”但你光是把“回溯”两个字理解透就花掉了半下午。我在刷力扣热题100的时候第17题“电话号码的字母组合”就是这么一道绕不开的坎。说它难吧代码量连三十行都不到说它简单吧递归、回溯、状态恢复、字符串拼接这些概念全挤在同一个题目里新手一上来容易被绕晕。这道题的核心任务很明确给定一个仅包含数字2到9的字符串返回所有它能表示的字母组合。每个数字对应一组字母就像九宫格输入法时代按一个键可能打出的那三四个字母组合起来就是结果。它看起来是个“穷举题”但它的价值在于它是理解回溯算法的绝佳入口也是hot100里后续一大堆组合类问题的模板源头。这篇文章我打算按我自己的复盘思路来写先拆解题目的真实意图再解释为什么回溯是这题的正解然后给出完整的可运行代码和复杂度分析最后把我刷这题时踩过的坑和面试里可能被追问的点一起整理出来。适合正在刷力扣hot100、准备算法面试或者刚接触递归回溯想找一个温和入门的读者。不管你是第一次见这道题还是刷了两遍还想把细节抠清楚这篇都能给你点实在的东西。1. 题目到底在问什么——先把它翻译成人话1.1 原题信息与输入输出拆解力扣17题的原文我不用多背核心信息就三行输入一个字符串比如“23”字符串里每个字符都是2到9之间的数字数字和字母的映射关系固定2对应abc3对应def以此类推输出是所有可能的字母组合顺序不限。举两个具体例子。输入“23”输出就是[ad,ae,af,bd,be,bf,cd,ce,cf]一共9个结果。输入“2”输出就是[a,b,c]一共3个结果。输入空字符串输出是空列表而不是包含一个空字符串的列表这个边界细节经常被人忽略。题目要求里有两个容易被忽略的点。第一数字1和0不参与映射这题直接不考虑它们你不需要对它们做任何特殊处理就当它们不存在。第二结果顺序不限这给实现留了很大余地你不需要刻意排序只要把所有组合都生成出来就行。还有一个容易想歪的地方这里的“组合”和数学里的组合不完全一样。它更像是“笛卡尔积”——第一个数字选一个字母第二个数字选一个字母把所有可能性串起来。数字之间是有位置关系的第一位选了a第二位可以选d、e、f中的任意一个这跟从一堆字母里挑几个出来不考虑顺序完全是两码事。1.2 为什么说这是一道“组合生成”问题理解这道题的分类很重要因为它决定了你能不能把这道题的经验迁移到其他题目上。电话号码字母组合本质上是“多阶段选择”的组合生成问题每一阶段对应一个数字每个阶段有几个可选字母你要把所有阶段的选择串联成一条完整路径。我习惯用一个生活化的类比来理解想象你在搭配一套穿搭上衣有3件裤子有2件鞋子有2双那么全套搭配就有3乘以2乘以2等于12种。电话号码字母组合就是这个逻辑只是每件“单品”都是字母而且数量可能不一样有的数字给3个字母有的给4个。再往深一层说这类问题有一个共同特征你需要枚举所有可能的路径而这些路径共同构成一棵“决策树”。从第一个数字开始每走一步就做一次选择走到最后一个数字时这条路径上的所有选择连起来就是一个答案。明白了这一点你就会理解为什么这类题最终都会倒向同一个解法家族——回溯。2. 思路选型为什么第一反应该是回溯2.1 暴力枚举为什么不现实我第一次看到这道题时脑子里冒出来的第一个想法是用嵌套循环不就行了输入两个数字就写两层循环输入三个数字就写三层循环。这种思路在输入长度固定时完全可行但这题的输入长度是变化的你不可能为每一种长度手动写一套循环代码。你可能说可以用递归模拟多层循环啊。没错递归确实能解决“层数不固定”的问题但如果你只把递归当成“动态生成循环”的工具很容易写出一种带了循环痕迹但没抓住回溯精髓的代码。真正的回溯比“递归套循环”多了一个关键动作那就是状态恢复。再想一个更实际的问题如果输入是“23456789”这样8个数字暴力嵌套循环在代码层面就彻底失效了因为你没法在写代码时知道要嵌套几层。哪怕你硬写代码的可读性也会降到冰点面试官大概率会直接让你换个思路。2.2 回溯的本质走不通就回头回到那个穿搭搭配的类比。你要枚举所有穿搭方案一个自然的思路是先固定上衣再固定裤子然后轮着试鞋子试完所有鞋子后回到裤子这一层换一条裤子再重新试一遍所有鞋子。这个过程里当你“回到裤子这一层”的时候实际上就是把“当前鞋子选择”这个状态清空这就叫状态恢复。在代码层面回溯算法通常长这样一个递归函数负责处理某一阶段的选择进入递归前先做出一个选择递归返回后撤销这个选择再尝试下一个可选值。这个“先选再撤”的模式就是回溯区别于普通递归的核心特征。用这道题来说递归函数的语义可以定义成“从当前层开始把后面所有层能产生的组合都拼出来”。每进入一层取当前数字对应的字母串挨个尝试每个字母。选了一个字母后就把它拼到当前结果里然后进入下一层。下一层处理完回来把这个字母从当前结果里去掉再试下一个字母。这个设计里最精妙的地方在于所有层共用同一个“当前结果”变量。进递归前加一个字符出递归后减一个字符整个搜索过程结束后这个变量会回到最初的空状态。既省内存又不会让不同分支之间互相污染。2.3 剪枝问题这个场景需要剪枝吗很多回溯题都会附带剪枝优化——在递归过程中提前判断某些分支不可能产生合法答案直接跳过节省时间。这道题需要剪枝吗我的答案是不需要而且也不存在标准意义上的剪枝场景。为什么不需要因为这道题没有任何约束条件。每个数字位都必须选一个字母不存在“选了a就不能选b”的规则也不存在“选到某个字母后和已有结果冲突”的情况。每个分支都是合法的每条路径走到头都是一个有效答案。换句话说这棵决策树的叶子节点全都是答案没有一条分支是“死路”。你可能会问输入里有1或者0怎么办题目已经保证输入只含2到9所以根本走不到那一步。如果你在代码里加了针对1和0的判断属于防御性编程面试官不会扣分但也别指望靠这个展示水平。真正值得你花时间的是把回溯框架本身写到无懈可击。3. 核心实现代码逐行拆解与完整解法3.1 数据结构与前置映射表写代码之前先把数字和字母的映射关系落定。我见过不少写法有人用HashMap有人用switch有人用二维数组。我比较推荐用一个字符串数组理由后面细说。数组下标就是数字字符对应的整数值数组元素就是该数字对应的字母串。String[] mapping { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz };注意数组前两个位置留空因为0和1不参与映射。这样写的好处是下标2正好对应2下标9对应wxyz无需做任何换算代码读起来也直观。如果你愿意也可以把数组长度设为10这是最常见也最稳妥的做法。调用的时候需要注意字符转下标这个细节。遍历输入字符串拿到的每个字符是char类型比如2不能直接拿去做数组下标需要先转成int。推荐写digits.charAt(i) - 0这样2就变成了2正好命中mapping[2]。这一步虽然不起眼但新手很容易在这里写错导致数组越界。3.2 递归函数的参数设计与终止条件递归函数的设计是整个解法的灵魂。我用的函数签名是这个void backtrack(String digits, int index, StringBuilder current, ListString result)四个参数各司其职。digits是输入字符串全程不变相当于全局只读数据。index表示当前处理到第几位数字是递归推进的指针。current是当前已经拼出的前缀比如处理到第二位时current可能已经是a了。result是结果列表所有完整组合都往里加。这里有个参数设计上的关键点index是值传递每一层递归都有自己的index副本不需要手动恢复。而current是引用传递所有递归层共用同一个StringBuilder对象所以必须在递归返回后手动删掉刚加进去的字符。这个“一个不用恢复一个必须恢复”的差异是理解回溯状态管理的关键。终止条件也很简单index digits.length()说明每一位数字都已经处理完了current里存的必然是一个长度等于digits长度的完整组合。把它加入result然后return。注意这里一定要new一个新的字符串加进去直接加current会在后续递归中因为内容变化而出错。3.3 完整Java实现与关键写法细节下面是完整的可运行代码我按力扣要求的Solution类格式来写。代码里我加了一些注释方便你对照上面的解释看。class Solution { public ListString letterCombinations(String digits) { ListString result new ArrayList(); if (digits null || digits.length() 0) { return result; } String[] mapping { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz }; backtrack(digits, 0, new StringBuilder(), result, mapping); return result; } private void backtrack(String digits, int index, StringBuilder current, ListString result, String[] mapping) { if (index digits.length()) { result.add(current.toString()); return; } int digit digits.charAt(index) - 0; String letters mapping[digit]; for (int i 0; i letters.length(); i) { current.append(letters.charAt(i)); backtrack(digits, index 1, current, result, mapping); current.deleteCharAt(current.length() - 1); } } }几个关键的写法细节第一开头就处理digits为空的情况直接返回空列表避免进入递归后出现边界问题。第二递归函数里先取当前数字对应的字母串再遍历这个字母串中的每个字母。第三递归调用后立刻删除刚追加的字符保持current的状态在每一轮循环开始前都是一致的。有些同学可能会把mapping定义成全局变量或者类成员变量这完全没问题。我选择把它作为参数传递主要是为了保持函数纯粹性方便做单元测试。实际面试中你写成类字段也能过面试官不会在意这种风格差异。3.4 复杂度分析时间与空间到底是多少这道题的复杂度分析是一个很好的面试考点。假设输入字符串长度为n每个数字最多对应4个字母7和9对应4个其余对应3个那么总组合数最多是4的n次方。时间复杂度方面你一共要生成4的n次方个结果每个结果的长度为n所以总的时间开销是n乘以4的n次方。这是无论你怎么优化都躲不开的下界因为你光是把所有结果输出出来就需要这么多时间。空间复杂度要分两部分看。递归调用栈的深度是n这部分是O(n)。current这个StringBuilder对象全程只有一个大小最多n也是O(n)。结果列表result占用的空间不算在算法空间复杂度里因为那是题目要求的输出。所以核心递归过程的空间复杂度是O(n)。这个结论面试时经常被问到你要能讲清楚为什么不是O(4的n次方)——因为回溯帮你复用了同一份存储空间而不是每个分支都开一份新空间。4. 刷题过程中最容易踩的坑与排查技巧4.1 边界条件digits为空时返回什么这个坑我见过很多人踩。输入是空字符串有同学直接返回一个包含空字符串的列表[]有同学返回null还有同学干脆不做处理让递归去跑。题目要求是返回空列表[]既不是包含空串的列表也不是null。为什么必须是空列表这题的含义是“没有任何数字产生不了任何组合”所以结果集合为空集。如果你返回[]在业务语义上等于说“存在一种什么都不选的组合”这跟题目预期不一致。更具体的验证方式是力扣的测试用例里有一条边界用例就是空输入返回值必须是[]你可以在代码开头显式处理。我在工程实践里还发现一个细节除了判断长度是否为空最好也判断digits是否为null。力扣的测试用例不会给你传null但真实项目里谁也说不好。加上一行判空代码成本极低却能避免空指针异常这种低级错误。4.2 String拼接与StringBuilder性能差多少很多同学写这道题时第一版用的是String直接拼接current letters.charAt(i)。这个写法在n很小的时候完全没问题代码也更短。但如果你去面试面试官很可能追问一句为什么不用StringBuilder这里涉及一个语言层面的基础知识点Java里的String是不可变对象每次用加号拼接底层会创建一个新的StringBuilder执行append操作再toString生成新字符串。如果每个分支都这么干字符串对象的总量会比结果数量多好几倍GC压力直线上升。StringBuilder的优势在于它可变你可以原地追加、原地删除全程只维护一个字符序列。这道题用StringBuilder还有个额外的好处删除最后一个字符的操作特别自然deleteCharAt(current.length() - 1)一行搞定。换成String就麻烦了还得执行substring又会产生新的字符串对象。不过说实话在力扣这种在线评测环境里用String直接拼接也能通过测试数据量根本到不了性能瓶颈。但我还是建议你用StringBuilder不是为了性能是为了养成好习惯为后面做更复杂的回溯题打基础。4.3 映射表用数组还是HashMap谁更合适这个问题是我在一场模拟面试里被问到的。当时我用的是HashMap面试官问能不能换成数组为什么。我卡了几秒但事后想想这个知识点其实不难。数组方案的优势是访问时间复杂度O(1)而且数组下标天然就是数字省去了HashMap的哈希计算开销。更直观的是代码体积一个数组声明直接搞定比手动put十几次简洁得多。数组方案的潜在风险是有越界问题但本题数字范围固定是2到9数组长度设为10就永远不会越界。HashMap的优势是语义更清晰键值对一目了然而且天然容错——你查询不存在的键会返回null不至于抛异常。但在这个场景下这个优点没什么用因为我们根本不需要查询不存在的键。综合来看数组是更优选。顺便提一个细节如果你的代码里要用map.getOrDefault(...)这类函数来兜底那说明你的设计已经有问题了。这题的映射关系是确定的、范围是已知的根本不需要兜底。过度防御不会加分反而显得你对边界情况没把握。4.4 面试官的经典追问结果顺序有要求吗力扣原题里明确写了顺序不限但面试官经常把这个条件拿掉问你会不会有不同写法。其实代码完全不用变因为回溯自带的遍历顺序天然就是确定的按数字顺序、按每个数字的字母顺序一路枚举下去输出结果自然是有序的。这时候面试官可能还想考察你一个点如果要求输出结果按照字典序排序你的代码需要加什么答案是不需要加当前回溯顺序已经满足字典序。你可以用输入“23”验算一下输出的结果正是从ad到cf严格按字母顺序排列的。如果真的遇到一个要求输出某种特定顺序的变体题大多数情况下你只需要调整字母串内部的顺序或者调整遍历顺序即可回溯框架本身不用动。这也是这类题的常见变体方向理解了这一点你遇到变体题就不会慌。5. 同类题扩展与hot100刷题策略参考5.1 这道题和括号生成、组合总和的联系hot100里有一批题代码框架跟电话号码字母组合几乎是一个模子刻出来的。第22题括号生成第39题组合总和第46题全排列第78题子集本质上都是“选择若干次每次有若干候选把所有合法路径收集起来”。括号生成的差异在于它多了一个剪枝条件任何时候右括号数量不能超过左括号数量。组合总和的差异在于它允许重复选择同一个数而且需要保证结果不重复。全排列的差异在于每次选过的元素不能再选需要额外用一个visited数组记录状态。这些都是在回溯框架上加约束条件基础模板不变。我个人的建议是把17题吃透以后趁着回溯框架还热乎赶紧按顺序刷括号生成和全排列。这两道题一个能帮你理解剪枝一个能帮你理解状态去重补上这两个技能点后hot100里剩下的回溯题基本就拦不住你了。5.2 hot100刷题节奏这题应该放在什么位置如果你是第一次刷hot100我不建议把17题放在太前面。它虽然代码简单但涉及递归和回溯如果递归基础不牢容易产生“好像看懂了但自己写不出来”的挫败感。我比较推荐先刷几道链表和数组题找找手感再刷两道二叉树题练递归然后回来碰这题手感会顺很多。如果你已经有了刷题经验只是想快速过一遍hot100那这题可以当作回溯专题的第一道热身题。我的建议是别急着看答案先自己写一版哪怕写得丑也没关系。写完之后再对照优秀题解看差距重点关注终止条件写在哪、状态恢复怎么做的、边界情况有没有覆盖。5.3 一题多解除了回溯还能怎么做面试中如果有人问你“不用回溯怎么写”你可以提两个方向。一是层序遍历法或者叫BFS。先维护一个结果队列初始放置空字符串每读入一个数字就把队列里的每个字符串弹出拼接上当前数字的每个字母重新入队。循环结束后队列里的元素就是所有组合。这个方法能过但空间复杂度比回溯高因为它每一步都会产生大量中间字符串。二是用迭代法配合List的遍历。维护一个结果列表初始只有空字符串。遍历每个数字对当前结果列表里的每个字符串逐一追加当前数字的字母生成新的字符串放入一个临时列表处理完一个数字后替换掉原列表。这个方法的本质和BFS一样只是换了个容器表达。我为什么还是推荐回溯为主因为它才是这类问题的通用解。BFS和迭代法虽然能解这道题但它们的思路很难迁移到括号生成、全排列这些更复杂的问题上。刷题不能只满足于通过更关键的是为后续的题做积累。写在最后力扣hot100里比这题难的题不少但这道题绝对是最值得反复写几遍的基础题之一。我自己的体会是回溯算法真正难的地方不是“递归调用自己”这个动作而是搞清楚什么时候该记录状态、什么时候该撤销状态、哪些状态是自动恢复的。电话号码字母组合恰好把这几个问题浓缩在一个最小场景里弄懂了它后面一堆中等难度的组合类问题都不再是障碍。最后再分享一个小技巧刷完这道题后试着把mapping里的字母顺序改一下比如把abc改成cba观察输出结果的变化。这个动作能帮你直观理解遍历顺序如何决定输出顺序也会让你对回溯的执行流程产生肌肉记忆。等你把这个细节也掌握了这道题才算真正吃透了。