
周末刷力扣的时候碰到57. 电话号码的字母组合这道题。说实话这道题在力扣热题100里算是一道非常经典的“回溯”入门题但别看它难度标着Medium其实核心思想并不复杂很多刚接触DFS回溯的朋友容易被“映射 组合”这两个点绕晕。我这篇就把这道题从题目本质到代码实现再到常见的坑和优化思路完整地拆开揉碎讲一遍。1. 题目本质与核心思路拆解这道题的题干大家应该都见过给定一个仅包含数字2-9的字符串返回所有它能表示的字母组合。数字到字母的映射和电话九宫格一样比如2对应abc3对应def以此类推。很多新手看到这个题的第一反应是这有什么难的几个for循环嵌套不就完了但问题在于输入的数字长度是未知的。如果长度是3写三层循环长度是5写五层循环——显然不可能这么干。这就引出了回溯算法最核心的价值用递归的方式处理不确定层数的循环嵌套。举个例子输入23你需要在脑海中构建这样一棵树从数字2开始它有a、b、c三个分支每个分支下接数字3的d、e、f三个分支从根到叶子节点的每一条路径就是一个完整的字母组合DFS回溯在这里做的事情就是沿着一条路径走到黑深度优先走不动了处理完最后一个数字就记录结果然后回退一步尝试另一个分支。这就像你在一个迷宫里探索走到死胡同就原路返回换一个岔路口再走。这里有个非常关键的设计点递归函数的参数设计。我见过很多人在这一关卡住。通常我们需要两个参数index当前处理到第几位数字了path当前已经拼接好的字符串路径为什么需要index因为你要知道递归什么时候该停。为什么需要path因为你要记录当前这条路径上已经选了哪些字母。这两个参数是回溯算法最基础的“状态”概念。注意这道题里我们不需要像全排列那样维护一个visited数组因为数字键盘上每个数字对应的字母是互斥的同一层不可能重复选同一个位置的字母天然不存在重复访问的问题。2. 映射表的构建与细节处理题目给了数字到字母的映射关系但没直接给你数据结构。这其实是个很基础的编码问题但不同的人处理方式天差地别。2.1 数组映射是最稳妥的方案有些朋友喜欢用MapInteger, String然后一个个put这样写起来啰嗦而且容易漏。我推荐直接用字符串数组用索引做下标匹配非常干净String[] mapping { , // 0 , // 1 abc, // 2 def, // 3 ghi, // 4 jkl, // 5 mno, // 6 pqrs, // 7 tuv, // 8 wxyz // 9 };这里有个细节要注意0和1在电话键盘上不对应任何字母所以映射为空字符串。很多人在定义数组的时候会漏掉这两个位置导致下标错位。我的习惯是把数组长度设为10下标直接对应数字这样避免做digit - 0 - 2之类的偏移计算减少心智负担。2.2 字符转数字的两种方式拿到字符串后我们要取每一位数字转成int去查表。常见的写法有两种// 方式一字符减字符 int num digits.charAt(index) - 0; // 方式二String.valueOf Integer.parseInt int num Integer.parseInt(String.valueOf(digits.charAt(index)));我强烈推荐方式一因为它高效且简洁。0的ASCII码是482的ASCII码是50相减直接得到整数2一步到位。方式二做了太多没必要的类型转换在刷题时属于多余操作。2.3 空输入的特殊处理这里有一个极易踩坑的点输入空字符串时应该返回什么很多人的第一反应是返回空列表[]但题目要求明确说了如果输入为空返回空列表。但你仔细想想如果传入我们递归函数里第一行就判断index digits.length()此时会直接执行path.append()然后加入结果集最终得到一个包含空字符串的列表[]——这就错了。所以必须在主函数里提前判断if (digits null || digits.length() 0) { return new ArrayList(); }这个判断看似简单但真的很重要。我刷题的时候见过不少代码测试用例一跑空的输入直接报错或返回错误结果都是栽在这个细节上。3. 回溯算法完整实现与逐行解读接下来是重头戏完整的代码实现。我用Java来写因为力扣上Java是最主流的语言之一但思路是通用的换成Python、C完全一样。import java.util.ArrayList; import java.util.List; class Solution { // 数字到字母的映射表 private static final String[] MAPPING { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz }; public ListString letterCombinations(String digits) { ListString result new ArrayList(); // 空输入特殊处理 if (digits null || digits.length() 0) { return result; } // 开始回溯 backtrack(digits, 0, new StringBuilder(), result); return result; } private void backtrack(String digits, int index, StringBuilder path, ListString result) { // 递归终止条件处理完所有数字 if (index digits.length()) { result.add(path.toString()); return; } // 当前数字对应的字母串 String letters MAPPING[digits.charAt(index) - 0]; // 遍历当前数字的每一个字母 for (int i 0; i letters.length(); i) { // 做选择把当前字母加入路径 path.append(letters.charAt(i)); // 递归处理下一位数字 backtrack(digits, index 1, path, result); // 撤销选择回溯的关键一步 path.deleteCharAt(path.length() - 1); } } }3.1 递归终止条件的设计逻辑终止条件是index digits.length()意思是所有数字都处理完毕。此时path里存的已经是一个完整的组合直接加入结果集。这里有个面试官常问的考点为什么不用index digits.length() - 1作为终止条件因为这样最后一个数字的字母还没有被遍历到逻辑会在最后一层循环里多走一轮反而麻烦。用index digits.length()是最自然、最不容易出错的写法代码的语义和物理含义完全对应。3.2 为什么用StringBuilder而不是String拼接很多初学者喜欢这么写backtrack(digits, index 1, path letters.charAt(i), result);这样写其实也能跑通因为String的拼接本质上是在创建新字符串回溯时不需要手动撤销。但问题在于Java里字符串是不可变的每次拼接都会创建新的对象在递归层数较深或分支较多时会有额外的内存开销和GC压力。用StringBuilder的好处是同一个对象可以被反复修改。做选择时append撤销时deleteCharAt全程只维护一个路径对象内存效率高得多。不过用StringBuilder也有个坑如果你直接把path对象加入结果集后续对path的修改会影响已经加入的结果。所以必须用path.toString()生成一个新的字符串快照再放入result。我在实战中就见过同学栽在这上面结果所有结果集里的字符串全都变成一模一样的了。3.3 回溯的三步曲整个backtrack函数内部就是回溯算法最经典的三步做选择path.append(letters.charAt(i))递归进入下一层backtrack(digits, index 1, path, result)撤销选择path.deleteCharAt(path.length() - 1)第三步“撤销”是回溯和普通递归的本质区别。没有这一步path会一直累积下去最终得到的结果是ad、ade、adef这种越来越长的残留路径完全不是我们要的组合。我用生活中的场景来类比你在橱柜里挑搭配先拿了一件上衣再看裤子搭配完一套记下来然后必须把上衣放回去才能拿另一件上衣继续搭配。撤销选择就是“把上衣放回去”的动作没有它你的手里永远是上一套衣服没法尝试新的组合。4. 时间复杂度与空间复杂度分析这道题的复杂度分析也是面试中常见的考点。虽然代码简单但复杂性分析却能区分出你是不是真正理解了递归的本质。4.1 时间复杂度假设输入的数字个数为n每个数字最多对应4个字母7和9对应4个其余对应3个。那么组合的总数最坏情况是4^n。递归树中每个叶子节点对应一个完整的组合每个组合的拼接操作是O(n)。所以最坏时间复杂度 组合总数 × 每次生成组合的代价 O(4^n * n)这里的n即输入数字字符串的长度。对于力扣的测试数据而言长度通常不超过4这个复杂度完全在可接受范围内但如果你真去处理长度10以上的输入结果集会爆炸式增长内存都装不下。4.2 空间复杂度空间复杂度主要由两部分组成递归调用栈的深度O(n)结果集占用的空间O(4^n * n)所以总的空间复杂度是O(4^n * n)。严格来说递归栈本身只占O(n)但结果集才是大头。不过通常面试回答时说空间复杂度O(4^n * n)是比较严谨的。提示有些资料会把空间复杂度写成O(n)这也没有错因为它们只算了递归栈的额外空间忽略了结果集本身。讨论复杂度时先明确“是否包含输出结果”这个前提避免面试时各说各话。5. 常见问题与排查技巧这道题虽然不难但实际写代码时踩坑的点还挺多的。我总结几个我见过的、以及我自己踩过的坑供大家参考。5.1 问题一返回结果顺序不对有些朋友写完代码一跑发现结果集顺序和预期不一样比如bd排在ad前面。这里要说清楚力扣对这道题的输出顺序其实有要求必须按照字典序其实就是DFS的遍历顺序排列。如果你用的遍历顺序是“先遍历后一个数字的字母再遍历前一个数字的字母”那顺序自然就反了。解决办法很简单递归时始终从左到右处理数字循环内也按字母表顺序遍历DFS天然的遍历顺序就是答案要求的顺序。5.2 问题二StringBuilder被复用导致结果全是最后一个这是我见过最经典的错误result.add(path); // 错误应该用 path.toString()如果你直接把path加入结果集那么之后path的任何修改都会反映在result里的“所有”元素上。最终你得到的结果集会是多个指向同一个StringBuilder的引用里面全是最后一次回溯完的状态。排查方法也很简单在result.add(path.toString())前后打印path的内容你会发现加入时是正确的但最终结果却全变成了最后一个。这就是典型的“引用传递”问题。5.3 问题三递归深度和栈溢出理论上输入长度为n时递归深度就是n。力扣的测试数据长度有限不会栈溢出。但如果你把这道题的思路扩展到无限长度的输入就要考虑递归深度的问题了。实际工作中如果需要处理超长数组可以改用显式的栈来模拟回溯过程或者用迭代法BFS逐层扩展。力扣这道题用递归完全没问题但理解迭代法的思路能帮你应对更复杂的变种题。5.4 问题四回溯撤销操作遗漏很多新手写完“递归”忘记“撤销”导致路径无限累积。这里我分享一个自查技巧在递归函数里做选择和撤销选择一定要对称。每一层append最终必然对应一次deleteCharAt。你可以数一数每个分支的结尾path.length()应该回到进入该分支前的长度。还有一个小技巧如果忘记写撤销得到的path长度永远不会等于digits.length()所以在if (index digits.length())里加一个打印看看path的结果立刻就能发现问题。6. 回溯算法的模式提炼与举一反三这道题解决之后千万别急着划走。我强烈建议你把它当作“回溯算法”的模板题认真总结一下通用套路。回溯算法的代码结构几乎都是同一个模板void backtrack(参数) { if (满足终止条件) { 记录结果; return; } for (选择 : 所有可选选择) { 做选择; backtrack(更新后的参数); 撤销选择; } }掌握了这个模板你就解锁了一大批力扣的经典题目46. 全排列需要加visited数组避免重复选择78. 子集每个元素选或不选17. 电话号码的字母组合也就是本题从不同数字对应的字母集合中各选一个39. 组合总和可以重复选择同一元素131. 分割回文串需要额外判断回文这些题的区别主要在于“选择空间”的定义方式不同。全排列的选择空间是“还没被选过的所有元素”本题的选择空间是“当前数字对应的几个字母”。想明白了这一点你就不会被题目的表面形式迷惑而能直击回溯的本质。我在刷题时习惯把回溯题的代码结构先在纸上画一遍递归树明确每一层的“选择列表”是什么、终止条件是什么、需要撤销什么然后再写代码基本能做到一次通过。7. 从“AC”到“真懂”进阶优化思路如果上面的内容你已经完全掌握了我再给你多聊几句进阶的内容。这道题虽然官方解法就是DFS回溯但有些细节还能继续优化以及有一些变体思路值得了解。7.1 用队列实现BFS版本回溯DFS的思想是深度优先一路走到头再回头。但很多人可能没想到这道题也可以用BFS广度优先来做思路是逐层扩展public ListString letterCombinations(String digits) { LinkedListString queue new LinkedList(); if (digits.isEmpty()) return queue; String[] mapping {, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz}; queue.offer(); for (int i 0; i digits.length(); i) { String letters mapping[digits.charAt(i) - 0]; int size queue.size(); for (int j 0; j size; j) { String cur queue.poll(); for (char c : letters.toCharArray()) { queue.offer(cur c); } } } return queue; }BFS版本的好处是代码更短不需要显式地管理递归状态。每次从队列里取出当前层已有的字符串尝试追加下一个数字的每个字母再放回队列。等所有数字都处理完队列里存的就是全部组合。这个思路在打印层序遍历、求最短路径等场景里也很有用。不过从“刷题面试”的角度DFS回溯还是更主流、更通用因为回溯能解决更多更灵活的排列组合问题而BFS这个写法比较依赖逐层扩展的规则。7.2 剪枝思路的启发这道题本身几乎没有剪枝空间因为每个字母都是合法的组合路径。但从“回溯优化”的思想出发字符串拼接时会有大量中间状态产生。如果输入特别长可以提前用StringBuilder的容量初始化来减少扩容开销或者像之前说的用char[]数组替代StringBuilder在指定位置赋值再撤销进一步降低创建对象的成本。不过说实话这些优化在力扣的数据范围内意义不大我更建议把精力花在对“回溯模板”的理解和变体题的迁移上。7.3 为什么这道题是“进大厂必刷”的入门必备你可能也发现了力扣热题100里回溯相关的题不算少但这道题被放在很靠前的位置原因就在于它足够“纯粹”。它不涉及复杂的剪枝条件不用担心重复元素不需要维护额外的访问标记数组。它把回溯最核心的骨架——做选择、递归、撤销选择——完整地展现出来了没有任何多余的修饰。当我刚接触回溯时其实也是从这道题入门的。因为我一开始完全看不懂那些递归套递归的写法后来静下心来把这道题的递归树手动画了一遍突然就通了。所以说这道题是理解DFS回溯的“杠杆点”花一两个小时彻底搞懂它后面再刷十道回溯题都会轻松很多。我在实际给朋友讲题时最常推荐的方法就是拿张纸把23的递归树画出来然后在代码里每个函数入口和出口各打一行日志亲眼看着path怎么增长、怎么回退。这个过程走一遍之后回溯对你来说就不再是玄学了而是有章可循的思维工具。