77. 组合
给定两个整数
n和k,返回范围[1, n]中所有可能的k个数的组合。你可以按 任何顺序 返回答案。
cpp
class Solution {
public:
vector<int> path; // 单层路径,存储当前组合
vector<vector<int>> ans; // 结果集,存储所有符合条件的组合
// 回溯函数
// n: 总数
// k: 需要选取的个数
// startIndex: 当前搜索的起始位置(防止重复选取,保证组合有序)
void backtracking(int n, int k, int startIndex) {
// 1. 终止条件:当前组合长度等于 k,收集结果
if (path.size() == k) {
ans.push_back(path);
return;
}
// 2. 单层搜索逻辑
// 剪枝优化:i <= n - (k - path.size()) + 1
// 解释:还需要选取 (k - path.size()) 个元素
// 如果 i 超过了这个范围,剩下的元素就不够凑齐 k 个了
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:下一层从 i+1 开始
path.pop_back(); // 回溯:撤销处理结果
}
}
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return ans;
}
};
总结
1. 回溯三部曲
- 确定返回值和参数:返回值通常为
void,参数根据题目需求定(这里是范围n、目标数量k、起始位置startIndex)。 - 确定终止条件:当路径长度满足要求(
path.size() == k)时,保存结果并返回。 - 确定单层搜索逻辑:
for循环横向遍历(从startIndex到n)。backtracking递归纵向遍历。- 核心:
path.push_back和path.pop_back构成了回溯的"做选择"与"撤销选择"。
2. 剪枝优化
代码中 i <= n - (k - path.size()) + 1 是一个极其重要的优化。
- 场景:假设
n=4, k=3,当前path为空。 - 无剪枝:
i会遍历 1 到 4。当i=4时,path={4},递归下一层发现没有元素可选了,最终只能得到一个长度为 1 的无效路径。 - 有剪枝:
- 还需要选
k - path.size() = 3个数。 - 为了至少能有 3 个数,起始位置
i最大只能是4 - 3 + 1 = 2。 - 所以
i只需遍历 1 和 2,直接跳过了 3 和 4,大幅减少了无效递归。
- 还需要选
3. 复杂度分析
- 时间复杂度:O(C(n, k) * k)。
- 共有 C(n, k) 个组合,每个组合需要 O(k) 的时间存入结果集。
- 空间复杂度:O(k)。
- 递归深度为 k,path 最大长度也为 k(不考虑结果集占用的空间)。
216. 组合总和 III
找出所有相加之和为
n的k个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
cpp
class Solution {
public:
vector<int> path; // 单层路径
vector<vector<int>> ans; // 结果集
// 回溯函数
// k: 目标个数
// n: 目标和
// startIndex: 起始位置
// sum: 当前路径累加和
void backtracking(int k, int n, int startIndex, int sum) {
// 1. 终止条件:已选够 k 个数
if (path.size() == k) {
if (sum == n) ans.push_back(path); // 满足和的条件才收集
return;
}
// 2. 单层搜索逻辑
// 剪枝优化1:i <= 9 - (k - path.size()) + 1
// 保证剩余数字足够凑齐 k 个,避免无效递归
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
// 剪枝优化2:如果当前和已超标,直接返回
if (sum + i > n) return;
path.push_back(i); // 处理节点
backtracking(k, n, i + 1, sum + i); // 递归
path.pop_back(); // 回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return ans;
}
};
总结
1. 本题与上一题的区别
这道题是 216. 组合总和 III,可以看作是 77. 组合 的变体:
- 组合问题:依然是从集合中选 k 个数(这里是 1-9)。
- 增加约束:不仅要选够 k 个,还要满足
和 == n。 - 参数变化:回溯函数多了一个
sum参数,用于实时记录当前路径的和,避免了每次遍历数组求和的开销。
2. 剪枝优化(双重剪枝)
-
总和剪枝 (
sum + i > n):- 因为数字是正数且递增,如果加上当前的
i已经爆了,后面的i+1, i+2...更大,肯定也会爆,所以直接return结束当前层循环。
- 因为数字是正数且递增,如果加上当前的
-
数量剪枝 :
- 如果剩下的数字不够凑齐
k个,就没必要遍历了,可以优化为i <= 9 - (k - path.size()) + 1。
- 如果剩下的数字不够凑齐
3. 为什么要传递 sum?
在递归参数中直接维护 sum 是回溯常用的技巧:
- 对比:如果在终止条件里写
if (path.size() == k && accumulate(path.begin(), path.end(), 0) == n),每次都要遍历 path 计算总和,时间复杂度较高。 - 优化:通过传参
sum + i,我们在 O(1) 时间内完成了和的更新与判断。
4. 复杂度分析
- 时间复杂度:O(C(9, k) * k)。
- 最多有 C(9, k) 种组合,每个组合需要 O(k) 时间复制到结果。
- 空间复杂度:O(k)。
- 递归深度和 path 长度均为 k。
17. 电话号码的字母组合
给定一个仅包含数字
2-9的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
cpp
class Solution {
public:
// 数字到字母的映射表,索引 0 和 1 为空
vector<string> mp {"", "", "abc", "def", "ghi", "jkl",
"mno", "pqrs", "tuv", "wxyz"};
string path; // 当前拼接的字符串路径
vector<string> ans; // 结果集
// 回溯函数
// digits: 输入的数字字符串
// index: 当前处理到 digits 的第几个数字(树的深度)
void backtracking(string digits, int index) {
// 1. 终止条件:路径长度等于数字串长度,说明处理完了所有数字
if (path.size() == digits.size()) {
ans.push_back(path);
return;
}
// 边界检查:防止越界(虽然上面的判断已经隐含了这个逻辑,作为一个好习惯保留)
if (index >= digits.size()) return;
// 2. 单层搜索逻辑
// 获取当前数字对应的字母字符串
string s = mp[digits[index] - '0'];
// 遍历当前数字对应的所有字母
for (char c : s) {
path.push_back(c); // 处理节点
backtracking(digits, index + 1);// 递归:处理下一个数字
path.pop_back(); // 回溯:撤销处理结果
}
}
vector<string> letterCombinations(string digits) {
// 特殊情况处理:如果输入为空,直接返回空结果
if (digits.empty()) return ans;
backtracking(digits, 0);
return ans;
}
};
总结
1. 树形结构的理解
- 树的宽度:由当前数字对应的字母个数决定(例如 "2" 对应 "abc",宽度为 3)。
- 树的深度:由输入数字字符串
digits的长度决定。 - 区别:之前的"组合问题"是通过
startIndex控制不再选取之前的元素(纵向遍历),而本题是两个不同集合之间的组合(横向遍历),每一层代表一个数字位,所以用index来控制层级即可。
2. 细节处理
- 字符转数字:
digits[index] - '0'是经典写法,将字符型数字转为整型索引。
3. 复杂度分析
- 时间复杂度:O(3^m * 4^n)。
- 其中 m 是对应 3 个字母的数字个数,n 是对应 4 个字母的数字个数(7 和 9)。
- 因为每个数字对应的字母个数不同,这是一个指数级的复杂度。
- 空间复杂度:O(m + n)。
- 主要是递归调用的栈空间消耗。