引言
LeetCode第17题"电话号码的字母组合"是一个经典的算法问题,它要求我们根据手机键盘的数字-字母映射,生成所有可能的字母组合。对于初学者来说,很容易写出只处理特定长度输入的代码。今天,我们就从这种有限处理开始,逐步探讨如何将其扩展为通用解法。
目录
[1. 处理边界情况](#1. 处理边界情况)
[2. 性能优化](#2. 性能优化)
[3. 扩展到其他问题](#3. 扩展到其他问题)
问题回顾
给定一个仅包含数字2-9的字符串,返回所有它能表示的字母组合。数字到字母的映射如下:
-
2: abc
-
3: def
-
4: ghi
-
5: jkl
-
6: mno
-
7: pqrs
-
8: tuv
-
9: wxyz
示例:
-
输入:"23"
-
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
第一步:有限处理(长度1或2)
思路分析
当我们刚开始接触这个问题时,可能会先考虑简单情况。最直观的思路是:根据输入长度分别处理。
-
如果输入长度为1,直接返回该数字对应的所有字母
-
如果输入长度为2,使用两层循环生成所有组合
-
对于长度大于2的情况...暂时忽略或简单处理
代码实现
cpp
#include <string>
#include <vector>
using namespace std;
class Solution {
public:
vector<string> letterCombinations(string digits) {
string str[10] = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
vector<string> ret;
if (digits.empty())
return ret; // 空字符串返回空数组
int len = digits.size();
// 情况1:输入长度为1
if(len == 1)
{
string aa = str[digits[0] - '0'];
for(auto e : aa)
{
ret.push_back(string(1,e));
}
return ret;
}
// 情况2:输入长度为2
if(len == 2)
{
string array1 = str[digits[0] - '0'];
string array2 = str[digits[1] - '0'];
for(auto e1 : array1)
{
for(auto e2 : array2)
{
ret.push_back(string(1, e1) + e2);
}
}
return ret;
}
// 长度大于2的情况没有处理
return ret; // 对于更长的输入,这会返回错误结果
}
};
这种方法的局限性
-
不通用:只能处理特定长度的输入
-
代码重复:每个长度都需要独立的逻辑
-
可维护性差:新增长度需要修改代码
-
边界情况处理困难:比如输入"0"或"1"时
第二步:发现问题,寻找通用模式
仔细观察有限处理的代码,我们可以发现一个模式:
-
对于长度为1的情况:遍历第一个数字的所有字母
-
对于长度为2的情况:遍历第一个数字的所有字母 × 第二个数字的所有字母
实际上,这是一个组合扩展的过程。无论输入多长,我们都可以:
-
从空字符串开始
-
依次处理每个数字
-
将现有结果与当前数字的每个字母组合
这种思路启发我们找到通用解法。
第三步:通用解法(迭代构建)
算法思路
我们可以采用**广度优先搜索(BFS)**的思想,逐位扩展:
-
初始化结果列表,包含一个空字符串
-
遍历输入字符串的每个数字:
-
获取该数字对应的字母集合
-
创建临时列表存储新组合
-
将现有组合与每个字母拼接,生成新组合
-
用新组合更新结果
-
-
返回最终结果
通用代码实现
cpp
#include <string>
#include <vector>
using namespace std;
class Solution {
public:
vector<string> letterCombinations(string digits) {
vector<string> ret;
if (digits.empty()) return ret;
// 数字到字母的映射
string str[10] = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
// 初始化一个空字符串作为起点
ret.push_back("");
// 遍历每个数字
for (char digit : digits) {
string letters = str[digit - '0'];
vector<string> tmp;
// 将现有组合与当前数字的每个字母组合
for (const string& s : ret) {
for (char letter : letters) {
tmp.push_back(s + letter);
}
}
// 更新结果
ret = tmp;
}
return ret;
}
};
逐步解析通用解法
让我们通过具体例子来理解通用解法的执行过程:
输入:"23"
text
初始状态:ret = [""]
处理第一个数字'2'(letters="abc"):
- 将""与"a","b","c"分别组合 → ["a", "b", "c"]
- ret更新为["a", "b", "c"]
处理第二个数字'3'(letters="def"):
- 将"a"与"d","e","f"组合 → ["ad", "ae", "af"]
- 将"b"与"d","e","f"组合 → ["bd", "be", "bf"]
- 将"c"与"d","e","f"组合 → ["cd", "ce", "cf"]
- ret更新为["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
输入:"234"
text
初始状态:ret = [""]
处理'2' → ["a", "b", "c"]
处理'3' → ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
处理'4' → 将9个组合分别与"g","h","i"组合 → 27个结果
第四步:另一种通用解法(递归/回溯)
除了迭代方法,递归也是一个很好的选择。递归解法体现了**深度优先搜索(DFS)**的思想:
递归代码实现
cpp
class Solution {
private:
vector<string> result;
string mapping[10] = {"", "", "abc", "def", "ghi", "jkl",
"mno", "pqrs", "tuv", "wxyz"};
void backtrack(string& digits, int index, string& current) {
// 终止条件:已处理完所有数字
if (index == digits.size()) {
result.push_back(current);
return;
}
// 获取当前数字对应的字母
string letters = mapping[digits[index] - '0'];
// 遍历当前数字的所有字母
for (char letter : letters) {
current.push_back(letter); // 做出选择
backtrack(digits, index + 1, current); // 递归处理下一个数字
current.pop_back(); // 撤销选择
}
}
public:
vector<string> letterCombinations(string digits) {
if (digits.empty()) return {};
string current;
backtrack(digits, 0, current);
return result;
}
};
递归解法分析
递归解法的核心思想:
-
选择:将当前数字的一个字母添加到组合中
-
探索:递归处理下一个数字
-
撤销:回溯到之前的状态,尝试其他字母
这种方法与迭代方法在本质上相同,都是通过组合扩展来生成所有可能。
第五步:两种通用解法的比较
| 方面 | 迭代解法(BFS) | 递归解法(DFS) |
|---|---|---|
| 思路 | 从空字符串开始,逐层扩展 | 深度优先探索所有可能路径 |
| 代码结构 | 循环嵌套 | 递归函数 |
| 空间复杂度 | O(4^n × n) | O(n) 递归栈 + O(4^n × n) 结果 |
| 可读性 | 直观,容易理解过程 | 简洁,体现回溯思想 |
| 适用场景 | 适合迭代思维 | 适合递归思维,易于扩展 |
第六步:优化与扩展
1. 处理边界情况
通用解法已经能够处理各种边界情况:
-
空输入:直接返回空结果
-
包含"0"或"1":对应空字符串,不会产生新组合
-
长输入:自动处理任意长度
2. 性能优化
对于特别长的输入,可以考虑以下优化:
-
提前计算总组合数,预分配内存
-
使用原地修改减少临时对象创建
3. 扩展到其他问题
这种"逐步构建组合"的思路可以应用于许多类似问题:
-
生成所有可能的括号组合
-
子集生成
-
排列组合问题
总结
从只处理长度1或2的有限代码,到通用的迭代和递归解法,我们看到了算法设计思维的演进:
-
从特殊到一般:先解决简单情况,再寻找通用规律
-
模式识别:发现组合扩展的通用模式
-
抽象思维:将具体问题抽象为组合构建过程
-
多种解法:同一问题可以有不同解决思路
通过这个例子,我们不仅学会了解决电话号码字母组合问题,更重要的是掌握了"组合扩展"这一重要算法思想。这种思想在解决许多其他问题时同样有用,是算法学习中的一个重要里程碑。
在实际编程中,我们经常需要从有限的特例开始思考,然后逐步推广到通用情况。这个过程体现了算法设计的精髓:发现问题本质,找到通用规律,实现高效解决。