电话号码字母组合问题源于传统手机键盘的数字-字母映射关系。在功能机时代,数字键2-9分别对应3-4个字母(如2对应ABC,3对应DEF),这种设计带来了一个经典问题:给定一串数字,如何生成所有可能的字母组合?
这个问题在实际开发中有多重价值:
以输入"23"为例,需要输出["ad","ae","af","bd","be","bf","cd","ce","cf"]这9种组合。这类问题在自动文本生成、密码破解等场景都有应用。
回溯算法特别适合解决这类需要穷举所有可能解的问题,其核心优势在于:
与暴力枚举相比,回溯通过递归调用自动维护了状态,避免了手动管理多重循环的复杂性。
标准回溯解法包含三个关键部分:
对于字母组合问题,我们可以这样建模:
首先建立数字到字母的映射关系:
java复制private static final Map<Character, String> digitMap = new HashMap<>() {{
put('2', "abc");
put('3', "def");
put('4', "ghi");
put('5', "jkl");
put('6', "mno");
put('7', "pqrs");
put('8', "tuv");
put('9', "wxyz");
}};
完整解法类结构:
java复制class Solution {
private List<String> result = new ArrayList<>();
public List<String> letterCombinations(String digits) {
if (digits == null || digits.length() == 0) {
return result;
}
backtrack(digits, 0, new StringBuilder());
return result;
}
private void backtrack(String digits, int index, StringBuilder path) {
if (index == digits.length()) {
result.add(path.toString());
return;
}
char digit = digits.charAt(index);
String letters = digitMap.get(digit);
for (char c : letters.toCharArray()) {
path.append(c); // 做出选择
backtrack(digits, index + 1, path); // 递归
path.deleteCharAt(path.length() - 1); // 撤销选择
}
}
}
最坏情况下(输入包含多个7或9):
在LeetCode测试中,该解法:
迭代解法示例:
java复制public List<String> letterCombinations(String digits) {
LinkedList<String> result = new LinkedList<>();
if (digits.isEmpty()) return result;
result.add("");
for (char digit : digits.toCharArray()) {
int size = result.size();
for (int i = 0; i < size; i++) {
String prefix = result.poll();
for (char c : digitMap.get(digit).toCharArray()) {
result.add(prefix + c);
}
}
}
return result;
}
空输入处理:
if (digits.isEmpty()) return result;StringBuilder使用:
path.deleteCharAt()数字映射:
java复制private void backtrack(String digits, int index, StringBuilder path) {
System.out.println("当前深度:" + index + " 路径:" + path);
// ...原有逻辑...
}
code复制输入"23"时的调用栈:
depth=0 path=""
depth=1 path="a"
depth=2 path="ad" → 加入结果
depth=2 path="ae" → 加入结果
...
掌握此回溯模板后,可解决:
以子集问题为例,只需调整终止条件和选择逻辑:
java复制private void backtrack(int[] nums, int start, List<Integer> path) {
result.add(new ArrayList<>(path)); // 所有节点都是解
for (int i = start; i < nums.length; i++) {
path.add(nums[i]);
backtrack(nums, i + 1, path);
path.remove(path.size() - 1);
}
}
输入验证:
内存管理:
性能监控:
java复制// 避免频繁创建StringBuilder
private void backtrack(..., StringBuilder path) {
if (...) {
result.add(path.toString()); // 只有这里创建新String
}
// ...
}
java复制// 使用数组替代Map更高效
private static final String[] digitMap = {"", "", "abc", "def", ...};
java复制// 对首数字的不同字母并行处理
digitMap.get(digits.charAt(0)).chars().parallel().forEach(c -> {
StringBuilder path = new StringBuilder().append((char)c);
backtrack(digits, 1, path);
});
python复制def letterCombinations(digits):
if not digits:
return []
digit_map = {
'2': 'abc',
'3': 'def',
# ...
}
result = []
def backtrack(index, path):
if index == len(digits):
result.append(''.join(path))
return
for c in digit_map[digits[index]]:
path.append(c)
backtrack(index + 1, path)
path.pop()
backtrack(0, [])
return result
优势:
cpp复制vector<string> letterCombinations(string digits) {
if (digits.empty()) return {};
const vector<string> digit_map = {"", "", "abc", ...};
vector<string> result;
string path;
function<void(int)> backtrack = [&](int index) {
if (index == digits.size()) {
result.push_back(path);
return;
}
for (char c : digit_map[digits[index] - '0']) {
path.push_back(c);
backtrack(index + 1);
path.pop_back();
}
};
backtrack(0);
return result;
}
注意:
java复制@Test
public void testLetterCombinations() {
Solution solution = new Solution();
// 空输入
assertTrue(solution.letterCombinations("").isEmpty());
// 单数字
assertEquals(Arrays.asList("a","b","c"), solution.letterCombinations("2"));
// 典型情况
List<String> expected = Arrays.asList("ad","ae","af","bd","be","bf","cd","ce","cf");
assertEquals(expected, solution.letterCombinations("23"));
// 包含7/9的情况
assertEquals(16, solution.letterCombinations("79").size());
// 长输入
assertEquals(3*3*4*3, solution.letterCombinations("2347").size());
}
无效输入:
特殊映射:
性能测试:
基础阶段:
中级阶段:
高级应用:
递归与调用栈:
字符串处理:
组合数学:
在实际编码中,我发现使用StringBuilder时如果忘记撤销操作会导致难以排查的bug。一个有用的调试技巧是在递归入口和出口打印当前状态,这能清晰展示算法的执行路径。对于特别长的输入,考虑使用迭代解法或设置递归深度限制会更安全