00:00:00
回溯算法小记
求解数组的所有子集是一个经典的回溯问题,常见的解法有两种:基于“横向遍历”的写法和基于“选与不选”的写法。下面我们将对这两种写法进行详细分析,并比较它们的异同。
🔍 代码逻辑分析
代码片段 1:基于“横向遍历”的写法
这种写法通常出现在“回溯算法模板”中,强调在每一层递归中通过 for 循环去尝试选择列表中的元素。
cpp
class Solution {
public:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param S int整型vector
* @return int整型vector<vector<>>
*/
vector<int>path;
vector<vector<int>>result;
static bool cmp(const vector<int>& a, const vector<int>& b) {
if (a.size() == b.size()) {
return a < b;
} else {
return a.size() < b.size();
}
}
void backtracking(vector<int>& S, int startIndex) {
result.emplace_back(path);
if (startIndex == S.size()) {
return;
}
for (int i = startIndex; i < S.size(); i++) {
path.emplace_back(S[i]);
backtracking(S, i + 1);
path.pop_back();
}
}
vector<vector<int> > subsets(vector<int>& S) {
// write code here
backtracking(S, 0);
sort(result.begin(), result.end(), cmp);
return result;
}
};- 核心思想:这棵树是一个多叉树。在每一层,我们决定“下一个元素选谁”。
- 特点:
result.emplace_back(path)放在递归终止条件之前。这意味着,只要进入这一层,当前的path就是一个合法的子集(无论是空集还是部分集),都会被记录。
代码片段 2:基于“选与不选”的写法
这种写法更贴近二叉树的逻辑,对于数组中的每一个元素,我们只有两个选择:要它,或者不要它。
cpp
class Solution {
public:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param S int整型vector
* @return int整型vector<vector<>>
*/
vector<int>path;
vector<vector<int>>result;
static bool cmp(const vector<int>& a, const vector<int>& b) {
if (a.size() == b.size()) {
return a < b;
} else {
return a.size() < b.size();
}
}
void backtracking(vector<int>& S, int startIndex) {
if (startIndex == S.size()) {
result.emplace_back(path);
return;
}
// 不选当前元素
backtracking(S, startIndex + 1);
// 选当前元素
path.push_back(S[startIndex]);
backtracking(S, startIndex + 1);
path.pop_back(); // 回溯
}
vector<vector<int> > subsets(vector<int>& S) {
// write code here
backtracking(S, 0);
sort(result.begin(), result.end(), cmp);
return result;
}
};- 核心思想:这棵树是一个二叉树(高度为
)。左分支代表“不选”,右分支代表“选”。 - 特点:
result.emplace_back(path)放在终止条件里。只有当我们对数组中所有元素都做出了“选”或“不选”的决定(即到达叶子节点)时,才记录结果。
📊 对比分析
功能等价性
假设输入数组
代码 1 的执行流:
- 初始调用,
path=[],加入结果。循环选 1。 - 递归,
path=[1],加入结果。循环选 2。 - 递归,
path=[1, 2],加入结果。 - 回溯... 最终结果集包含:
[], [1], [1, 2], [2]。
- 初始调用,
代码 2 的执行流:
- 针对 1:先走“不选”分支,再走“选”分支。
- 针对 2:在上面的基础上,分别再走“不选”和“选”。
- 到达叶子节点记录。最终结果集包含:
[], [2], [1], [1, 2]。
结论:两者生成的子集内容完全一致,只是生成的顺序可能略有不同(取决于具体的遍历顺序),但作为集合的集合,它们是相等的。
复杂度分析
- 时间复杂度:两者都是
。 - 都需要遍历
种状态。 - 每次构造子集(拷贝
path到result)需要的时间。
- 都需要遍历
- 空间复杂度:两者都是
。 - 递归栈的深度最大为
。 path变量占用的空间最大为。
- 递归栈的深度最大为
适用场景差异
- 代码 1 (for 循环版):
- 通用性强:这是解决组合、切割、子集问题的标准模板。
- 易于扩展:如果题目变成“求所有长度为 K 的子集”,只需要在函数开头加一个
if (path.size() == K)的判断即可,或者修改for循环的边界。
- 代码 2 (选/不选版):
- 逻辑直观:对于“0/1 背包问题”或者简单的“每个物品选不选”的问题,这种逻辑非常清晰。
- 局限性:如果问题变成“每个元素可以选多次”或者“从 N 个数中选 K 个”,这种写法的修改不如代码 1 方便。
📌 总结
这两段代码功能一样,都是求数组的所有子集。
- 如果你在做回溯算法的通用练习(如 LeetCode 78. 子集),代码 1 是更标准的写法,因为它符合回溯算法“遍历决策树”的通用模型。
- 如果你在处理特定逻辑(如每个位置只有两种状态),代码 2 的可读性可能更高。
