📌 题目链接: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..."。
🧩 解题思路(分步拆解)
-
预处理映射关系
建立数字'0'-'9'到字母串的映射数组(digital_mapping),注意0和1对应空字符串。 -
处理空输入
若digits为空,直接返回空vector(题目明确要求)。 -
启动回溯
从index = 0开始,逐位处理数字。 -
递归与回溯
- 每层根据当前数字获取候选字母
- 依次尝试每个字母,递归处理下一位
- 递归返回后立即撤销选择,保证状态干净
-
收集结果
当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^n 与 4^k 的差异 |
| 空间优化 | 可讨论是否将 path 作为参数传入(避免全局变量) |
💬 面试官可能追问:
- “如果数字包含
'1'或'0'怎么办?” → 答:题目限定2-9,但可扩展映射表- “能否用 BFS 实现?” → 答:可以,用队列逐层扩展,但空间开销更大
- “如何去重?” → 答:本题无重复,但若输入有重复数字(如
"22"),仍会生成"aa", "ab"...,这是合法的
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!