LeetCode 17. 电话号码的字母组合:回溯算法入门实战

0 阅读7分钟

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),只不过在搜索过程中会「回溯」到上一步,继续尝试其他可能性。

对应这道题,我们可以这样理解:

  1. 先取第一个数字对应的所有字母,逐个选择一个字母作为组合的第一个字符;

  2. 再取第二个数字对应的所有字母,逐个拼接在第一个字符后面,作为组合的第二个字符;

  3. 以此类推,直到拼接的字符长度等于输入数字的长度(说明已经处理完所有数字),就把这个组合加入结果集;

  4. 当一个数字的所有字母都尝试完,就回溯到上一个数字,换一个字母继续拼接,直到所有可能性都被尝试完。

举个通俗的例子:输入 "23",流程就是:a→d(加入结果)→a→e(加入结果)→a→f(加入结果)→回溯到a,a的所有字母尝试完,回溯到2,换b→b→d(加入结果)→b→e(加入结果)…… 以此类推,直到所有组合都被生成。

三、完整代码实现(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)开始,初始的拼接字符串为空,启动递归流程,开始生成所有组合。

五、易错点提醒 & 优化方向

易错点

  1. 忘记处理输入为空的情况,导致递归报错;

  2. 回溯函数的终止条件写错(比如写成index > digitsLen),导致组合缺失或重复;

  3. 没有加非空判断(if (!letters) return),虽然题目输入合法,但代码不够健壮;

  4. 混淆index的含义,导致处理数字时出现偏差。

优化方向

这道题的解法已经很高效了,但可以做一些小优化,提升可读性和性能:

  1. 用数组代替Map存储映射关系,比如const map = ['', '', 'abc', 'def', ...],通过索引直接获取(数字字符转数字即可,比如digits[index] - 0),查询速度更快;

  2. 用数组拼接代替字符串拼接(因为字符串不可变,频繁拼接会生成新字符串,数组push+join效率更高),比如用currentArr存储当前组合,递归时push(letter),回溯时pop(),最后join成字符串加入结果集。

优化后的回溯函数(数组拼接版):

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, []);

六、总结:回溯算法的核心要点

通过这道题,我们可以总结出回溯算法解决组合问题的通用步骤:

  1. 确定「终止条件」:当满足某个条件(比如处理完所有元素),将当前结果加入结果集,返回;

  2. 确定「递归逻辑」:遍历当前可选的所有元素,逐个选择,递归处理下一个元素;

  3. 「回溯操作」:要么利用不可变类型(如字符串)天然回溯,要么手动回溯(如数组pop()),回到上一步继续尝试。

这道题作为回溯入门题,难度适中,理解透彻后,再去做LeetCode上其他回溯题目(比如组合总和、子集),会轻松很多。