LeetCode 17 电话号码的字母组合:Java 回溯模板、多叉决策树与复杂度分析

一句话说明核心方法

把 digits 的每一位看作决策树的一层,每层从"当前数字对应的一组字母"里选一个,用回溯 DFS 走完所有层,叶子节点拼出的字符串就是一个合法组合。

思路推导

题意转化 :输入 "23",本质是"第 1 位从 abc 里选一个,第 2 位从 def 里选一个",两个位置各做一次选择 → 笛卡尔积。

关键观察 :这是一棵多叉决策树 ------树的层数 = digits.length(),第 i 层的分支数 = digits[i] 对应字母的个数(3 或 4)。答案就是所有"根到叶子"的路径。

算法选择 :既然要枚举所有路径,回溯是最自然的工具。用一个 StringBuilder path 记录当前路径,走到最后一层就收集。

和子集题的关键区别 :子集是二叉决策(每个元素选/不选),每层只有 0/1 两种分支,且每个节点都是答案 ;本题是 k 叉决策,每层分支数由数字决定,只有走到最后一层(index == length)才是一个完整组合。所以收集点从"每个节点"变成了"仅叶子节点"。

回溯树示意(digits = "23")

复制代码
                        ""                     ← index=0,path 为空
            ┌────────────┼────────────┐
          'a'          'b'          'c'        ← digits[0]='2' → "abc"
           │            │            │
        index=1      index=1      index=1
        "a"          "b"          "c"
        ┌─┼─┐        ┌─┼─┐        ┌─┼─┐
       d e f        d e f        d e f        ← digits[1]='3' → "def"
       │ │ │        │ │ │        │ │ │
      ad ae af     bd be bf     cd ce cf      ← index=2,收集(叶子)

                       共 3 × 3 = 9 个组合

注意每层 for 循环遍历的不是 nums 全部元素,而是当前数字对应的那几个字母 ;层数固定为 digits.length(),不深不浅。

Java 完整代码

复制代码
class Solution {
    // 数字到字母的映射:下标 0/1 占位为 "",2→abc ... 9→wxyz
    String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
    List<String> res = new ArrayList<>();

    public List<String> letterCombinations(String digits) {
        // 空串特判:否则会错误地收集一个 ""
        if (digits == null || digits.length() == 0) {
            return res;
        }
        backTrack(digits, 0, new StringBuilder());
        return res;
    }

    private void backTrack(String digits, int index, StringBuilder path) {
        // 终止条件:已经处理完所有数字,path 就是一个完整组合
        if (index == digits.length()) {
            res.add(path.toString());
            return;
        }
        char ch = digits.charAt(index);
        String letters = map[ch - '0'];   // 当前层的候选字母集
        for (int i = 0; i < letters.length(); i++) {
            path.append(letters.charAt(i));          // 1. 选
            backTrack(digits, index + 1, path);      // 2. 递归到下一层
            path.deleteCharAt(path.length() - 1);    // 3. 撤销
        }
    }
}

关键代码逐行解释

  • map[ch - '0']:字符 '2' 的 ASCII 是 50,'0' 是 48,相减得到整数 2,正好作为数组下标取到 "abc"。这比写 switch 或一长串 if-else 简洁得多,也是本题最常用的技巧。
  • if (index == digits.length()) 作为终止条件:走到这里说明每一位都选完了,path 长度恰好等于 digits.length(),是一个完整组合。这是"叶子收集"的典型写法------只在满足长度条件时收,不像子集题每个节点都收。
  • String letters = map[ch - '0'] 而不是在 for 里直接写 map[digits.charAt(index) - '0'].length():先把候选集提出来,for 循环里只做"取字母",读写更清爽,也避免重复计算。
  • path.deleteCharAt(path.length() - 1):StringBuilder 的撤销操作,删掉最后加的字符。别写成 deleteCharAt(index) ------index 是层号,不是字符位置,这样删会出错。
  • res.add(path.toString()):必须 toString() 生成一份新的 String。如果直接 res.add(path) 类型都不对;即便类型转成 String 再共享引用,后续递归改了 StringBuilder,已加入的结果也会跟着变。

时间、空间复杂度

  • 时间复杂度:O(n · 4ⁿ) (n = digits.length())
    • 决策树叶子数上界为 4ⁿ(每个数字最多对应 4 个字母),每个叶子执行一次 path.toString() 生成长度为 n 的字符串,故上界 O(n · 4ⁿ)。因为 n ≤ 4,实际规模极小。
  • 空间复杂度:O(n) (不计输出)
    • 递归栈深度为 n,StringBuilder path 长度也为 n。输出结果本身占 O(4ⁿ · n),题目要求返回,无法避免。

易错点

  • 空串没特判 :如果题目允许 digits = "",代码会执行到 index == 0 == length,收集一个空字符串 "",结果变成 [""]。正确结果应是 [],所以入口要加 if (digits.length() == 0) return res;(本题约束 length >= 1,但面试/旧版题目常考这个边界)。
  • 忘了 toString() :用 StringBuilder 累积路径时,必须 res.add(path.toString()) 拷贝一份。直接存引用会让所有结果指向同一个对象,最后全被后续递归改掉。
  • 撤销位置写错 :deleteCharAt(path.length() - 1) 删的是"最后一个字符";写成 deleteCharAt(index)(层号)会删错位置,导致路径混乱。
  • map 数组占位不能少 :下标 0 和 1 必须留成 "" 占位。如果从 "abc" 开始填,ch - '0' 算出的下标就会整体错位一位。

可复用模板

回溯的"多层多分支枚举"通用框架(笛卡尔积 / 字符串枚举型),本题和括号生成、字母大小写全排列等都可套用:

java

复制代码
class Solution {
    private static final String[] MAP = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
    private final List<String> res = new ArrayList<>();

    public List<String> letterCombinations(String digits) {
        if (digits == null || digits.isEmpty()) return res;   // ① 空串特判
        backtrack(digits, 0, new StringBuilder());
        return res;
    }

    // 模板:回溯 = 终止判断(叶子收集) + 取当前层候选集 + 循环 选/递归/撤销
    private void backtrack(String digits, int index, StringBuilder path) {
        if (index == digits.length()) {                       // ② 走到叶子才收集
            res.add(path.toString());                         // ③ 拷贝,别存引用
            return;
        }
        String letters = MAP[digits.charAt(index) - '0'];     // ④ 当前层候选集
        for (char c : letters.toCharArray()) {                // ⑤ 多分支
            path.append(c);                                   // 选
            backtrack(digits, index + 1, path);               // 递归
            path.deleteCharAt(path.length() - 1);             // 撤销
        }
    }
}

变体提示:

  • 候选集来自"每层不同集合"→ 用 MAP[...] 这样按层取;来自"同一集合且不可重复用"→ 用 start 参数;来自"同一集合可重复用"→ 每层都从 0 开始。
  • 需要剪枝(如括号生成)→ 在递归入口加约束判断,不满足直接 return。

相似题及区别

  • LeetCode 78 子集 :二叉决策树,每层 2 分支,且每个节点都是答案 ;本题是多叉决策树,每层分支数由数字决定,只有叶子才是答案
  • LeetCode 46 全排列 :也是回溯构建完整排列,但每层候选集相同(整个数组),需要 boolean[] used 去重;本题每层候选集不同(由 digits[index] 决定),天然不会重复。
  • LeetCode 22 括号生成 :同样是回溯拼接字符串,但加入了 左括号数 < n右括号数 < 左括号数 的剪枝;本题无剪枝,纯枚举。
  • LeetCode 39 组合总和:回溯 + 排序剪枝,候选元素可重复使用;本题每个数字只对应固定候选且每层各用一次,不涉及"重用"问题。
相关推荐
CoderYanger2 小时前
前端基础——JavaScript(WebAPI)代码案例
java·开发语言·前端·javascript·css·前端框架·html5
Nuanyt2 小时前
JUC常见核心知识梳理02 AQS ReentrantLock CAS 原子类 线程池
java
CodeStats2 小时前
【Java数据类型】Java底层数据类型原理(上):基本类型、转换与封装核心
java·数据类型·string·封装·转换
陈皮波比茶2 小时前
SpringCloudAlibaba
java·spring cloud·微服务
重生之小比特2 小时前
【Java SE】程序逻辑控制
java·开发语言·python
find1star2 小时前
LeetCode 25:K 个一组翻转链表
java·数据结构·算法·leetcode·链表·职场和发展·动态规划
染的人2 小时前
Java 10x15cm面单PDF转换A4格式PDF
java·开发语言·pdf
泡海椒2 小时前
JQuick-java (JQuick-ASM)性能优化原理:字节码生成与缓存机制深度解析
java·缓存·性能优化
Bs_MoneyMagnet2 小时前
基于springboot+vue的家居生活商城平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring