在之前的文章中,我们讨论了"全排列"和"子集"的区别。今天我们把难度稍微提升一点,看看在更复杂的场景下,如何灵活运用 "答案视角" 和 "输入视角" 来解题。
我们将通过两道 LeetCode 经典题目:电话号码的字母组合 (LeetCode 17)和 组合总和(LeetCode 39),来深刻体会这两种思维模式的差异。
一、 答案视角的经典:电话号码的字母组合
题目:给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
1. 视角选择:为什么是"答案视角"?
这道题是非常典型的"填坑"问题。
-
假设输入是 "23",那么答案的长度一定是 2。
-
我们手里有两个坑位:
[坑1, 坑2]。 -
坑1必须从数字 '2' 对应的 "abc" 里选一个。 -
坑2必须从数字 '3' 对应的 "def" 里选一个。
因为每个坑位的候选集是固定且相互独立 的(第1个坑选什么不影响第2个坑),所以我们采用 答案视角 ,即递归函数中的索引 i 代表**"当前正在填第几个坑"**。
2. 代码实现
这里通过直接覆盖 vals[i] 的方式,比传统的 push_back / pop_back 更加简洁,因为长度是固定的。(只要长度是固定的,就可以考虑用覆盖去替代恢复现场)。
C++代码实现:
cpp
class Solution {
// 答案视角
// 思路: 先把所有的这个电话数字对应的字母映射到好, 遍历每一个组(也就是abc)拿到了a就去堆第二个位置的def去遍历,因此可以用dfs来结果
string MAPPING[10] = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
vector<string> ans;
void dfs(string digits, string vals, int i, int n) {
// i 代表当前填到了第几个位置
if (i == n) {
ans.push_back(vals);
return;
}
// 这里的循环是在遍历当前这个坑位 i 有哪些字母可以选择
for (char c : MAPPING[digits[i] - '0']) {
vals[i] = c; // 直接覆盖,相当于填空
dfs(digits, vals, i + 1, n); // 填下一个空
// 注意:因为是直接覆盖 vals[i],下一次循环会再次覆盖,
// 所以这里不需要显式的"撤销"操作 (pop_back),这是固定长度填空的技巧
}
}
public:
vector<string> letterCombinations(string digits) {
int n = digits.size();
if (n == 0) return {};
// 预先分配好 vals 的长度,全部初始化为 0
string vals(n, 0);
dfs(digits, vals, 0, n);
return ans;
}
};
3. 时空复杂度分析
-
时间复杂度:O(4^N * N)
-
其中 N 是输入数字字符串的长度。
-
在最坏情况下(比如输入全都是 7 或 9,对应 4 个字母),每个坑位有 4 种选择,总组合数为 4^N。
-
递归树的叶子节点个数为 4^N。
-
在每个叶子节点,我们将
vals复制到ans中,字符串复制耗时 O(N)。 -
总耗时:节点数 * 单节点操作 = O(4^N * N)。
-
-
空间复杂度:O(N)
-
主要消耗在递归调用栈上,深度为 N。
-
vals字符串的空间也是 O(N)。 -
(输出答案的 O(4^N * N) 空间通常不计入算法辅助空间)。
-
二、 输入视角的变体:组合总和(可重复选)
题目 :给你一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target 的所有不同组合。数字可以无限制重复被选取。
1. 视角选择:为什么是"输入视角"?
这道题的难点在于**"无限制重复选取"**。
如果用答案视角(填坑),我们不知道坑有多少个(比如 target=10, candidates=[2],答案是 [2,2,2,2,2],有5个坑),这就很难定义循环的边界。
因此,我们切换到 输入视角(选或不选),逻辑会变得异常清晰:
对于当前数字 candidates[i],我有两种选择:
-
选它 :而且选了之后,下一次还可以继续选它(不换人)。
-
不选它 :彻底放弃这个数字,以后也不能再回头选它(换下一个人)。
2. 代码实现
C++代码实现:
cpp
class Solution {
// 思路: 看到目标taget,肯定是要让他-去值后得到0就是答案,因此用输入视角更合适,采用选或不选的策略
vector<vector<int>> ans;
void dfs(vector<int>& candidates, vector<int>& vals, int target, int i, int n) {
// 成功出口:正好减到 0
if (target == 0) {
ans.push_back(vals);
return;
}
// 失败出口:考察完所有数字,或者 target 减超了
if (i == n || target < 0) {
return;
}
// --- 核心逻辑:输入视角 ---
// 选择 1:选当前数字 candidates[i]
vals.push_back(candidates[i]);
// 关键点:参数依然是 i,而不是 i+1
// 这表示:虽然我选了你,但我还没考察完你,下一层递归还可以接着选你(实现无限重复)
dfs(candidates, vals, target - candidates[i], i, n);
vals.pop_back(); // 回溯
// 选择 2:不选当前数字(或者选够了,不想再选了)
// 参数变成 i + 1,表示彻底跳过 candidates[i],以后再也不看它了
dfs(candidates, vals, target, i + 1, n);
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<int> vals;
int n = candidates.size();
if (n == 0) return {};
dfs(candidates, vals, target, 0, n);
return ans;
}
};
3. 时空复杂度分析
这里的复杂度分析比普通组合要复杂,因为树的深度不固定。
-
时间复杂度:O(S) (粗略估计)(可行解的每一个长度,代表了它找路一次,所以是长度和),举例如下:
cpp假如 target = 5,candidates = [1, 2, 5]。找到的答案 ans 如下: [1, 1, 1, 1, 1] (长度 5) [1, 1, 1, 2] (长度 4) [1, 2, 2] (长度 3) [5] (长度 1) 那么 S = 5 + 4 + 3 + 1 = 13。-
严格来说是指数级的。最坏情况取决于 target 和数组中最小元素的值。
-
递归树的最大深度(Height)大约是
target / min(candidates)。 -
因为每个节点分两个叉(选或不选),所以节点总数大概是 2 的 Height 次方级别。
-
准确来说,时间复杂度通常表示为 O(S),其中 S 是所有可行解的总长度。因为我们需要把每个解都构造出来。
-
-
空间复杂度:O(target / min(candidates))
-
空间主要取决于递归栈的深度。
-
在最坏情况下(比如 target=100,最小元素是 1),我们可能需要递归 100 层(一直选 1)。
-
所以空间复杂度是 O(target / min_val)。
-
三、 总结与对比
通过这两道题,我们可以总结出回溯算法对索引 i 的不同处理方式:
| 题目类型 | 视角 | i 的含义 | 下一层递归传参 |
|---|---|---|---|
| 电话号码组合 | 答案视角 | 当前正在填第几个坑 | i + 1 (去填下一个坑) |
| 组合总和 | 输入视角 | 当前正在考察第几个候选人 | 选:传 i (还能接着选) 不选:传 i + 1 (跳过看下一个) |
-
什么时候用"选"与"不选"?
当题目允许重复选择同一个元素,或者无法确定答案的长度时,使用输入视角(选或不选)配合参数控制(传 i 还是 i+1)通常是逻辑最清晰的解法。
-
什么时候用"For 循环填坑"?
当题目明确了答案的结构固定(比如每个位置必须对应一个映射,或者全排列)时,使用答案视角配合 For 循环效率更高。