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

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)

下面是完整的解题代码,注释已经写得非常详细,大家可以先通读一遍,后续逐句拆解:

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成字符串加入结果集。

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

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

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

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

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

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

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

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

相关推荐
风骏时光牛马15 分钟前
AI编程常见问题梳理与要点解析
前端
前端snow18 分钟前
ai agent -- LCEL 汇总
前端
玖玥拾21 分钟前
LeetCode 392 判断子序列
笔记·算法·leetcode
计算机魔术师41 分钟前
NVIDIA 季度营收指引达 1080 亿美元,首次突破单季千亿大关
前端
数据狐(Datafox)1 小时前
京东商品列表API技术解析与落地应用(含标准 JSON 示例)
java·大数据·前端·人工智能·python·数据分析·json
风萧何1 小时前
架构艺术,是权衡的艺术。
前端·javascript·架构
YIAN1 小时前
ESLint 新版 Flat Config 实战:团队代码风格统一,从这一份配置开始
前端·团队管理
计算机魔术师1 小时前
英伟达单季营收逼近千亿美元,黄仁勋放话:真实需求远超想象
前端
明月_清风1 小时前
看完这段关于"全插件化架构"的技术分析后,我整理了一份笔记
前端·后端
YIAN1 小时前
Next.js App Router 全栈实战:从 0 到 1 写一个 Todo 应用,前端后端一个项目搞定
前端·全栈·next.js