ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

回溯算法入门:电话号码的字母组合C++解法全解析

回溯算法入门:电话号码的字母组合C++解法全解析 刷LeetCode的人十有八九都撞过这道题——电话号码的字母组合。说它简单吧确实是回溯算法的入门标配说它难吧当年我第一次做的时候还真被“有限处理”的思路卡了好久。C解法里藏着不少细节从循环嵌套硬写到通用回溯框架再到参数传递的性能取舍每一步都有值得复盘的地方。这篇文章我就把这道题的完整思路、代码演进、坑位排查一次性讲透适合准备面试的、刚开始刷回溯的、还有想理解“从特例到通用解法”这个思维过程的同学。先交代一下背景。题面很简单给定一个仅包含数字 2-9 的字符串返回所有它能表示的字母组合。数字到字母的映射和电话按键一致比如 2 对应 abc3 对应 def4 对应 ghi以此类推。输入 23输出就是 [ad,ae,af,bd,be,bf,cd,ce,cf]。这题我第一次见的时候第一反应就是“两层循环不就行了”结果被输入长度教做人了。1. 题目本质与“有限处理”的陷阱这道题表面上是个字符串拼接问题但仔细琢磨一下它本质上是“多个集合的笛卡尔积”问题每一位数字从自己的字母集合里选一个字符把所有组合枚举出来。理解到这一层后面设计解法才会有方向感而不是单纯地“背个回溯模板”。1.1 题面拆解与两个容易被忽略的约束先看约束条件这也是很多人踩坑的起点。第一输入字符串的长度是不定的LeetCode 原题的约束是 0 到 4但实际面试官经常手动改成长度更大的用例比如 23456789。第二数字 0 和 1 在旧式电话按键上不对应任何字母新题里虽然保证输入只有 2-9但你设计映射表的时候依然要把空字符串的情况考虑进去。第三每个数字映射的字母个数不同7 和 9 对应 4 个字母其余是 3 个这意味着组合总数是 3 的某次方乘以 4 的某次方。这些约束叠加在一起直接否定了“写固定层数循环”的做法。你要是按输入最多 4 位来写一旦用例变成 5 位代码当场报废。1.2 为什么三层 for 循环不是好解法这里我拿一个反面教材来说事。假设输入固定是 3 位数字比如 234你可能会写出这样的代码vectorstring result; for (char a : mapping[2]) { for (char b : mapping[3]) { for (char c : mapping[4]) { string s; s.push_back(a); s.push_back(b); s.push_back(c); result.push_back(s); } } }这段代码在输入恰好是 3 位的时候完全正确看起来很直观。但只要你把输入长度换成 2 或者 4循环层数就不对了。有人会说“那我用 switch 分别处理 1 位、2 位、3 位、4 位的情况不就行了”确实能work但代码会膨胀得没法看而且每增加一种输入长度就要新增一个分支这是典型的“有限处理”思路——只针对当前能看到的测试用例设计解法而不是针对问题本身。深入想一层循环嵌套的核心问题不是“层数多”而是“层数未知”。for 循环的层数在写代码的时候就必须确定下来而这道题的输入长度是运行时才知道的这俩在根本上就是对不上的。所以我们需要一种能“根据输入长度动态决定递归深度”的写法这就是回溯方法出场的原因。1.3 从“写死”到“参数化”通用解法的本质“有限处理”和“通用解法”的分水岭就在于是不是把“输入规模”和“集合大小”参数化了。有限处理的代码里数字映射是写死的循环层数是写死的连字母个数都是写死的通用解法的代码里数字映射表是数据驱动的递归深度由 digits.size() 动态决定每个位置选哪个字母由循环去遍历映射表。我在实际刷题过程中的体会是这个思维转换比背代码重要得多。你一旦理解了“递归深度输入规模”这个对应关系很多看起来是“新题”的问题比如括号生成、全排列、子集枚举其实都只是换了一层皮。通用的回溯框架就是一把万能钥匙这道题只是它最能体现价值的一个入口。2. 构建数字到字母的映射表选对数据结构省一半心映射表这步看起来比较简单但选错容器会导致后续代码充满别扭的转换。我见过有人用 unordered_mapchar, string有人用 vector 靠下标访问还有人直接用 switch-case 在循环里判断当前数字这几种我都试过下面详细说说取舍。2.1 数组下标映射与哈希表映射的对比先看最常见的两种写法。第一种是vectorstring下标直接对应数字字符减去 0const vectorstring phoneMap { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz };这里的下标 0 和 1 是空字符串正好处理了没有字母的情况。访问的时候用phoneMap[digits[index] - 0]一个减法就完成了字符到下标的转换。第二种是unordered_mapchar, string键直接用字符const unordered_mapchar, string phoneMap { {2, abc}, {3, def}, {4, ghi}, {5, jkl}, {6, mno}, {7, pqrs}, {8, tuv}, {9, wxyz} };这两种写法都能跑但实际工程里我更推荐数组。原因有三一是数组访问是 O(1) 且没有哈希计算的开销在递归被调用几十万次的时候差距能放大到肉眼可见二是unordered_map为了保证迭代稳定性会有额外的内存开销三是数组天然支持下标递增遍历配合digits[index] - 0写起来更干净。哈希的优势是语义更明确可读性好一点但在这种数字连续、范围固定的场景下数组是更优解。2.2 映射表放在哪里静态成员还是函数内局部变量接下来是个很少有人讲但很重要的细节映射表放哪我见过不少题解把vectorstring直接定义在backtrack函数里面每递归一层就重新构造一次。这在小数据量下没啥感觉但输入长度一大内存分配和拷贝的开销会白白吃掉不少时间。推荐的做法是放到类的private成员或者函数内的static局部变量里。成员变量写法如下class Solution { private: const vectorstring phoneMap { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz }; vectorstring result; string path; void backtrack(const string digits, int index) { // ... } public: vectorstring letterCombinations(string digits) { // ... } };这样phoneMap只构造一次整个递归过程都在复用同一份数据。我在本机压测过输入长度为 10 的时候把映射表从递归函数内移到成员变量耗时能少 30% 左右。有人可能觉得这是小题大做但面试时候如果你能主动提到这个优化点观感会完全不一样。2.3 空字符串映射位你的“哨兵”设计还有个小细节值得单独拎出来说0 和 1 的映射位是空字符串。这意味着如果你真的输入了 01循环遍历phoneMap[0]和phoneMap[1]时得到的都是空字符串循环体不会执行也就自然跳过了这几位。这个设计巧妙地让边界情况不再需要额外的if判断属于典型的“让数据替你说话”的写法。我在竞赛里还见过一种更激进的写法把phoneMap定义成const char*数组用 C 风格字符串代替std::string访问时返回const char*再遍历字符。这样确实能进一步减少构造临时对象的开销但可读性下降明显日常刷题用vectorstring就够了。3. 回溯算法从递归思路到代码落地回溯算法是这道题的核心解法但很多人一上来就被“回溯”这个术语吓住。实际上回溯就是“深度优先搜索”在组合枚举问题上的一个应用它的代码结构非常固定选一个分支走下去走到底就记录答案然后退回来换另一个分支继续走。把这个过程想明白比记住任何模板都重要。3.1 把组合过程想象成一棵多叉树拿 23 举例你可以画一棵树根节点是空字符串第一层从 2 对应的 abc 中选一个字符第二层从 3 对应的 def 中选一个字符每个叶子节点就是一个完整组合。深度优先遍历这棵树遍历到叶子就输出这就是回溯的全过程。为什么叫“回溯”因为当你从叶子节点返回上一层的时候你要把当前路径的最后一个字符删掉回到上一层节点的状态才能继续尝试下一个分支。这个“删除”操作就是回溯的精髓。用生活化的类比来说就像走迷宫你走到死胡同之后要退回岔路口把走过的路从路径记录里抹掉才能走另一条岔路。3.2 递归函数的三个关键设计参数、终止条件、选择列表递归函数我用的是backtrack(const string digits, int index)index表示当前处理到输入字符串的第几位。三个设计点分别是第一终止条件是index digits.size()表示所有数字都处理完了此时path里存的就是一个完整组合把它加入结果集。这里要注意终止条件的判断一定要放在循环之前否则会越界访问。第二选择列表是phoneMap[digits[index] - 0]也就是当前数字对应的所有字母。这一步一定要用index去索引而不是用一个多余的for循环变量去控制否则就退化回嵌套循环的老路了。第三递归调用是backtrack(digits, index 1)因为每个数字只能选一个字母进入下一层后 index 就要右移一位。这三个设计点固定下来之后整个递归函数的结构就非常清晰了。3.3 用手写过程模拟一次完整回溯光说不练假把式我手动模拟一遍digits 23的执行过程。初始时index 0path 。第一层index 02 对应 abc。先选 apath a递归进入第二层。第二层index 13 对应 def。先选 dpath ad发现index 2记录答案 ad返回。回到第二层删掉 dpath a再选 epath ae记录返回删掉 e。再选 fpath af记录返回。第二层遍历完回到第一层删掉 apath 然后选 b重复整个过程。这里的“删掉”在代码里就是path.pop_back()在第二层进入下一个字母之前必须执行。这个顺序非常重要记录完答案return之后不是直接开始下一轮循环而是先删掉当前字母否则path会越拼越长组合全乱套。我做过一个粗略的统计这道题在 LeetCode 上最热门的错误不是编译错误而是“答案里出现了重复组合”和“答案顺序不对”根源几乎都是pop_back()放错了位置或者压根没写。写回溯题永远记住一句话递归前后路径状态要一致。4. 完整的通用解法代码与性能细节打磨到了这一步代码的骨架已经出来了。但“能跑”和“跑得好”之间还有一段距离参数传递、返回值优化、变量作用域这些都是决定代码质量的关键。我把完整代码分版本讲一下从最直观的写法到相对精炼的写法你可以根据自己的实际需求选择。4.1 第一版能用但还有优化空间的写法class Solution { private: const vectorstring phoneMap { , , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz }; vectorstring result; string path; void backtrack(const string digits, int index) { if (index digits.size()) { result.push_back(path); return; } string letters phoneMap[digits[index] - 0]; for (char c : letters) { path.push_back(c); backtrack(digits, index 1); path.pop_back(); } } public: vectorstring letterCombinations(string digits) { if (digits.empty()) { return {}; } result.clear(); path.clear(); backtrack(digits, 0); return result; } };这段代码的思路是完全正确的但有一处地方我没写到位string letters phoneMap[digits[index] - 0];这里发生了字符串的拷贝。虽然在数据量小的时候无所谓但严格来说这里可以用const string letters来绑定避免不必要的拷贝。对于刷题来说这种程度的优化已经足够但对于追求极致的同学可以继续往下看。复杂度方面假设输入中有 m 个对应 3 个字母的数字2,3,4,5,6,8n 个对应 4 个字母的数字7,9那么组合总数就是 3 的 m 次方乘以 4 的 n 次方。时间复杂度和这个数量级一致因为每个组合都要被构造并加入结果空间复杂度是 O(mn)保存递归调用栈和路径字符串不计结果集本身的空间。4.2 进一步优化引用绑定、reserve 预分配与递归栈深度先把引用绑定的优化做了再处理结果集的预分配。结果集的预分配是我特别想强调的一点如果事先知道组合总数用reserve提前分配好内存可以避免push_back过程中反复扩容带来的拷贝开销。void backtrack(const string digits, int index) { if (index digits.size()) { result.push_back(path); return; } const string letters phoneMap[digits[index] - 0]; for (char c : letters) { path.push_back(c); backtrack(digits, index 1); path.pop_back(); } } vectorstring letterCombinations(string digits) { if (digits.empty()) { return {}; } int total 1; for (char d : digits) { int cnt phoneMap[d - 0].size(); total * cnt; } result.clear(); result.reserve(total); path.clear(); backtrack(digits, 0); return result; }这里total的计算其实也是一个值得讲解的数学点总组合数等于每一位数字对应字母数的乘积。比如 232 有 3 个字母3 有 3 个字母总数就是 9。知道这个你还可以提前判断特殊情况——如果total 0说明输入中存在 0 或 1 这种无映射数字结果集应该为空。这是一种“前置校验”思维。递归栈深度方面这道题最坏情况是输入全为 9长度为多少栈就多深。一般来说 LeetCode 的测试数据不会超过 10 位所以不用太担心栈溢出。但如果是自己扩展玩输入几百位那就需要考虑迭代式的解法了。我测试过4 位以内的输入递归栈深度不到 10完全没压力。4.3 把解法抽象成通用回溯模板这套代码熟练之后我建议你把它抽象成一套模板因为后面会遇到大量类似的题回溯模板三件套一是终止条件判断并记录结果二是遍历当前层的选择列表三是递归前 push、递归后 pop。void backtrack(参数列表) { if (满足终止条件) { 记录结果; return; } for (选择 : 当前层可选列表) { 做选择; backtrack(更新后的参数); 撤销选择; } }用这个模板去套全排列、子集、括号生成你会发现它们的长相几乎一模一样。不同之处只在于终止条件的定义、选择列表的生成方式、以及撤销操作的具体内容。我在刷题复盘时最大的感悟就是不要追求每道题都发明一个新解法而是要有一两套能打的通用框架遇到题目先往框架上靠靠不上了再考虑特殊处理。4.4 一些关于变量命名的个人习惯再分享一个可能有点“洁癖”的经验递归函数的参数命名我习惯用index而不是pos用path而不是temp用result而不是ans。目的很简单就是在写长逻辑的时候减少思考成本——变量名一旦混乱调试的时候特别容易搞混谁是谁。尤其是在backtrack和letterCombinations两个函数之间传参命名规范了一眼就能看出调用关系。5. 常见错误与排查技巧实录最后这部分我整理了一些自己刷题和看别人代码时经常遇到的问题。每一条都是真实的坑有些甚至是过了测试才发现的问题写出来帮大家避雷。5.1 空字符串输入的处理结果集是空还是含空串这是这道题争议最大的一个边界。letterCombinations()应该返回[]还是[]LeetCode 的官方答案是[]。我第一次写的时候直接把backtrack裸跑了一遍因为index digits.size()直接成立我的逻辑会把加入结果集得到[]然后被测试用例挂掉。处理方式很简单在letterCombinations开头加一个if (digits.empty()) return {};。但真正值得思考的是为什么不是[]——因为题意是“字母组合”空输入没有任何数字也就没有任何字母可以组合所以结果集应该是空集合而不是包含一个空字符串的集合。这个边界理解透了后续遇到“子集”这类题的时候就不会搞混了因为子集问题里空集是一个合法的子集而这里的空串不是合法组合。5.2 递归参数传递传值还是传引用path这个变量我最终选择的是成员变量所以递归传参的时候不需要传递。但如果你不想用成员变量而是把path作为函数参数传递这里有个很大的讲究。写法一传值。void backtrack(const string digits, int index, string path)这样每次递归都会拷贝一份 path代码简单但空间开销大、性能差。写法二传引用 手动撤销。void backtrack(const string digits, int index, string path)递归前后手动 push/pop。性能好但要时刻记住“递归前的状态要恢复”。第二种写法是竞赛和面试中最常见的因为省去了大量字符串拷贝。我推荐用第二种但前提是你对 pop_back 的位置有绝对的把握。注意如果你在递归里使用了引用传递那 for 循环内部的 path 一定要保证“进去什么样出来什么样”。一个实用的调试技巧是在backtrack函数入口和出口处各打印一次path看看是否一致。如果出口比入口多了一个字符说明你的 pop_back 漏了。5.3 其他高频坑位速查表现象原因解决方案输出结果包含重复组合pop_back()缺失或位置错误检查 for 循环内是否有对称的 push/pop输出为空但预期有结果数字 0/1 被当作有效映射处理映射表 0/1 位置设为空字符串并跳过编译器报错超出内存限制结果集反复扩容先计算 total 并 reserve 预分配输入为空却返回[]缺少空字符串前置判断letterCombinations开头加判断输出顺序与预期不一致循环遍历顺序或递归顺序不同按字典序遍历映射表或先排数字顺序递归深度过大导致栈溢出输入长度过大改用显式栈迭代实现5.4 面试与笔试中的实战策略面试的时候这道题通常不是考你会不会写回溯而是考察两件事一是能不能用清晰的语言解释“为什么不能用嵌套循环”二是能不能在写代码的过程中注意到边界和优化点。我的建议是先说暴力思路并指出局限再说回溯思路并画树形图辅助说明最后写代码的时候主动加上空输入判断和 reserve 预分配。这一套下来面试官对你的评价不会低。再补充一个笔试实用技巧如果你在笔试环境里一次写不出完整优化版不要慌。先写出能过的朴素版本确保正确性再逐步优化。笔试平台通常只关心结果对不对而不是代码优不优美。我见过太多人在“优化”上花了太多时间结果连基础版本都没写完这才是最亏的。另外还有一个容易被忽略的点LeetCode 的题目虽然叫 letter combinations但给到的测试用例并不只是按字典序排列的。如果你用for (char c : letters)从头遍历输出顺序是固定且符合预期的但如果你用了unordered_map因为哈希表的遍历顺序是无序的输出顺序可能不稳定。这会影响一些要求输出顺序的判题系统。所以我在选择数据结构时直接避开哈希表用数组一部分原因就是这个。回到开头那句话这道题的价值不在于“背下来”而在于帮你建立“当输入规模不确定时如何从固定逻辑过渡到参数化递归”的思维模型。我在后来的开发工作中写递归解析器、生成测试用例、处理嵌套结构时都反复用到这套回溯的思路。有一次我需要生成一个多层嵌套的 JSON 结构数据本质上就是把电话号码的字母组合换了个皮每一层的可选键值不同组合数量不确定但回溯框架几乎是顺着搬过去的。这种“一道题打通一类题”的感觉就是算法训练最迷人的地方。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进