LeetCode中等难度题目------17. 电话号码的字母组合,这道题是回溯算法的经典入门题,既能帮我们熟悉回溯的核心思想,又能巩固字符串、哈希表的基础用法,非常适合新手上手练习。
一、题目解析:读懂需求,明确边界
先看题目要求:给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合,答案可以按任意顺序返回。
核心映射关系(和手机按键一致):
-
2 → abc
-
3 → def
-
4 → ghi
-
5 → jkl
-
6 → mno
-
7 → pqrs
-
8 → tuv
-
9 → wxyz
关键边界条件:
-
输入为空字符串(digits.length === 0)时,直接返回空数组;
-
数字仅包含2-9,无需处理1(1不对应任何字母);
-
输出需包含所有可能的组合,无重复、无遗漏。
举个例子:输入 "23",输出应该是 ["ad","ae","af","bd","be","bf","cd","ce","cf"],因为2对应abc,3对应def,每个字母两两组合,就是所有可能的结果。
二、解题思路:为什么选回溯算法?
这道题的核心是「穷举所有可能的组合」,而回溯算法正是解决「穷举组合」类问题的最优思路之一。
什么是回溯?简单来说,就是「走一步、试一步,走不通就退回来换条路走」,本质是一种深度优先搜索(DFS),只不过在搜索过程中会「回溯」到上一步,继续尝试其他可能性。
对应这道题,我们可以这样理解:
-
先取第一个数字对应的所有字母,逐个选择一个字母作为组合的第一个字符;
-
再取第二个数字对应的所有字母,逐个拼接在第一个字符后面,作为组合的第二个字符;
-
以此类推,直到拼接的字符长度等于输入数字的长度(说明已经处理完所有数字),就把这个组合加入结果集;
-
当一个数字的所有字母都尝试完,就回溯到上一个数字,换一个字母继续拼接,直到所有可能性都被尝试完。
举个通俗的例子:输入 "23",流程就是:a→d(加入结果)→a→e(加入结果)→a→f(加入结果)→回溯到a,a的所有字母尝试完,回溯到2,换b→b→d(加入结果)→b→e(加入结果)...... 以此类推,直到所有组合都被生成。
三、完整代码实现(TypeScript)
下面是完整的解题代码,注释已经写得非常详细,大家可以先通读一遍,后续逐句拆解:
typescript
function letterCombinations(digits: string): string[] {
// 边界条件:输入为空,直接返回空数组
if (digits.length === 0) return [];
// 建立数字到字母的映射表,用Map存储,查询更高效
const map = new Map([
['2', 'abc'],
['3', 'def'],
['4', 'ghi'],
['5', 'jkl'],
['6', 'mno'],
['7', 'pqrs'],
['8', 'tuv'],
['9', 'wxyz']
]);
// 存储最终结果的数组
const result: string[] = [];
// 输入数字串的长度,用于判断终止条件
const digitsLen: number = digits.length;
/**
* 回溯函数:递归生成字母组合
* @param {number} index - 当前处理的数字在digits中的索引
* @param {string} currentStr - 当前已经拼接好的字母组合
* @returns
*/
const backtrack = (index: number, currentStr: string) => {
// 终止条件:当处理完所有数字(索引等于数字串长度),将当前组合加入结果集
if (index === digitsLen) {
result.push(currentStr);
return;
}
// 取出当前数字对应的字母(加非空判断,增强代码健壮性,避免异常)
const letters = map.get(digits[index]);
if (!letters) return;
// 遍历当前数字对应的每一个字母,递归拼接
letters.split('').forEach((letter: string) => {
// 字符串不可变,currentStr + letter 会生成新字符串,无需手动回溯(天然回溯)
backtrack(index + 1, currentStr + letter);
});
}
// 启动回溯:从第0个数字开始,初始拼接字符串为空
backtrack(0, '');
// 返回最终结果
return result;
};
四、代码逐句拆解:读懂每一步的意义
1. 边界条件处理
if (digits.length === 0) return [];
这一步很关键,当输入为空字符串时,没有任何组合可以生成,直接返回空数组,避免后续递归报错。
2. 建立数字-字母映射
用Map存储映射关系,相比对象(Object),Map的get方法查询效率更高,而且可以直接用数组初始化,代码更简洁。这里明确了每个数字对应的字母,和题目要求完全一致。
3. 初始化变量
-
result: string[] = []:用于存储所有生成的字母组合,最终作为返回值; -
digitsLen: number = digits.length:存储输入数字串的长度,避免在递归中多次调用digits.length,提升性能(虽然影响不大,但养成良好习惯)。
4. 核心:回溯函数(backtrack)
回溯函数是这道题的灵魂,我们重点拆解它的两个参数和内部逻辑:
参数说明
-
index: number:当前正在处理的数字在digits中的索引,比如输入"23",index=0时处理数字"2",index=1时处理数字"3"; -
currentStr: string:当前已经拼接好的字母组合,比如index=0时,currentStr可能是"a"、"b"、"c",index=1时,currentStr可能是"ad"、"ae"等。
终止条件
if (index === digitsLen) { result.push(currentStr); return; }
当index等于数字串的长度时,说明我们已经处理完了所有数字,当前的currentStr就是一个完整的组合,把它加入result,然后返回(结束当前递归,回溯到上一步)。
获取当前数字对应的字母
const letters = map.get(digits[index]); if (!letters) return;
根据当前index,取出digits中对应的数字,再通过Map获取该数字对应的字母串。加一个非空判断,防止出现异常(虽然题目说输入仅包含2-9,但健壮性代码不能少)。
遍历字母,递归拼接
letters.split('').forEach((letter: string) => { backtrack(index + 1, currentStr + letter); });
这一步是回溯的核心逻辑:
-
将字母串拆分成单个字母(比如"abc"拆成["a","b","c"]);
-
遍历每个字母,调用回溯函数,此时index+1(处理下一个数字),currentStr+letter(将当前字母拼接到已有的组合上);
-
这里有个小技巧:因为字符串是不可变的,
currentStr + letter会生成一个新的字符串,而不是修改原字符串,所以当递归结束后,会自动回到上一步的currentStr,无需手动"回溯"(比如拼接完"ad",递归结束后,会回到"a",继续拼接"ae")。
5. 启动回溯
backtrack(0, '');
从第一个数字(index=0)开始,初始的拼接字符串为空,启动递归流程,开始生成所有组合。
五、易错点提醒 & 优化方向
易错点
-
忘记处理输入为空的情况,导致递归报错;
-
回溯函数的终止条件写错(比如写成index > digitsLen),导致组合缺失或重复;
-
没有加非空判断(if (!letters) return),虽然题目输入合法,但代码不够健壮;
-
混淆index的含义,导致处理数字时出现偏差。
优化方向
这道题的解法已经很高效了,但可以做一些小优化,提升可读性和性能:
-
用数组代替Map存储映射关系,比如
const map = ['', '', 'abc', 'def', ...],通过索引直接获取(数字字符转数字即可,比如digits[index] - 0),查询速度更快; -
用数组拼接代替字符串拼接(因为字符串不可变,频繁拼接会生成新字符串,数组push+join效率更高),比如用currentArr存储当前组合,递归时push(letter),回溯时pop(),最后join成字符串加入结果集。
优化后的回溯函数(数组拼接版):
typescript
const backtrack = (index: number, currentArr: string[]) => {
if (index === digitsLen) {
result.push(currentArr.join(''));
return;
}
const letters = map.get(digits[index]);
if (!letters) return;
letters.split('').forEach(letter => {
currentArr.push(letter); // 加入当前字母
backtrack(index + 1, currentArr);
currentArr.pop(); // 回溯:移除最后一个字母
});
}
// 启动回溯
backtrack(0, []);
六、总结:回溯算法的核心要点
通过这道题,我们可以总结出回溯算法解决组合问题的通用步骤:
-
确定「终止条件」:当满足某个条件(比如处理完所有元素),将当前结果加入结果集,返回;
-
确定「递归逻辑」:遍历当前可选的所有元素,逐个选择,递归处理下一个元素;
-
「回溯操作」:要么利用不可变类型(如字符串)天然回溯,要么手动回溯(如数组pop()),回到上一步继续尝试。
这道题作为回溯入门题,难度适中,理解透彻后,再去做LeetCode上其他回溯题目(比如组合总和、子集),会轻松很多。