☰
九键字母组合:DFS与回溯的经典入门题全解析
2026/10/14 17:54:23 网站建设 项目流程

最近帮一个正在准备算法面试的朋友梳理经典的DFS入门题,翻来翻去还是绕不开这道九键字母组合。作为深度优先遍历里最常用来“开场”的题目,它的代码量不大,但把递归、回溯、状态恢复这几个核心概念全占了,非常适合用来讲清楚DFS到底在干什么。我索性把这几年反复写这道题、还有从它延伸出去的变种题目经验,一并整理出来,希望对正在刷题的人有帮助。

1. 先搞清楚这道题到底在考什么

1.1 题目本身的输入输出

九键字母组合,输入是一个只包含数字的字符串,比如"23",输出要返回按顺序按下这些数字键时,所有可能的字母组合。九宫格键盘的映射是这样规定的:2对应abc,3对应def,4对应ghi,5对应jkl,6对应mno,7对应pqrs,8对应tuv,9对应wxyz。注意7和9有4个字母,其他按键是3个字母。

输入"23"时,最终结果应该是:

["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]

第一个数字2取一个字母,第二个数字3取一个字母,两个拼在一起,就得到一个组合。数字串有多长,组合出来的字符串长度就是多少。

这道题的输出是“组合”而不是“排列”,也就是说,字母的位置是固定的:第1位只能来自第1个按键,第2位只能来自第2个按键。你不需要去把整个字符串重新洗牌,也不需要考虑“ad”和“da”是不是两种。这一点决定了它的递归树长什么样,也决定了写代码时索引参数的用法。

1.2 为什么它天然是一棵递归树

做这类题前,我习惯先在脑子里把解空间画出来。按"23"为例,第一位字母有3个分支,每个分支下第二位又有3个分支,所以总共有3 x 3 = 9个结果。如果把整个过程画出来,就是一棵深度为2的树:

  • 根节点是空字符串
  • 第一层有3个节点:a、b、c
  • 第二层每个节点再分出3个子节点,最终叶子节点数就是结果数

DFS的含义就是:从根出发,沿着一条分支一直往下走,走到叶子就记录一个结果,然后回头走另一条分支。具体到这个题目,就是先固定第一个字母为a,然后分别拼上d、e、f;再回头把第一个字母改成b,继续分别拼上d、e、f。

这样一棵树,天然适合用递归来处理。因为每一层做的事完全一样:从当前数字对应的字母表里挑一个字母,拼到已有路径末尾,然后去处理下一位数字。不同层之间唯一的区别是“当前处理到第几位”,所以递归参数里只需要一个index就能区分层级。

提示:理解“每一层做同样的事、只靠参数区分状态”,是看懂一切DFS递归代码的关键。看不懂代码时,不要盯着每行死磕,先试着把树的层级画出来,代码就是树的翻译。

2. 手把手写一个干净的DFS实现

2.1 数字到字母的映射表怎么写

映射关系通常有两种写法:哈希表或者数组。这道题我用数组,因为每个数字对应一个下标,直接映射到字符串,简单直接:

"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"

数组前两个位置留空,对应数字0和1。因为在老式九键键盘上,0和1不参与字母映射,下标占位后,mapping[digits[index] - '0']就能拿到当前数字对应的字母串。不建议用switch或者一堆if/else去处理,写起来啰嗦,还容易漏分支。

这里有个小细节经常被忽略:字符'0'和整数0的ASCII码差了48。digits[index]是字符类型,要拿到整数下标,必须digits[index] - '0'。我第一次写这道题时直接mapping[digits[index]],结果有时候取到空串,有时候越界,后来才意识到这是字符转整数的经典坑。

2.2 递归函数的三要素参数设计

DFS递归函数设计,我通常按三件事来定参数:

  • 当前处理到哪一层:int index
  • 已经拼出的部分结果:string path或List<Character> path
  • 最终结果的收集容器:result

这三要素缺一不可。index决定递归何时终止,path记录当前分支走到哪,result是全局的收集器。终止条件写起来很自然:index == digits.length(),说明所有数字都处理完了,把path存进result。

有人喜欢把index换成已处理字符串prefix,比如递归参数传"a"、"ab"这样。好处是少一个索引参数,坏处是每次递归都要创建一个新字符串,内存开销大。刷题阶段我建议用索引加回溯,这样能更好地理解DFS的状态恢复机制。

还有一个决定代码风格的问题:path用字符串拼还是用列表加回溯?两种都对,Python里字符串是不可变对象,每次拼接都会生成新对象,代码会简洁一些,但递归深度大时开销明显。用列表的话,最后join一次,性能更好,也逼着自己写出“先加入、递归、再撤销”的标准回溯三步。

2.3 三种语言的完整实现对照

不同语言写DFS,风格差异集中在两点:字符串或列表的传递方式、递归函数怎么访问外部变量。

C++实现,我习惯把mapping和result写成成员变量,递归函数少传两个参数:

class Solution { public: vector<string> letterCombinations(string digits) { if (digits.empty()) return {}; string path; backtrack(digits, 0, path); return result; } private: vector<string> mapping = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; vector<string> result; void backtrack(const string& digits, int index, string& path) { if (index == digits.size()) { result.push_back(path); return; } string letters = mapping[digits[index] - '0']; for (char c : letters) { path.push_back(c); backtrack(digits, index + 1, path); path.pop_back(); } } };

Python实现,核心逻辑一样,path用列表:

def letter_combinations(digits: str) -> list[str]: if not digits: return [] mapping = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"] result = [] path = [] def dfs(index: int) -> None: if index == len(digits): result.append("".join(path)) return for ch in mapping[int(digits[index])]: path.append(ch) dfs(index + 1) path.pop() dfs(0) return result

JavaScript实现,闭包写法最顺手:

const letterCombinations = (digits) => { if (!digits) return []; const mapping = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"]; const result = []; const path = []; const dfs = (index) => { if (index === digits.length) { result.push(path.join("")); return; } for (const ch of mapping[Number(digits[index])]) { path.push(ch); dfs(index + 1); path.pop(); } }; dfs(0); return result; };

三个版本里,最核心的动作都是同一个三部曲:选一个字母放进路径,递归处理下一位,撤销刚才的选择。把path.pop_back()或者path.pop()去掉,整个程序就会崩溃式地产生重复结果,这一点在后面的避坑部分我会展开说。

注意:C++里backtrack的path是引用传参,递归返回后一定要pop_back()恢复现场。如果不用引用,每次递归都拷贝一份path,虽然逻辑上也能对,但性能差一个数量级。

3. 复杂度、迭代改法与几个实用优化

3.1 时间空间复杂度到底怎么算

先给结论:时间复杂度和结果数量成正比,空间复杂度等于递归栈深度加上结果存储。

设输入数字串长度为n,每个数字最多对应4个字母,那么最多有4^n个结果。生成一个结果字符串需要 O(n) 的拼接拷贝时间,所以最坏时间复杂度是 O(n * 4^n)。很多讲法只写 O(4^n),那是忽略了字符串拼接这一步。虽然常数因子不影响量级,但面试被追问时,能答出这个细节会加分。

空间复杂度分两部分:递归栈深度最大是 n,这是 O(n);存储所有结果的列表是 O(4^n)。所以整体空间复杂度 O(4^n)。如果结果不计入复杂度,那主要就是递归栈 O(n)。

这里还有个可以提的观察点:如果输入是"9999",每个按键4个字母,结果是4^4 = 256个;如果输入是"23",结果是3^2 = 9个。复杂度被字符集大小直接放大,4的指数增长非常快。所以这道题没有真正意义上的“优化到多项式时间”的办法,因为输出本身就那么大,你不可能比输出结果更快。凡是说“优化到O(n)”的,都是在耍流氓,除非不要求返回所有组合。

3.2 用队列改写:从DFS到层序迭代

DFS写熟了之后,可以尝试用队列做一层一层的扩展,这是这道题另一种经典解法。思路是:维护一个队列,初始放一个空字符串,然后每读入一个数字,就把队列里所有字符串取出来,分别拼接这个数字对应的每个字母,再放回队列。

def letter_combinations_bfs(digits: str) -> list[str]: if not digits: return [] mapping = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"] queue = [""] for digit in digits: letters = mapping[int(digit)] queue = [prefix + ch for prefix in queue for ch in letters] return queue

这段代码每一轮循环处理一个数字,处理完一轮,队列里的字符串长度就加1。处理完所有数字,队列里的每条字符串长度等于数字串长度,也就是最终答案。这个过程等价于原始树的一层层遍历,本质上就是BFS。

我个人的看法是,这道题DFS更直观,因为“每个位置选一个字母”本身就是纵向深入的思路。BFS版本适合用来直观地感受回溯和最短路之间的关系,但前提是题目允许“一层层生成”。如果在实际工程里输入数字串很长、需求只需要前几个结果时,BFS配合队列截断反而更灵活,比如只取前100条就停止,DFS就得额外加计数器。

3.3 缓存和预分配这类小优化

九键字母组合本身没有太多优化空间,但有几个小优化值得养成习惯:

  • C++里在递归前先result.reserve(...),预先分配好大致的容量,能减少字符串数组扩容带来的重复拷贝。容量的上下界是3^n到4^n,可以直接按最大量预留。
  • Python里path用列表而不是字符串拼接,最后只join一次,性能会好很多。字符串是不可变对象,每次path + ch都生成新字符串,递归深了会产生大量临时对象。
  • 如果同一个数字串要被查询多次,可以给mapping结果加一层缓存,用字典记录已经算过的输入。比如:
cache = {} def letter_combinations_with_cache(digits): if digits not in cache: cache[digits] = letter_combinations(digits) return cache[digits]

这个优化在刷题时通常用不上,但如果你在做输入法联想、批量电话号码转文字这类业务,相同的按键序列会被大量重复查询,缓存收益非常明显。

4. 从九键出发:DFS的家族变种题

4.1 变种题一:组合总和与全排列

九键字母组合解决的是“每个位置从有限的集合里选一个元素,排列出所有结果”的问题。它几乎是回溯算法里最简单的一类:没有约束条件,没有重复元素,只要填满每个位置就收集结果。

一旦加上“不能重复选取”或者“元素可以重复但结果里不能有重复子集”的约束,就变成了组合总和、全排列一类的题。比如全排列是在一个数字集合里选出所有排列,和九键的区别在于:九键每个位置的候选集是固定且互不干扰的,全排列需要用一个visited数组标记哪些元素已经用过。代码结构上,全排列的DFS多了一个“排除已用元素”的步骤,撤销的时候除了pop_back()还要把visited状态重置。

我见过很多人在九键这些基础题上练得不熟,直接冲组合总和,结果既分不清startIndex和index的区别,也不知道什么时候用i+1去避免重复。我的建议是,先把九键这类“无选择的约束”写熟练,再去加条件,加一层约束就多一个参数,思路不会乱。

4.2 变种题二:括号生成与恢复IP地址

括号生成是DFS的经典进阶题,它要求每层递归判断当前左括号和右括号的数量关系,不再是“无脑选所有候选字母”。但骨架和九键一模一样:先判断递归终点,再遍历候选,选择后递归,递归结束撤销。

恢复IP地址这类题目更有意思:输入一个纯数字字符串,要求分解成4段,每段是0到255之间的数字,输出所有合法IP。这题每一层要“选的长度”是可变的,可能是1位、2位、3位,所以在DFS内部多了一个for循环来枚举当前段截取的长度。本质上还是九键那棵树,只是每个节点的分支数不固定,而且每个分支要额外判断合法性。

我把这些题和九键放在一起对比,是因为它们共用同一个解题框架:DFS函数里先判断终止条件,然后枚举当前层所有可能的选择,选择后递归调用,调用回来撤销选择。框架本身不会变,变的只是候选集合的大小、是否有限制条件、路径记录的格式。

题目候选集来源是否可重复选额外约束
九键字母组合数字对应的固定字母表每层独立无
全排列输入数组的全部元素不可重复选需要 visited 标记
组合总和输入数组的部分元素可以重复选需要用 startIndex 防止顺序重复
括号生成左括号或右括号数量有限右括号数量不能超过左括号
恢复IP地址每段截取1到3位数字每段独立段值必须合法

4.3 一个业务化扩展:带上词典过滤

九键字母组合在真实场景里最常见的一个扩展,是输入法联想和电话按键转文本。早期手机上输入英文单词,连续按数字键,系统要给出所有可能的字母组合,再用上屏词典过滤,最终留下真正是单词的候选。这个场景中,全量枚举后过滤就行,但如果数字串很长,全量枚举的开销会承受不住,所以更优的做法是在DFS的每一层就判断当前前缀是否存在于词典前缀树中,如果不在就直接剪枝返回。

这个优化思路也叫“前缀剪枝”。前面我说过九键没有可剪枝的约束条件,但加上业务条件后情况就完全不同了。曾有一个项目里需要做关键词的模糊匹配,输入一串数字,把它转换成可能的字母组合,再和已有词库比对。一开始全量枚举再过滤,数字长度超过8就跑不动了,后来改成DFS加前缀树剪枝,组合数量在早期就被砍掉一大半,后来数字长度到15也能秒出结果。

这个例子说明一件事:算法题里的“DFS”只是一个基础引擎,真正决定性能的,是你能不能根据业务约束,在合适的位置让递归停下来。

5. 我踩过的坑与排查技巧实录

5.1 忘了回溯这一步,结果直接翻倍

最初写C++版时,我把递归调用的path.pop_back()漏了,结果并非报错,而是输出了一堆诡异的重复结果。比如输入"23",我得到的东西长度五花八门,有的是两个字母,有的是四个,路径越攒越长,递归完全失控。

原因是没撤销时,前面分支选过的字母全部留在了path里,进入新分支时字符串会越来越长。回溯的作用就是把当前层加进去的字母撤掉,让path回到进入递归前的状态。调试这类问题,最直接的办法就是把递归函数里每次进入和返回时的path打印出来,一眼就能看出哪一步没有恢复原状。

记住这个底层逻辑:path在递归里的角色是“当前路径”,它只属于当前这层递归。你修改了它,必须在返回前还原,否则上层就看到一个被改过的路径。

5.2 索引从0开始还是从1开始

处理第几个数字时,很多人会被index的起点带偏。我的写法是:入口从dfs(0)开始,递归里终止条件是index == len(digits),所以一旦index等于长度就认定所有数字都处理完。

如果入口写成dfs(1),终止条件就得是index > len(digits),而且取数字时要digits[index - 1],容易出错。我建议统一用“0开头、等于长度终止”的约定,这是C++和Python等语言遍历容器最自然的方式。

还有一处细节:mapping[digits[index] - '0'],很多人第一次写错成mapping[digits[index]],因为digits[index]是字符,不是整数。Python里因为可以隐式转换字符串到整数,反而不容易踩这个坑,但 C++ 里字符的ASCII码会导致数组下表越界。刷题时如果用C++,遇到莫名越界,先检查有没有做- '0'。

5.3 调试递归树的好习惯

递归不好调,是因为它跳来跳去,不像循环那样线性推进。我的调试习惯是给DFS函数加一个depth参数,然后在进入和退出时打印缩进:

def dfs(index: int, depth: int) -> None: print(" " * depth + f"enter index={index}, path={path}") if index == len(digits): result.append("".join(path)) print(" " * depth + "collect result, return") return for ch in mapping[int(digits[index])]: path.append(ch) dfs(index + 1, depth + 1) path.pop() print(" " * depth + f"backtrack to index={index}, path={path}")

跑一次"23",你会看到类似这样的输出:

enter index=0, path=[] enter index=1, path=['a'] enter index=2, path=['a', 'd'] collect result, return enter index=2, path=['a', 'e'] collect result, return ... backtrack to index=1, path=['a'] enter index=1, path=['b'] ...

这样能直观看到DFS深挖到底、然后一层层弹回来的全过程。我在线下带人时经常用这个方式,比讲十遍递归理论都管用。

5.4 遇到的一些边界输入

平时刷题,九键这道题最容易出问题的输入是空字符串""。标准预期是返回空列表,而不是返回一个包含空字符串的列表。三种语言实现的入口都有if not digits或if (digits.empty())的判断,先把这个边界处理掉,后面递归才不会错乱。

另一个边界是长度为1的输入,比如"2",结果应该是["a", "b", "c"]。这时递归只走一层,恰好能验证index == len(digits)的终止条件有没有写对。如果输出多了一个空字符串之类的内容,说明入口的地方空字符串处理有误。

还有"23*"这类输入,题目一般说只含2到9的数字,所以不用额外考虑*和#。但在业务里如果真的要处理特殊按键,映射表里就要加对应的逻辑,比如*可以映射成"+"或者空字符,这属于业务扩展,不是题目考的点了。

最后再分享一点我个人的体会:九键字母组合这道题,很多教程里都把它归为“简单题”,但它的价值一点都不低。我面试别人或者带新人时,经常用这道题来判断对方是不是真正理解了回溯:代码背下来很容易,但能讲清楚为什么要撤销、为什么复杂度是 O(n * 4^n)、为什么结果顺序是这样,往往就不是一眼能看透的事了。建议拿到这道题时,别急着写答案,先动手把递归树画出来,再思考每个叶子节点的路径是怎么一步步攒出来的。画通这一棵树,后面所有DFS变种题的代码框架,基本都不会再怵了。

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

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

立即咨询