一、题目描述
给定一个仅包含数字 2~9 的字符串,返回这些数字在电话按键上能够表示的所有字母组合,答案顺序不限。
数字和字母的对应关系如下:
2 -> abc 3 -> def
4 -> ghi 5 -> jkl
6 -> mno 7 -> pqrs
8 -> tuv 9 -> wxyz
例如输入:
digits = "23"
数字 2 可以选择 a、b、c,数字 3 可以选择 d、e、f,因此结果为:
["ad","ae","af","bd","be","bf","cd","ce","cf"]
这道题的关键不是按照某种规律计算字符串,而是把每个数字对应的字母全部组合出来。只要题目要求"列出所有可能",通常就可以考虑 DFS 与回溯。
二、核心思路:把组合过程看成决策树
假设输入为 "23",整个选择过程可以看成一棵决策树。
-
第 0 层处理数字
2,可以选择a、b、c; -
第 1 层处理数字
3,可以选择d、e、f; -
当处理完全部数字时,就得到一个完整组合。
例如先选择 a,再选择 d,得到 ad;回到上一层后撤销 d,再尝试 e 和 f,就能依次得到 ae、af。
因此,每一层递归只需要完成三件事:
-
找到当前数字对应的字母集合;
-
依次选择其中一个字母,并递归处理下一个数字;
-
递归返回后撤销刚才的选择,继续尝试其他字母。
这就是回溯的经典过程:
做出选择 -> 进入下一层 -> 撤销选择
DFS 负责沿着一条路径不断向下搜索,回溯则负责在一条路径搜索完成后恢复现场,使程序能够继续探索其他分支。
三、递归参数如何设计
定义递归方法:
dfs(digits, index, path)
三个参数的含义分别是:
-
digits:原始数字字符串; -
index:当前正在处理第几个数字; -
path:当前已经选择的字母组合。
例如输入 "23":
index = 0:处理数字 2
index = 1:处理数字 3
index = 2:所有数字处理完成
所以递归终止条件为:
if (index == digits.length()) {
result.add(path.toString());
return;
}
当 index 等于数字字符串的长度时,说明每个数字都已经选择了一个字母,此时 path 就是一个完整答案。
四、数字与字母的映射
可以使用字符串数组保存电话按键映射:
private final String[] mapping = {
"", "", "abc", "def", "ghi",
"jkl", "mno", "pqrs", "tuv", "wxyz"
};
数组下标直接对应数字。当前字符为 digits.charAt(index),减去字符 '0' 后即可得到真正的数字下标:
String letters = mapping[digits.charAt(index) - '0'];
例如字符 '3' - '0' 的结果是整数 3,因此可以得到 mapping[3],也就是 "def"。
五、完整 Java 代码
import java.util.ArrayList;
import java.util.List;
class Solution {
private final List<String> result = new ArrayList<>();
private final String[] mapping = {
"", "", "abc", "def", "ghi",
"jkl", "mno", "pqrs", "tuv", "wxyz"
};
public List<String> letterCombinations(String digits) {
if (digits == null || digits.length() == 0) {
return result;
}
dfs(digits, 0, new StringBuilder());
return result;
}
private void dfs(String digits, int index, StringBuilder path) {
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));
dfs(digits, index + 1, path);
path.deleteCharAt(path.length() - 1);
}
}
}
六、以 "23" 为例推演
程序首先从 index = 0 开始,当前数字是 2,对应字母 abc。
第一次循环选择 a:
path = "a"
随后递归到 index = 1,处理数字 3 对应的 def。依次选择 d、e、f,得到:
ad、ae、af
生成 ad 后,程序执行:
path.deleteCharAt(path.length() - 1);
将 d 删除,使 path 恢复为 "a",然后才能继续选择 e。数字 3 的所有字母处理完后,递归返回第一层,再删除 a,接着尝试 b 和 c,最终得到全部九种组合。
整个过程可以概括为:
选择 a -> 选择 d -> 保存 ad -> 删除 d
-> 选择 e -> 保存 ae -> 删除 e
-> 选择 f -> 保存 af -> 删除 f
删除 a,继续选择 b、c
七、为什么必须回溯
StringBuilder 是可变对象,所有递归层操作的是同一个 path。如果递归结束后不删除最后加入的字符,旧路径就会影响后续分支。
例如得到 ad 后,如果不删除 d,下一次追加 e 就会变成 ade,而不是正确的 ae。
因此下面两行必须成对出现:
path.append(letters.charAt(i));
path.deleteCharAt(path.length() - 1);
前者表示做出选择,后者表示撤销选择。回溯不是重新创建一套状态,而是在递归返回时把共享状态恢复到进入当前分支之前的样子。
另外,将答案加入结果集时需要调用 path.toString(),保存当前组合对应的字符串,而不是保存可继续变化的构造器对象。
八、复杂度分析
数字 7 和 9 各对应 4 个字母,其他数字对应 3 个字母。若输入长度为 n,组合数量最多为 4^n。
-
时间复杂度:最坏为
O(n × 4^n)。一共最多生成4^n个组合,每个组合转换为字符串需要O(n); -
空间复杂度:不计算返回结果时为
O(n),主要来自递归栈和StringBuilder;如果计算结果集,则需要O(n × 4^n)。
九、常见错误
1. 空字符串返回包含空串的集合
当输入为 "" 时,题目要求返回空列表,因此应在启动 DFS 前单独判断,不能让递归直接把空字符串加入结果集。
2. 递归时没有让 index + 1
每层递归处理一个数字。若仍传入原来的 index,程序会反复处理同一数字,无法正确终止。
3. 忘记删除最后一个字符
这是最常见的错误。每次递归返回后,都要撤销当前层刚刚追加的字符,保证同层不同分支互不影响。
4. 使用组合长度代替索引却混淆含义
本题中组合长度通常等于当前层数,但直接使用 index 表达"正在处理哪个数字"更清晰,也更不容易写错。
十、总结
这道题是 DFS 回溯的典型入门题。每个数字对应决策树的一层,每个字母对应当前层的一种选择:先把字母加入路径,再递归处理下一个数字,递归返回后删除刚加入的字母。当 index == digits.length() 时,说明已经走到叶子节点,可以保存一个完整组合。