【LeetCode Hot100 刷题日记 (57/100)】17. 电话号码的字母组合 —— 回溯算法(Backtracking)📞

3 阅读6分钟

📌 题目链接:17. 电话号码的字母组合 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:字符串、哈希表、回溯

⏱️ 目标时间复杂度:O(3^m × 4^n) (m 为对应 3 字母的数字个数,n 为对应 4 字母的数字个数)

💾 空间复杂度:O(m + n) (递归栈深度)


🔍 题目分析

给定一个仅包含数字 2-9 的字符串 digits,要求返回所有它能表示的字母组合。每个数字映射到电话键盘上的若干字母(如 '2' → "abc"),我们需要穷举所有可能的字母排列。

  • 输入约束1 <= digits.length <= 4,且只含 '2''9'
  • 输出要求:任意顺序,但必须包含所有合法组合
  • 边界情况:若输入为空字符串,应返回空列表(而非包含空字符串的列表)

这是一道典型的组合生成问题,适合用**回溯(Backtracking)**解决。


🧠 核心算法及代码讲解:回溯(Backtracking)🔄

✅ 什么是回溯?

回溯是一种暴力搜索的优化策略,通过“试错 + 撤销”来系统性地遍历所有可能解。其核心思想是:

在每一步做选择 → 递归进入下一层 → 回退时撤销选择

适用于:

  • 组合、排列、子集类问题
  • 棋盘/路径搜索(如 N 皇后、数独)
  • 所有需要“穷举 + 剪枝”的场景

🔑 本题回溯三要素

要素说明
路径(path)当前已选字母组成的字符串(如 "ad"
选择列表当前数字对应的所有字母(如 '3' → "def"
结束条件已处理完所有数字(index == digits.size()

📜 C++ 回溯函数详解(带行注释)

void backtracking(string digits, int index) {
    // 2️⃣ 终止条件:当 index 等于 digits 长度,说明已选完所有位
    if (index == digits.size()) {
        result.push_back(path);  // 将当前路径加入结果集
        return;
    }

    // 3️⃣ 获取当前数字对应的字母集合
    int digital_number = digits[index] - '0';           // 字符转数字(如 '2' → 2)
    string letters = digital_mapping[digital_number];   // 查表得字母串(如 2 → "abc")

    // 4️⃣ 遍历当前可选字母(for 循环 = 本层所有选择)
    for (int i = 0; i < letters.size(); i++) {
        path.push_back(letters[i]);      // 👉 做选择:加入当前字母
        backtracking(digits, index + 1); // 🔁 递归:处理下一位数字
        path.pop_back();                 // 🔙 回溯:撤销选择(关键!)
    }
}

💡 为什么需要 pop_back()
因为 path 是全局变量(或引用传递),如果不回溯,上一层的选择会污染下一次循环。例如:第一次选 'a' 后递归得到 "ad",若不弹出 'a',下次循环选 'b'path 会变成 "ab..." 而非 "b..."


🧩 解题思路(分步拆解)

  1. 预处理映射关系
    建立数字 '0'-'9' 到字母串的映射数组(digital_mapping),注意 01 对应空字符串。

  2. 处理空输入
    digits 为空,直接返回空 vector(题目明确要求)。

  3. 启动回溯
    index = 0 开始,逐位处理数字。

  4. 递归与回溯

    • 每层根据当前数字获取候选字母
    • 依次尝试每个字母,递归处理下一位
    • 递归返回后立即撤销选择,保证状态干净
  5. 收集结果
    index 达到字符串末尾,将 path 加入 result


📊 算法分析

⏱️ 时间复杂度:O(3^m × 4^n)

  • 数字 2,3,4,5,6,8 各对应 3 个字母 → 共 m
  • 数字 7,9 各对应 4 个字母 → 共 n
  • 总组合数 = 3^m × 4^n,每种组合需 O(1) 时间生成(忽略字符串拷贝)

📌 面试提示:若面试官问“为什么不是 O(4^k)(k 为长度)?”,可解释:不同数字分支因子不同,需按实际字母数分类计算。

💾 空间复杂度:O(m + n)

  • 递归栈深度 = digits.length() = m + n
  • digital_mapping 为常量空间(10 个字符串)
  • result 为输出空间,通常不计入(除非特别说明)

面试加分点:强调“输出空间不算入空间复杂度”是算法分析惯例。


💻 代码实现

✅ C++ 完整代码(

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
/*
思路:每个数字代表的数集是不一样的,第一层能选的数集
是digits第一个数字所以代表的字母集,每层的for循环的数
集是不一样的,所以要用一个索引表示当层循环的数集,
然后要把数字转换为字母,字母是就是可以选择的数集,
还要一个path记录路径一个result记录答案,一个参数记
录递归的深度(层数),   path也可写到参数里
*/

const string digital_mapping[10] =  //映射每个数字所代表字母集合
{
    "", //0
    "", //1
    "abc", //2
    "def", //3
    "ghi", //4
    "jkl", //5
    "mno", //6
    "pqrs", //7
    "tuv", //8
    "wxyz", //9
};
vector<string>result;   //记录答案
string path; //记录路径
    //1:确定函数参数
    void backtracking(string digits, int index)
    {
        //2:确定终止条件 处理结果 return
        if(index == digits.size())
        {
            result.push_back(path);
            return;
        }

        //3:选择本层集合中元素
        int digital_number = digits[index] - '0'; //取出digits中本层的数字
        string letters = digital_mapping[digital_number]; //从映射中取出字母集
        for(int i = 0; i < letters.size(); i++)
        {
            path.push_back(letters[i]); //处理
            backtracking(digits, index + 1); //递归
            path.pop_back(); //回溯
        }
    }

    vector<string> letterCombinations(string digits) {
        if(digits.empty())return result;
        backtracking(digits, 0);
        return result;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    
    // 🧪 测试用例 1
    string digits1 = "23";
    vector<string> res1 = sol.letterCombinations(digits1);
    cout << "输入: "23"\n输出: [";
    for(int i = 0; i < res1.size(); i++) {
        cout << """ << res1[i] << """;
        if(i != res1.size()-1) cout << ", ";
    }
    cout << "]\n\n";

    // 🧪 测试用例 2
    string digits2 = "2";
    vector<string> res2 = sol.letterCombinations(digits2);
    cout << "输入: "2"\n输出: [";
    for(int i = 0; i < res2.size(); i++) {
        cout << """ << res2[i] << """;
        if(i != res2.size()-1) cout << ", ";
    }
    cout << "]\n\n";

    // 🧪 测试用例 3:空输入
    string digits3 = "";
    vector<string> res3 = sol.letterCombinations(digits3);
    cout << "输入: ""\n输出: []\n";

    return 0;
}

✅ JavaScript 完整代码(等效实现)

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function(digits) {
    if (digits.length === 0) return [];
    
    const digitalMapping = [
        "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
    ];
    
    const result = [];
    let path = "";
    
    function backtracking(index) {
        if (index === digits.length) {
            result.push(path);
            return;
        }
        
        const digit = parseInt(digits[index]);
        const letters = digitalMapping[digit];
        
        for (let i = 0; i < letters.length; i++) {
            path += letters[i];          // 做选择
            backtracking(index + 1);     // 递归
            path = path.slice(0, -1);    // 回溯(JS 字符串不可变,用 slice 模拟 pop)
        }
    }
    
    backtracking(0);
    return result;
};

// 🧪 测试
console.log(letterCombinations("23")); // ["ad","ae","af","bd","be","bf","cd","ce","cf"]
console.log(letterCombinations("2"));  // ["a","b","c"]
console.log(letterCombinations(""));   // []

💡 JS 注意:字符串不可变,回溯时用 path = path.slice(0, -1) 模拟 pop_back()


🎯 面试高频考点总结

考点说明
回溯模板掌握能快速写出“选择-递归-撤销”三步结构
状态管理理解为何 path 需要回溯(避免状态污染)
边界处理空输入必须特判(否则会输出 [""] 错误结果)
复杂度分析能区分 3^m × 4^n4^k 的差异
空间优化可讨论是否将 path 作为参数传入(避免全局变量)

💬 面试官可能追问

  • “如果数字包含 '1''0' 怎么办?” → 答:题目限定 2-9,但可扩展映射表
  • “能否用 BFS 实现?” → 答:可以,用队列逐层扩展,但空间开销更大
  • “如何去重?” → 答:本题无重复,但若输入有重复数字(如 "22"),仍会生成 "aa", "ab"...,这是合法的

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!