LEETCODE 017Medium

电话号码的字母组合

每个数字对应一小组字母,答案是各组的笛卡尔积,回溯按位递归拼接就是逐层展开这个乘积。

问题拆解

九宫格键盘上,每个数字键映射几个字母(2 -> abc3 -> def……),给一串数字,输出所有可能的字母组合。比如 "23" 的答案是 ad, ae, af, bd, be, bf, cd, ce, cf——第一位从 abc 里挑一个,第二位从 def 里挑一个,组合数是各组大小的乘积,本质是若干个小集合的笛卡尔积。

如果数字串长度固定,写几层嵌套循环就完了;可长度不定,循环的层数没法写死。回溯解决的正是“层数由输入决定的嵌套循环”:用递归深度代替循环层数,每层负责一个数字位,枚举它的所有候选字母,拼上后进入下一层,走到底就收集一条结果。

递归树的第 k 层展开第 k 个数字的字母,一条根到叶的路径就是一个组合。回溯没有什么神秘的,它只是把不定层数的 for 循环写成了递归。

最容易踩的坑在空输入:digits 为空时应返回空列表 [],而不是含一个空串的 [""]。若不加特判,递归第一步就命中“拼完所有位”的收集条件,把空串收进去。

按位递归拼接

映射表用一个按下标访问的数组即可(下标 0、1 空着),比哈希表更直接。路径用可变缓冲区维护,进入下一层前追加一个字母,返回后弹掉,保证同层兄弟分支互不污染。

public List<String> letterCombinations(String digits) {
    List<String> res = new ArrayList<>();
    if (digits.isEmpty()) return res; // 空输入返回空列表,而不是 [""]
    String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
    StringBuilder path = new StringBuilder();
    dfs(digits, 0, map, path, res);
    return res;
}

private void dfs(String digits, int i, String[] map, StringBuilder path, List<String> res) {
    if (i == digits.length()) { // 每一位都选好了
        res.add(path.toString());
        return;
    }
    for (char c : map[digits.charAt(i) - '0'].toCharArray()) {
        path.append(c);
        dfs(digits, i + 1, map, path, res);
        path.deleteCharAt(path.length() - 1); // 回溯:撤销本层选择
    }
}
def letterCombinations(digits: str) -> list[str]:
    if not digits:  # 空输入返回空列表,而不是 [""]
        return []
    mapping = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"]
    res, path = [], []

    def dfs(i: int) -> None:
        if i == len(digits):  # 每一位都选好了
            res.append("".join(path))
            return
        for c in mapping[int(digits[i])]:
            path.append(c)
            dfs(i + 1)
            path.pop()  # 回溯:撤销本层选择

    dfs(0)
    return res
func letterCombinations(digits string) []string {
    if digits == "" { // 空输入返回空列表,而不是 [""]
        return []string{}
    }
    mapping := []string{"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}
    res := []string{}
    path := make([]byte, 0, len(digits))
    var dfs func(i int)
    dfs = func(i int) {
        if i == len(digits) { // 每一位都选好了
            res = append(res, string(path))
            return
        }
        for _, c := range []byte(mapping[digits[i]-'0']) {
            path = append(path, c)
            dfs(i + 1)
            path = path[:len(path)-1] // 回溯:撤销本层选择
        }
    }
    dfs(0)
    return res
}
pub fn letter_combinations(digits: String) -> Vec<String> {
    if digits.is_empty() { // 空输入返回空列表,而不是 [""]
        return vec![];
    }
    const MAP: [&str; 10] = ["", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"];
    let digits: Vec<usize> = digits.bytes().map(|b| (b - b'0') as usize).collect();
    let mut res = Vec::new();
    let mut path = String::new();

    fn dfs(digits: &[usize], i: usize, path: &mut String, res: &mut Vec<String>) {
        if i == digits.len() { // 每一位都选好了
            res.push(path.clone());
            return;
        }
        for c in MAP[digits[i]].chars() {
            path.push(c);
            dfs(digits, i + 1, path, res);
            path.pop(); // 回溯:撤销本层选择
        }
    }
    dfs(&digits, 0, &mut path, &mut res);
    res
}

三个位置容易出错。一是前面说的空输入特判;二是“撤销”必须与“追加”严格配对——漏掉 pop,前一个分支的字母会残留在路径里,后续组合全部错位;三是收集结果时要拷贝一份(toString / join / clone),直接把可变缓冲区塞进结果列表,后面的回溯会把已收集的答案改掉。

复杂度

指标 复杂度 原因
时间 O(n · 4ⁿ) 组合最多 4ⁿ 个(7、9 各有四个字母),每个组合拼接花 O(n)
空间 O(n) 递归深度与路径缓冲区,均为数字串长度(不计输出)

可以迁移的模式

  • 层数不定的嵌套枚举,用递归深度换循环层数,这是回溯的原始形态;
  • “路径缓冲区 + 进入前追加、返回后撤销”的配对动作是回溯的手型,收集时记得拷贝;
  • 候选集固定且以小整数索引时,数组映射表比哈希表更简洁。

这道题没有剪枝、没有去重,是回溯框架最干净的样板;组合、子集、全排列都只是在这个骨架上增删收集条件与枚举范围。