欢迎光临
我们一直在努力

力扣热题100实战 | 第17期:电话号码的字母组合——回溯算法的经典启蒙

力扣热题100实战 | 第17期:电话号码的字母组合——回溯算法的经典启蒙

    • 前言
    • 一、题目:电话按键上的字母组合
      • 关键点解读
    • 二、第一反应:多层循环嵌套(无法扩展)
      • 代码实现
      • 这段代码的问题在哪?
    • 三、核心思路:回溯算法(多叉树遍历)
      • 回溯思想
      • 回溯三要素
      • 算法步骤
    • 四、代码实现:回溯算法(面试标准答案)
      • 代码解析
      • 复杂度分析
    • 五、另一种思路:BFS队列解法(拓展思维)
      • BFS核心思想
      • BFS图解流程
      • BFS代码实现
      • DFS vs BFS
    • 六、细节剖析:面试官真正关心的问题
      • Q1:为什么用 StringBuilder 而不用 String 拼接?
      • Q2:回溯时为什么要撤销选择?不撤销会怎样?
      • Q3:index 参数的作用是什么?
      • Q4:为什么映射表用数组而不用 HashMap?
      • Q5:时间复杂度中的 3^m × 4^n 是怎么来的?
    • 七、面试官追问进阶版
      • 追问1:如果输入包含数字0和1,应该怎么处理?
      • 追问2:如何用迭代的方式(非递归)实现?
      • 追问3:如果要求输出的是字母组合的个数(而不列出具体组合),怎么算?
      • 追问4:如果数字可以重复,结果中会出现重复组合吗?
    • 八、实际开发:这道题到底有什么用?
      • 场景1:输入法联想
      • 场景2:密码破解
      • 场景3:基因序列组合
      • 场景4:电话号码记忆
      • 场景5:自动化测试
    • 九、总结:从一道题到一类题
    • 附录:思考题

当一个问题需要“多选一”地构建所有可能结果时,回溯算法就是你最锋利的武器。而这道题,正是理解回溯思想的最佳起点。

前言

你好,我是@礼拜天没时间。

前十六期我们系统学习了哈希表、链表操作、滑动窗口、二分查找、动态规划、双指针、贪心算法等多种算法思想。这一期,我们来解一道全新的算法类型——回溯算法的入门经典:电话号码的字母组合(LeetCode 第17题)。

这道题在力扣上被标记为“中等”,但它的难度其实不高,真正有价值的是它作为回溯算法启蒙题的地位。回溯算法是解决排列、组合、子集等问题的通用框架,而这道题恰好展示了回溯最核心的思想——多叉树的遍历。

很多同学第一次接触回溯时,会被“递归”“撤销选择”这些概念绕晕。但当你把问题想象成在一棵树上从根走到叶子,一切就豁然开朗了。

今天,我希望能带你从决策树的视角,一次看懂回溯算法。


一、题目:电话按键上的字母组合

先看题目描述(LeetCode 第17题):

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

2: abc
3: def
4: ghi
5: jkl
6: mno
7: pqrs
8: tuv
9: wxyz

示例 1:

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = ""
输出:[]

示例 3:

输入:digits = "2"
输出:["a","b","c"]

关键点解读

  • 映射关系:每个数字对应一组字母,2→abc,3→def,…,9→wxyz。

  • 组合长度:每个组合的长度必须等于输入数字串的长度。比如"23"生成的长度都是2。

  • 任意顺序:结果集的顺序不重要,可以按任意顺序返回。

  • 空串处理:输入为空字符串时,返回空列表。


  • 二、第一反应:多层循环嵌套(无法扩展)

    当我第一次看到这道题,我的第一反应是:用多层循环嵌套,根据输入长度决定循环层数。

    代码实现

    // 错误思路示例——只能处理固定长度
    public List<String> letterCombinations(String digits) {
    List<String> result = new ArrayList<>();
    if (digits.length() == 2) {
    String letters1 = getLetters(digits.charAt(0));
    String letters2 = getLetters(digits.charAt(1));
    for (char c1 : letters1.toCharArray()) {
    for (char c2 : letters2.toCharArray()) {
    result.add("" + c1 + c2);
    }
    }
    } else if (digits.length() == 3) {
    // 需要三层循环
    // …
    }
    return result;
    }

    这段代码的问题在哪?

  • 无法通用:输入长度是变化的,我们不可能为每种长度都写一套循环。

  • 代码冗余:长度增加时,代码量呈指数级增长。

  • 无法处理未知长度:在写代码时,我们不知道用户会输入多长的数字串。

  • 这正是回溯算法登场的地方——用递归代替多层循环,用回溯探索所有分支。


    三、核心思路:回溯算法(多叉树遍历)

    回溯思想

    回溯算法的本质是多叉树的深度优先遍历(DFS)。我们可以把问题建模成一棵树:

    • 树的根节点:空字符串
    • 树的每一层:对应输入数字串中的一个位置
    • 树的每个节点:代表当前已拼接的字母组合
    • 树的叶子节点:代表一个完整的字母组合

    以输入"23"为例,决策树如图所示:

    root("")
    / | \\
    2:a 2:b 2:c
    / | \\ / | \\ / | \\
    3:d 3:e 3:f 3:d 3:e 3:f 3:d 3:e 3:f
    / | \\ / | \\ / | \\
    ad ae af bd be bf cd ce cf

    回溯三要素

    回溯算法有三个核心要素:

  • 路径(path):已经做出的选择,即当前已拼接的字母串。
  • 选择列表(choices):当前可以做的选择,即当前数字对应的所有字母。
  • 结束条件:到达决策树的叶子节点,即路径长度等于输入数字串的长度。
  • 算法步骤

  • 建立映射表:将数字字符映射到对应的字母串。

  • 定义回溯函数:参数包括当前处理到的数字索引 index 和当前已拼接的路径 path。

  • 结束条件:如果 index == digits.length(),说明已经处理完所有数字,将 path 加入结果集。

  • 递归遍历:

    • 获取当前数字对应的字母串
    • 遍历每个字母
    • 将当前字母加入 path
    • 递归处理下一个数字(index + 1)
    • 回溯:将刚加入的字母从 path 中移除,以便尝试下一个字母

  • 四、代码实现:回溯算法(面试标准答案)

    class Solution {
    // 数字到字母的映射表
    private String[] mapping = {
    "", // 0
    "", // 1
    "abc", // 2
    "def", // 3
    "ghi", // 4
    "jkl", // 5
    "mno", // 6
    "pqrs", // 7
    "tuv", // 8
    "wxyz" // 9
    };

    public List<String> letterCombinations(String digits) {
    List<String> result = new ArrayList<>();
    if (digits == null || digits.length() == 0) {
    return result;
    }

    backtrack(digits, 0, new StringBuilder(), result);
    return result;
    }

    /**
    * 回溯算法核心函数
    * @param digits 输入的数字字符串
    * @param index 当前处理到的数字位置
    * @param path 当前已拼接的字母组合
    * @param result 结果集
    */

    private void backtrack(String digits, int index, StringBuilder path, List<String> result) {
    // 结束条件:处理完所有数字
    if (index == digits.length()) {
    result.add(path.toString());
    return;
    }

    // 获取当前数字对应的字母串
    char digit = digits.charAt(index);
    String letters = mapping[digit '0'];

    // 遍历每个字母
    for (int i = 0; i < letters.length(); i++) {
    char c = letters.charAt(i);

    // 做选择:将当前字母加入路径
    path.append(c);

    // 递归处理下一个数字
    backtrack(digits, index + 1, path, result);

    // 撤销选择(回溯):移除刚加入的字母
    path.deleteCharAt(path.length() 1);
    }
    }
    }

    代码解析

  • 映射表:用数组 mapping 存储数字到字母的映射,下标直接对应数字,digit – '0' 转换为整数索引。

  • StringBuilder:使用 StringBuilder 来构建路径,因为它在末尾增删字符的效率很高。每次递归后需要 deleteCharAt 回溯。

  • index参数:index 表示当前处理到第几个数字,同时也是树的深度。当 index == digits.length() 时,说明已经到达叶子节点。

  • 回溯的精髓:path.append(c) 和 path.deleteCharAt(…) 配对出现,保证每次递归返回后,path 恢复到之前的状态,这样才能尝试当前数字的下一个字母。

  • 复杂度分析

    • 时间复杂度:O(3^m × 4^n) —— 其中 m 是对应3个字母的数字个数(2,3,4,5,6,8),n 是对应4个字母的数字个数(7,9)。每个组合需要 O(len) 的时间构建。
    • 空间复杂度:O(m + n) —— 递归栈的深度等于输入数字串的长度,不计结果集的空间。

    五、另一种思路:BFS队列解法(拓展思维)

    除了回溯(DFS),我们也可以用广度优先搜索(BFS)来解决这个问题。

    BFS核心思想

    用一个队列来存储中间结果,逐层扩展:

  • 初始时队列中放入一个空字符串
  • 遍历每个数字
  • 对于队列中的每个字符串,将其与当前数字对应的每个字母拼接,形成新的字符串放回队列
  • 处理完所有数字后,队列中的内容就是所有组合
  • BFS图解流程

    以输入"23"为例:

    • 初始队列:[""]
    • 处理数字2:队列弹出"",与"abc"拼接 → 队列变为 ["a","b","c"]
    • 处理数字3:依次弹出"a"、“b”、“c”,分别与"def"拼接
      • 弹出"a" → 生成"ad",“ae”,"af"入队
      • 弹出"b" → 生成"bd",“be”,"bf"入队
      • 弹出"c" → 生成"cd",“ce”,"cf"入队
    • 最终队列:["ad","ae","af","bd","be","bf","cd","ce","cf"]

    BFS代码实现

    class Solution {
    public List<String> letterCombinations(String digits) {
    List<String> result = new ArrayList<>();
    if (digits == null || digits.length() == 0) {
    return result;
    }

    String[] mapping = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

    // 使用LinkedList作为队列
    Queue<String> queue = new LinkedList<>();
    queue.offer("");

    for (int i = 0; i < digits.length(); i++) {
    char digit = digits.charAt(i);
    String letters = mapping[digit '0'];
    int size = queue.size(); // 当前层的元素个数

    // 处理当前层的所有元素
    for (int j = 0; j < size; j++) {
    String cur = queue.poll();
    // 将当前字符串与每个字母拼接后入队
    for (int k = 0; k < letters.length(); k++) {
    queue.offer(cur + letters.charAt(k));
    }
    }
    }

    // 队列中剩余的就是所有组合
    result.addAll(queue);
    return result;
    }
    }

    DFS vs BFS

    维度DFS(回溯)BFS(队列)
    空间复杂度 O(n) 递归栈 O(3m×4n) 队列可能很大
    代码复杂度 简洁,符合思维 稍复杂,需要处理层数
    适用场景 几乎所有组合问题 适合需要按层处理的场景
    面试推荐 ⭐⭐⭐⭐⭐ ⭐⭐⭐

    建议:面试中优先掌握DFS回溯解法,BFS作为拓展思路了解即可。


    六、细节剖析:面试官真正关心的问题

    Q1:为什么用 StringBuilder 而不用 String 拼接?

    答案:在回溯过程中,我们需要频繁地在末尾添加和删除字符。StringBuilder 的 append 和 deleteCharAt 都是 O(1) 操作。如果用 String 拼接,每次都会创建新对象,效率较低。

    Q2:回溯时为什么要撤销选择?不撤销会怎样?

    答案:如果不撤销选择,path 会一直累加,最终得到的是所有字母连在一起的字符串,而不是组合。例如"23",如果第一次选了’a’,第二次选了’d’,得到"ad";如果不撤销’d’,下一次就会在"ad"基础上继续加’e’,得到"ade",这显然是错误的。

    Q3:index 参数的作用是什么?

    答案:index 有两个作用:

  • 表示当前处理到第几个数字,同时也是递归的深度
  • 作为结束条件:当 index == digits.length() 时,说明所有数字都已处理完
  • Q4:为什么映射表用数组而不用 HashMap?

    答案:因为数字是连续且范围固定的(0-9),用数组可以通过 digit – '0' 直接索引,时间复杂度 O(1),比 HashMap 更快。如果用 HashMap 也可以,但需要额外的哈希计算。

    Q5:时间复杂度中的 3^m × 4^n 是怎么来的?

    答案:数字2、3、4、5、6、8对应3个字母,数字7、9对应4个字母。设 m 为三字母数字的个数,n 为四字母数字的个数,则总组合数为 3^m × 4^n。例如输入"23",m=2,n=0,总数为 3² = 9。


    七、面试官追问进阶版

    追问1:如果输入包含数字0和1,应该怎么处理?

    思路:题目说0和1不对应任何字母,如果输入中包含它们,可以有两种处理方式:

    • 直接忽略(跳过该数字)
    • 视为空字符,不影响组合

    // 修改后的代码
    if (digit == '0' || digit == '1') {
    // 跳过,递归下一个数字
    backtrack(digits, index + 1, path, result);
    return;
    }

    追问2:如何用迭代的方式(非递归)实现?

    思路:可以用 BFS 队列解法,上面已经给出。也可以用累乘法:

    public List<String> letterCombinations(String digits) {
    List<String> result = new ArrayList<>();
    if (digits.length() == 0) return result;

    result.add("");
    String[] mapping = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

    for (int i = 0; i < digits.length(); i++) {
    String letters = mapping[digits.charAt(i) '0'];
    List<String> newResult = new ArrayList<>();
    for (String prefix : result) {
    for (char c : letters.toCharArray()) {
    newResult.add(prefix + c);
    }
    }
    result = newResult;
    }
    return result;
    }

    追问3:如果要求输出的是字母组合的个数(而不列出具体组合),怎么算?

    思路:直接套用乘法原理:对于每个数字,乘以其对应的字母个数。不需要回溯,一次遍历即可。

    public int countCombinations(String digits) {
    int[] counts = {0, 0, 3, 3, 3, 3, 3, 4, 3, 4}; // 0-9对应的字母个数
    int result = 1;
    for (char c : digits.toCharArray()) {
    result *= counts[c '0'];
    }
    return result;
    }

    追问4:如果数字可以重复,结果中会出现重复组合吗?

    答案:会的。例如输入"22",第一个2选’a’、第二个2选’b’得到"ab",与第一个2选’b’、第二个2选’a’得到的"ba"是不同的组合,但题目要求的是所有组合,这些都应该保留。所以重复数字不影响结果,每个位置独立选择。


    八、实际开发:这道题到底有什么用?

    很多读者会问:“电话号码的字母组合,实际工作中哪用得到?”

    其实它的思想无处不在:

    场景1:输入法联想

    在手机输入法中,当你按数字键时,系统需要预测你可能想输入的单词,这背后的算法就是根据数字到字母的映射生成候选词。

    场景2:密码破解

    在暴力破解密码时,有时需要枚举所有可能的字母组合。虽然实际密码不会这么简单,但思路是相通的。

    场景3:基因序列组合

    在生物信息学中,DNA序列由四种碱基组成,需要枚举所有可能的组合模式,这也可以用回溯算法。

    场景4:电话号码记忆

    有些公司会用字母代替电话号码(如1-800-FLOWERS),将字母转回数字时,就需要这种映射关系。

    场景5:自动化测试

    在测试输入框的边界值时,有时需要生成所有可能的字符组合,回溯算法可以派上用场。


    九、总结:从一道题到一类题

    回顾一下,我们从电话号码的字母组合学到了什么:

    维度收获
    算法思维 多层循环 → 回溯(多叉树遍历),理解“做选择-递归-撤销选择”的模板
    代码技巧 StringBuilder回溯、映射表的数组实现、index参数设计
    复杂度分析 O(3^m × 4^n) 时间、O(n) 空间(递归栈)
    面试要点 为什么撤销选择?为什么用StringBuilder?index的作用?
    工程关联 输入法联想、密码枚举、基因组合、自动化测试

    力扣热题100的第十七题,不是为了难住你,而是为了告诉你:当问题需要“尝试所有可能”时,回溯算法就是你最可靠的伙伴。掌握了回溯,你就掌握了排列、组合、子集等一系列问题的通解。

    下一期预告:《四数之和》——双指针的再进阶


    附录:思考题

    看完这篇文章,你可以试着回答:

  • 如果题目要求返回的不是字符串列表,而是字符串数组,代码需要怎么调整?
  • 如果输入数字串很长(比如100位),递归深度过大导致栈溢出,怎么解决?
  • 你能用这道题的思路,去解 LeetCode 46(全排列)和 LeetCode 78(子集)吗?这些题有什么区别?
  • 欢迎在评论区留下你的思考!

    赞(0)
    未经允许不得转载:171主机测评 » 力扣热题100实战 | 第17期:电话号码的字母组合——回溯算法的经典启蒙
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址