很多刚接触算法的小伙伴,一听到"回溯"、"递归"就觉得头大。别怕!这道题是绝佳的入门练习。我会把思考过程掰开揉碎,用画树状图的方式,带你一步步拿下它。
一、 题目剖析与思路解析
题目大意: 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。其实就是老式手机键盘的九宫格拼音输入法。
核心思路:把组合问题转化成"树的遍历"!
假设输入的是 "23":
-
数字
2对应字母串"abc" -
数字
3对应字母串"def"
这就相当于我们要在这个树形结构里,找出所有从根节点到叶子节点的路径:
(开始)
/ | \
数字2: a b c
/| \ ... ...
数字3: d e f
-
路径1:a -> d (组合 "ad")
-
路径2:a -> e (组合 "ae")
-
...以此类推。
既然是收集所有路径,回溯算法就是不二之选!接下来我们祭出经典的"回溯三部曲"。
二、 回溯三部曲
我们要写一个递归函数 backtrack,在写之前,先明确三个问题:
-
确定回溯函数参数: 我们需要传入当前的数字字符串
digits,还需要一个参数index来记录当前遍历到了第几个数字。 -
确定终止条件:
什么时候收集结果?当
index等于digits的长度时,说明所有的数字都遍历完了,这时候把手里拼好的字符串存入结果集,然后return。 -
确定单层遍历逻辑:
拿到当前数字,查表找出它对应的字母串。然后用一个
for循环遍历这些字母。选中一个字母,接着递归处理下一个数字(index + 1),递归回来后,要把刚才选的字母撤销掉(回溯),换下一个字母继续试。
为了方便查找数字对应的字母,我们首先需要定义一个映射表(数组或哈希表均可)。
三、 代码实现与逐行注释
这里提供 Python、C++ 和 C 三种语言的解法,大家可以选择自己熟悉的语言食用。
1. Python 解法 (最简洁直观)
Python 的字符串操作非常方便,代码看起来最清爽。
python
class Solution:
def __init__(self):
# 1. 定义数字到字母的映射表 (0和1不对应字母,留空)
self.letter_map = [
"", # 0
"", # 1
"abc", # 2
"def", # 3
"ghi", # 4
"jkl", # 5
"mno", # 6
"pqrs", # 7
"tuv", # 8
"wxyz" # 9
]
self.result = [] # 存放最终的所有组合
self.path = "" # 存放当前正在拼接的单个组合
def letterCombinations(self, digits: str) -> list[str]:
if not digits:
return [] # 如果输入为空,直接返回空列表
self.result.clear()
self.backtrack(digits, 0)
return self.result
# 回溯函数
def backtrack(self, digits: str, index: int):
# 2. 终止条件:当 index 等于 digits 长度时,说明走到了叶子节点
if index == len(digits):
self.result.append(self.path) # 收集结果
return
# 3. 单层遍历逻辑
digit = int(digits[index]) # 取出当前字符并转为整数,例如 '2' -> 2
letters = self.letter_map[digit] # 查表拿到对应的字母串,例如 "abc"
for letter in letters:
self.path += letter # 处理:把当前字母拼接到 path
self.backtrack(digits, index + 1) # 递归:处理下一个数字
self.path = self.path[:-1] # 回溯:撤销刚才添加的字母,尝试下一个
2. C++ 解法 (算法面试最常用)
C++ 使用 std::vector 和 std::string,效率高且标准。
cpp
#include <vector>
#include <string>
using namespace std;
class Solution {
private:
// 1. 映射表,使用 const 数组更加高效
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz" // 9
};
vector<string> result; // 存放最终结果
string path; // 存放当前路径
// 回溯函数
void backtracking(const string& digits, int index) {
// 2. 终止条件:路径长度等于输入数字的长度
if (index == digits.size()) {
result.push_back(path); // 收集当前组合
return;
}
// 3. 单层遍历逻辑
int digit = digits[index] - '0'; // 将字符转为数字,比如 '2' - '0' = 2
string letters = letterMap[digit]; // 拿到对应的字母集
for (int i = 0; i < letters.size(); i++) {
path.push_back(letters[i]); // 处理:加入当前字母
backtracking(digits, index + 1); // 递归:往下一层走
path.pop_back(); // 回溯:弹出末尾字母,准备尝试下一个
}
}
public:
vector<string> letterCombinations(string digits) {
result.clear();
path.clear();
if (digits.empty()) {
return result; // 判空处理
}
backtracking(digits, 0);
return result;
}
};
3. C 语言解法 (考验底层基本功)
C 语言没有自带的字符串类和动态数组,需要我们手动分配内存 (malloc),非常锻炼基本功。对于正在做系统级实验或者打牢基础的同学来说,这版代码很有参考价值。
objectivec
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
// 1. 全局映射表
const char* letterMap[10] = {
"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};
char** result; // 存储最终结果的二维数组
int resultTop; // 记录结果集里的元素个数
char* path; // 记录当前的组合路径
// 回溯函数
void backtrack(char* digits, int index) {
// 2. 终止条件
if (index == strlen(digits)) {
// 遇到终止条件,将当前 path 拷贝并存入 result
char* temp = (char*)malloc(sizeof(char) * (strlen(digits) + 1));
strcpy(temp, path);
result[resultTop++] = temp;
return;
}
// 3. 单层遍历逻辑
int digit = digits[index] - '0'; // 字符转整数
const char* letters = letterMap[digit]; // 获取对应字母串
int len = strlen(letters);
for (int i = 0; i < len; i++) {
path[index] = letters[i]; // 处理:直接在对应索引处赋值
path[index + 1] = '\0'; // 保持字符串有结束符
backtrack(digits, index + 1); // 递归
// 注意:C语言这里通过下一次循环覆盖 path[index] 来实现"回溯",
// 不需要显式的 pop 操作,非常巧妙!
}
}
char ** letterCombinations(char * digits, int* returnSize) {
*returnSize = 0; // 初始化返回的数组大小为0
int len = strlen(digits);
if (len == 0) return NULL; // 为空直接返回
// 估算最大可能的组合数:每个数字最多对应4个字母,最大长度是4^N
int maxCombinations = pow(4, len);
// 分配内存
result = (char**)malloc(sizeof(char*) * maxCombinations);
path = (char*)malloc(sizeof(char) * (len + 1));
resultTop = 0;
path[0] = '\0'; // 初始化为空字符串
// 开启回溯
backtrack(digits, 0);
*returnSize = resultTop; // 设置返回的实际数组大小
return result;
}
四、 复杂度分析
无论使用哪种语言,算法的本质是一样的:
时间复杂度: O(n4^n),其中 n 为 digits 的长度。最坏情况下每次需要枚举 4 个字母,递归次数为一个满四叉树的节点个数,那么一共会递归 O(4^n) 次(等比数列和),再算上加入答案时复制 path 需要 O(n) 的时间,所以时间复杂度为 O(n4^n)。
**空间复杂度:**O(n)。返回值的空间不计入
总结: 回溯算法本质上就是一种暴力穷举,只是套上了一层递归的壳子。只要按照"回溯三部曲"的框架去思考,理清参数、终止条件和单层逻辑,再难的题目也能被拆解得明明白白。
照例附上卡哥的代码随想录
17.电话号码的字母组合 | 回溯算法 | 数字映射 | 字符集 | 代码随想录
最后这个复杂度分析抄的灵神的,我自己还是不太会分析,灵神B站题目讲解: