本文概览:本文以LeetCode题目"电话号码的字母组合"为例,讲解回溯法在多选一场景下的应用,和子集方法二的模板对比
一、题目

二、题目分析
题目要求:给定一个数字字符串(2-9),每个数字对应一组字母(手机九宫格键盘),从每个数字对应的字母中各取一个,组合成新字符串,返回所有可能的组合
数字到字母的映射表:
1 2 3
| 2 → abc 3 → def 4 → ghi 5 → jkl 6 → mno 7 → pqrs 8 → tuv 9 → wxyz
|
比如 digits = "23":2 对应 "abc",3 对应 "def",从 "abc" 取一个,从 "def" 取一个,组合起来
1 2 3
| a + d → "ad" a + e → "ae" a + f → "af" b + d → "bd" b + e → "be" b + f → "bf" c + d → "cd" c + e → "ce" c + f → "cf"
|
共 3 × 3 = 9 种组合
这题和子集方法一(选/不选决策树)的思路很像——都是对每个位置做选择,递归到最深处收集结果。区别是:子集每个元素可以选也可以不选,这题每个数字必须选一个字母,相当于把决策树里所有"不选"的分支全砍掉,只保留"选"的分支一路走到底
思路概览
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| class Solution { private final String[] map = { "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" };
private final StringBuilder sb = new StringBuilder();
private final List<String> result = new ArrayList<>(); public List<String> letterCombinations(String digits) { if (digits.isEmpty()) { return new ArrayList<>(); } backtrack(digits, 0); return result; }
private void backtrack(String digits, int index) { if (index == digits.length()) { result.add(sb.toString()); return; } String letter = map[digits.charAt(index) - '0']; for (int i = 0; i < letter.length(); i++) { sb.append(letter.charAt(i)); backtrack(digits, index + 1); sb.deleteCharAt(sb.length() - 1); }
} }
|
思路简要说明
- 映射表:用数组存数字到字母的映射,索引就是数字本身,
map[2] = "abc"
- index 标记处理到第几个数字:每次取出
digits.charAt(index) 对应的字母串,for 循环选一个字母往下递归
- 只在叶子节点收集:index == digits.length 时,所有数字都选完了,把 StringBuilder 转成 String 存入结果
三、思路详解
第一步:映射表的设计
数字 2-9 对应字母,怎么存?最直接的方式是用数组,索引就是数字:
1 2 3 4 5 6 7 8 9 10 11 12
| private final String[] map = { "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" };
|
取数字 3 对应的字母串:map[3] → "def",O(1) 查询
0 和 1 没有对应字母,放空字符串占位,这样数字直接当索引用,不需要额外转换
第二步:和子集方法一(选/不选决策树)的对比
子集方法一每个元素有"选"和"不选"两个分支,递归 n 层到叶子节点收集结果。这题每个数字必须选一个字母,相当于把"不选"分支全砍掉:
子集方法一的决策树(nums = [1, 2]):
1 2 3 4 5 6 7
| 根 ├─ 选1 │ ├─ 选2 → {1,2} │ └─ 不选2 → {1} └─ 不选1 ├─ 选2 → {2} └─ 不选2 → {}
|
本题的决策树(digits = "23")——没有"不选"分支,每层必须选一个:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 根(处理数字2) ├─ 选a │ ├─ 选d → "ad" │ ├─ 选e → "ae" │ └─ 选f → "af" ├─ 选b │ ├─ 选d → "bd" │ ├─ 选e → "be" │ └─ 选f → "bf" └─ 选c ├─ 选d → "cd" ├─ 选e → "ce" └─ 选f → "cf"
|
每个节点都往下选,不存在"不选"的分支,所以只在叶子节点(所有数字都选完)收集结果
|
子集(方法一) |
电话号码的字母组合 |
| 分支方式 |
每个元素两个分支:选 / 不选 |
每个数字必须选一个字母,没有不选分支 |
| 收集时机 |
只在叶子节点收集 |
只在叶子节点收集 |
| 递归参数 |
index(当前处理第几个) |
index(当前处理第几个数字) |
| 回溯操作 |
add / removeLast |
append / deleteCharAt |
| 树的形态 |
满二叉树(2ⁿ 个叶子) |
多叉树(每层分叉数 = 字母数) |
核心区别:子集有"不选"分支所以是满二叉树,本题没有"不选"分支,每层分叉数取决于当前数字对应几个字母
第三步:index 参数的作用
index 表示当前处理到第几个数字,每层 index+1。以 digits = "23" 为例:
1 2 3 4 5 6 7 8 9 10 11 12
| backtrack(index=0, sb="") ├── append 'a' → backtrack(index=1, sb="a") ← 处理数字 '2',对应 "abc" │ ├── append 'd' → backtrack(index=2, sb="ad") ← 处理数字 '3',对应 "def" │ │ └── index == 2 == digits.length(),收集 "ad" │ ├── append 'e' → backtrack(index=2, sb="ae") │ │ └── 收集 "ae" │ └── append 'f' → backtrack(index=2, sb="af") │ └── 收集 "af" ├── append 'b' → backtrack(index=1, sb="b") │ ├── ...(同上,收集 "bd", "be", "bf") └── append 'c' → backtrack(index=1, sb="c") └── ...(同上,收集 "cd", "ce", "cf")
|
digits.charAt(index) - '0' 把字符转成数字,比如 '2' - '0' = 2,然后 map[2] 拿到 "abc"
第四步:回溯操作
回溯的两个动作:
- append = 选:
sb.append(letter.charAt(i)),选了第 i 个字母往下递归
- deleteCharAt = 撤销换下一个:
sb.deleteCharAt(sb.length() - 1),删掉最后一个字母,for 循环 i++ 选下一个
以数字 '2' 对应 "abc" 为例:
1 2 3
| 选 'a' → 递归 → 回溯(删掉 'a') 选 'b' → 递归 → 回溯(删掉 'b') 选 'c' → 递归 → 回溯(删掉 'c')
|
每次选完一个字母往深处走,回来后撤销,for 循环选下一个,这就是回溯的核心
第五步:完整执行过程
以 digits = "23" 为例,2 对应 "abc",3 对应 "def":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| backtrack(index=0, sb="") ├── append 'a' → backtrack(index=1, sb="a") │ ├── append 'd' → backtrack(index=2, sb="ad") │ │ └── 收集 "ad" ← ① │ ├── remove 'd' │ ├── append 'e' → backtrack(index=2, sb="ae") │ │ └── 收集 "ae" ← ② │ ├── remove 'e' │ ├── append 'f' → backtrack(index=2, sb="af") │ │ └── 收集 "af" ← ③ │ └── remove 'f' ├── remove 'a' ├── append 'b' → backtrack(index=1, sb="b") │ ├── append 'd' → backtrack(index=2, sb="bd") │ │ └── 收集 "bd" ← ④ │ ├── remove 'd' │ ├── append 'e' → backtrack(index=2, sb="be") │ │ └── 收集 "be" ← ⑤ │ ├── remove 'e' │ ├── append 'f' → backtrack(index=2, sb="bf") │ │ └── 收集 "bf" ← ⑥ │ └── remove 'f' ├── remove 'b' ├── append 'c' → backtrack(index=1, sb="c") │ ├── append 'd' → backtrack(index=2, sb="cd") │ │ └── 收集 "cd" ← ⑦ │ ├── remove 'd' │ ├── append 'e' → backtrack(index=2, sb="ce") │ │ └── 收集 "ce" ← ⑧ │ ├── remove 'e' │ ├── append 'f' → backtrack(index=2, sb="cf") │ │ └── 收集 "cf" ← ⑨ │ └── remove 'f' └── remove 'c'
|
结果:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"],共 9 个 = 3 × 3
第六步:StringBuilder vs String 拼接
代码中用 StringBuilder 而不是 String 拼接:
1 2 3 4 5 6 7 8 9
| String path = ""; path += 'a'; path += 'd';
StringBuilder sb = new StringBuilder(); sb.append('a'); sb.append('d');
|
回溯需要频繁增删字符,StringBuilder 的 append 和 deleteCharAt 都是 O(1),而 String 拼接每次创建新对象效率低
复杂度分析
- 时间复杂度:O(3ⁿ × 4ᵐ),其中 n 是对应 3 个字母的数字个数,m 是对应 4 个字母的数字个数(7 和 9 对应 4 个字母)。最坏情况 O(4ⁿ)
- 空间复杂度:O(n),递归深度最大为 n(数字个数),StringBuilder 最多存 n 个字符