C++的“决策树”:精通回溯法解决子集和问题
C++的“决策树”:精通回溯法解决子集和问题
在C++编程中,我们经常遇到“组合”问题:从一堆物品中,你能否找到一个组合,恰好满足某个条件?子集和问题 (Subset Sum Problem) 就是其中最经典的一个。
问题描述:
给定一个整数集合(例如:{10, 7, 15, 5})和一个目标总和(例如 22),你需要判断是否存在一个子集(从集合中挑选任意个数)?它们的和恰好等于目标总和?
- 在这个例子中,答案是**“是”**,因为
7 + 15 = 22。(而且10 + 7 + 5 = 22也是一个解)。
如何系统地“搜索”所有可能的组合来找到答案呢?最高效、最优雅的方法之一就是回溯法 (Backtracking)。
一个简单的比喻:“打包裹”
- 你有一个包裹,目标是让它的重量恰好等于
22公斤。 - 你面前有一堆物品,重量分别是
10, 7, 15, 5公斤。 - 你从第一个物品 (10kg) 开始,面临一个“决策”:
- “装入” 10kg:
- 你把 10kg 物品放入包裹(
currentSum = 10)。 - 你继续看下一个物品 (7kg),面临新决策…
- “装入” 7kg:
- 包裹总重
17kg(currentSum = 17)。 - 你继续看下一个物品 (15kg)…
- “装入” 15kg:
- 包裹总重
32kg(currentSum = 32)。 - 超重了! (
currentSum > target)。 - “回溯” (Backtrack)! 你把 15kg 物品拿出来,撤销这个决策。
- 包裹总重
- “不装” 15kg:
- 包裹总重仍是
17kg。 - 你继续看下一个物品 (5kg)… (依此类推)
- 包裹总重仍是
- “装入” 15kg:
- 包裹总重
- “装入” 7kg:
- 你把 10kg 物品放入包裹(
- “不装” 10kg:
- 你跳过 10kg 物品(
currentSum = 0)。 - 你继续看下一个物品 (7kg),面临新决策…
- “装入” 7kg:
- 包裹总重
7kg(currentSum = 7)。 - 你继续看下一个物品 (15kg)…
- “装入” 15kg:
- 包裹总重
22kg(currentSum = 22)。 - 目标达成! (
currentSum == target)。你找到了一个解!
- 包裹总重
- “装入” 15kg:
- 包裹总重
- “装入” 7kg:
- 你跳过 10kg 物品(
- “装入” 10kg:
回溯法就是这样一个系统性地探索“决策树” (装入 vs 不装),并在“走不通”或“超重”时自动“退回”(Backtrack)到上一个路口,尝试另一种选择的算法。
在本教程中,你将学会:
✅什么是回溯法:以及它“决策-探索-回溯”的核心思想。✅回溯函数的“三大支柱”:成功、失败、继续探索(递归)。✅push_back与pop_back:如何使用vector来“装入”和“回溯”(撤销)。✅实战演练:编写一个完整的subsetSum函数。✅“X光透视”:用调试器“亲眼目睹”调用栈是如何“深入”探索和“回溯”撤销的。✅终极挑战:如何修改代码以找出所有可能的子集和,而不仅仅是第一个。
前置知识说明 (100% 自洽):
- 变量 (Variable):理解存储数据的“盒子”,如
int target = 22;。 vector(向量):C++标准库提供的一种“动态数组”(“魔法弹性盒子列表”)。你需要#include <vector>。myVec.push_back(10);// 在末尾添加元素myVec.pop_back();// 移除末尾元素
- 函数 (Function):理解可重复使用的“代码积木”,知道什么是函数调用和函数返回。
- 递归 (Recursion):一个函数调用其自身。这是回溯法的基础。
- 调用栈 (Call Stack):程序用来管理函数调用的内存区域,遵循“后进先出”(LIFO)原则(“叠盘子”的比喻)。
if-else语句:用于“做决策”(“十字路口”)的工具。- 编译 (Compile):C++代码(“食谱”)必须被“编译”(“烘焙”),才能变成电脑可执行的程序(“蛋糕”)。
第一部分:回溯函数的“三大支柱”
为了实现“打包裹”的逻辑,我们需要设计一个递归函数。这个函数需要知道“探险”进行到哪一步了。
它的“签名”(参数)通常是这样的:
bool solve(const vector<int>& items, int target, int index, int currentSum, vector<int>& subset)
items: 物品列表(“固定不变”)。target: 目标重量(“固定不变”)。index: 我们当前正在“决策”第几个物品?currentSum: 包裹当前的总重量。subset: 我们当前已放入包裹的物品列表(需要“引用”&,以便修改)。
这个递归函数必须有“出口”(停止条件)和“前进”的逻辑:
- 支柱 1:基础情况(成功)
- “如果
currentSum恰好等于target?” - 回答: 找到了!停止搜索,打印
subset,返回true。
- “如果
- 支柱 2:基础情况(失败/剪枝)
- “如果
currentSum已经大于target?” (超重了) - 回答: 这条路走不通了(Pruning),回溯,返回
false。 - “如果
index已经越界了?” (物品都看完了) - 回答: 物品看完了还没凑够,这条路也失败了,回溯,返回
false。
- “如果
- 支柱 3:递归步骤(“决策”与“探索”)
- 决策 A:“装入” (
items[index])- 把
items[index]放入subset(subset.push_back())。 - 深入探索:调用自己
solve(..., index + 1, currentSum + items[index], ...)。 - 如果这个探索成功了(返回
true),太好了!直接返回true。 - 如果这个探索失败了(返回
false),说明“装入”items[index]是个错误决定。 - “回溯” (Backtrack)! 必须把
items[index]从subset中拿出来 (subset.pop_back()),**“撤销”**这个决策。
- 把
- 决策 B:“不装” (
items[index])- 深入探索:调用自己
solve(..., index + 1, currentSum, ...)。 - 返回这个探索的结果(
true或false)。
- 深入探索:调用自己
- 决策 A:“装入” (
第二部分:“实战演练”——编写 subsetSum
subset_sum.cpp
#include <iostream>
#include <vector>
using namespace std;
// 辅助函数:打印找到的子集
void printSubset(const vector<int>& subset) {
cout << "找到解: { ";
for (int i = 0; i < subset.size(); ++i) {
cout << subset[i] << (i == subset.size() - 1 ? "" : ", ");
}
cout << " }" << endl;
}
/**
* 回溯法解决子集和问题
* @param items - 物品列表 (例如 {10, 7, 15, 5})
* @param target - 目标总和 (例如 22)
* @param index - 当前正在考虑的物品索引
* @param currentSum - 当前包裹的总和
* @param subset - 当前包裹中的物品 (通过引用传递)
* @return true 如果找到解,否则 false
*/
bool findSubsetSum(const vector<int>& items, int target, int index,
int currentSum, vector<int>& subset)
{
// --- 支柱 1: 基础情况 (成功) ---
if (currentSum == target) {
printSubset(subset);
return true; // 找到一个解,立即停止
}
// --- 支柱 2: 基础情况 (失败/剪枝) ---
// 1. 如果超重了
if (currentSum > target) {
return false; // 这条路失败了,回溯
}
// 2. 如果物品都看完了,还没凑够
if (index == items.size()) {
return false; // 这条路失败了,回溯
}
// --- 支柱 3: 递归步骤 (决策与探索) ---
// 决策 A: “装入” items[index]
subset.push_back(items[index]); // 决策:装入
// 探索...
if (findSubsetSum(items, target, index + 1, currentSum + items[index], subset)) {
return true; // 如果这条路(或其子路)成功了,立刻返回
}
// “行内预警”:如果“装入”的探索失败了,我们必须“撤销”这个决策
subset.pop_back(); // 撤销(回溯)!
// 决策 B: “不装” items[index]
// 探索...
if (findSubsetSum(items, target, index + 1, currentSum, subset)) {
return true; // 如果这条路(或其子路)成功了,立刻返回
}
// 如果“装入”和“不装”两条路都失败了
return false;
}
int main() {
vector<int> items = {10, 7, 15, 5, 3};
int target = 22;
vector<int> currentSubset; // 初始包裹是空的
cout << "--- 寻找子集和 (目标: " << target << ") ---" << endl;
if (!findSubsetSum(items, target, 0, 0, currentSubset)) {
cout << "未找到解。" << endl;
}
return 0;
}
“手把手”终端模拟:
PS C:\MyCode> g++ subset_sum.cpp -o subset_sum.exe
PS C:\MyCode> .\subset_sum.exe
--- 寻找子集和 (目标: 22) ---
找到解: { 10, 7, 5 }
(注意:它可能找到 {10, 7, 5} 或 {7, 15},取决于 vector 的顺序和探索的顺序。这个代码会找到它遇到的第一个解。)
第三部分:“X光透视”——亲眼目睹“回溯” (pop_back)
回溯法中最难理解的就是“撤销”(pop_back)这一步。让我们用“X光眼镜”(调试器)来观察它。
“X光”实战(基于 subset_sum.cpp)
-
设置断点:
- 在第39行(
subset.push_back(items[index]);)设置断点。 - 在第45行(
subset.pop_back();)设置关键断点。 - 在第15行(
printSubset(subset);)设置成功断点。
- 在第39行(
-
启动“子弹时间”(F5):
- 程序会在
main调用findSubsetSum时,停在第39行。 - 观察“变量”窗口:
index: 0,currentSum: 0,subset: {}(空的)
- 程序会在
-
按下
F11键(“Step Into”,步入) 执行push_back(10)。 -
按下
F11键(步入递归调用)。 -
循环 1 (深入):
- 程序再次停在第39行。
- 观察“变量”窗口:
index: 1(物品7),currentSum: 10,subset: {10} - 按
F11,F11…
-
循环 2 (深入):
- 程序再次停在第39行。
- 观察“变量”窗口:
index: 2(物品15),currentSum: 17,subset: {10, 7} - 按
F11,F11…
-
循环 3 (深入):
- 程序再次停在第39行。
- 观察“变量”窗口:
index: 3(物品5),currentSum: 32(10+7+15),subset: {10, 7, 15} - “行内预警”:
currentSum (32)大于target (22)! - 按
F11… 函数会进入第26行(if (currentSum > target)),返回false。 - “多米诺骨牌”倒下…
-
“回溯”时刻!
- 你会看到: 程序返回到
findSubsetSum(index=2, sum=17) 的第42行。 if (findSubsetSum(...))的结果是false。- 按下
F10键(“Step Over”)。 - 你会看到: 程序停在了第45行(
subset.pop_back();)!
- 你会看到: 程序返回到
-
开启“X光”(观察
pop_back):- 观察“变量”窗口(
pop_back执行 前):subset: {10, 7, 15}
- 按下
F10键(执行pop_back)。 - 观察“变量”窗口(
pop_back执行 后):subset: {10, 7}
- 顿悟时刻: 你亲眼见证了“回溯”!程序撤销了“装入15”这个错误决策。
- 观察“变量”窗口(
-
继续探索 (F10)…
- 程序现在会执行第48行(“不装”15的决策),并调用
findSubsetSum(..., index=3, currentSum=17, ...)。 - …这个过程会一直持续,直到最终
index=3(物品5)的“装入”分支 (17+5=22) 击中了第15行的“成功”断点! - “X光”开启 (成功时):
currentSum: 22subset: {10, 7, 5}
- 程序现在会执行第48行(“不装”15的决策),并调用
动手试试!(终极挑战:你的“全解查找器”)
在我们的“实战演练”中,函数在找到第一个解 ({10, 7, 5}) 后就立刻 return true; 停止了。
但我们知道,{7, 15, ...} 也是一个可能的路径(7+15=22)。
任务:
修改 findSubsetSum 函数,使其不再找到一个解就立即停止,而是继续搜索,直到遍历完所有可能的“决策树”分支,并打印出所有可能的解。
提示:
- 当
currentSum == target时,你仍然需要打印子集。 - 但是,打印之后,不要
return true;! - 如果你
return true,上层函数就会停止探索“不装”那条路。 - 你应该怎么做?(提示:
return false;?或者干脆return;并修改函数返回类型为void?)
findall_subsets.cpp (你的 TODO):
#include <iostream>
#include <vector>
using namespace std;
// ... (printSubset 函数和上面一样) ...
// --- TODO 1: 修改函数签名 (也许返回 void?) ---
void findAllSubsets(const vector<int>& items, int target, int index,
int currentSum, vector<int>& subset)
{
// --- 支柱 2: 失败/剪枝 (这个不变) ---
if (currentSum > target) {
return; // 回溯
}
if (index == items.size()) {
// --- TODO 2: 在这里检查成功 ---
// 物品看完了,检查 *此时* currentSum 是否等于 target
// if (currentSum == target) {
// printSubset(subset);
// }
return; // 回溯
}
// “行内预警”:上面 TODO 2 的写法只能找到“用完所有物品”的解。
// GFG 的写法(在进入时检查成功)更好。
// --- 让我们用回 GFG 的结构 ---
// if (currentSum == target) {
// printSubset(subset);
// // --- TODO 3: *不要* 在这里返回 true! ---
// // 我们要继续搜索,所以假装“失败”以便回溯
// return; // (如果返回 void)
// // 或者 return false; (如果返回 bool)
// }
// (如果用 GFG 的结构,`index == items.size()` 检查必须放在最前面)
// --- 让我们重构 TODO (推荐的“全解”结构) ---
if (index == items.size()) {
if (currentSum == target) {
printSubset(subset); // 到底了,并且成功
}
return; // 到底了,回溯
}
// --- 支柱 3: 递归步骤 (决策与探索) ---
// 决策 A: “装入” items[index]
subset.push_back(items[index]);
findAllSubsets(items, target, index + 1, currentSum + items[index], subset);
subset.pop_back(); // “撤销”(回溯) -- *必须* 撤销,才能探索 B
// 决策 B: “不装” items[index]
findAllSubsets(items, target, index + 1, currentSum, subset);
}
int main() {
vector<int> items = {10, 7, 15, 5, 3};
int target = 22;
vector<int> currentSubset;
cout << "--- 寻找 *所有* 子集和 (目标: " << target << ") ---" << endl;
// findAllSubsets(items, target, 0, 0, currentSubset);
// (需要你填完上面的 TODO)
return 0;
}
这个挑战让你从“找到一个解”升级到“找到所有解”,这是对回溯法“撤销” (
pop_back) 机制的终极考验。欢迎在评论区分享你的代码,看看你能找到多少个解!
更多推荐


所有评论(0)