C++的“决策树”:精通回溯法解决子集和问题

在C++编程中,我们经常遇到“组合”问题:从一堆物品中,你能否找到一个组合,恰好满足某个条件?子集和问题 (Subset Sum Problem) 就是其中最经典的一个。

问题描述:
给定一个整数集合(例如:{10, 7, 15, 5})和一个目标总和(例如 22),你需要判断是否存在一个子集(从集合中挑选任意个数)?它们的和恰好等于目标总和?

  • 在这个例子中,答案是**“是”**,因为 7 + 15 = 22。(而且 10 + 7 + 5 = 22 也是一个解)。

如何系统地“搜索”所有可能的组合来找到答案呢?最高效、最优雅的方法之一就是回溯法 (Backtracking)

一个简单的比喻:“打包裹”

  • 你有一个包裹,目标是让它的重量恰好等于 22 公斤。
  • 你面前有一堆物品,重量分别是 10, 7, 15, 5 公斤。
  • 你从第一个物品 (10kg) 开始,面临一个“决策”:
    1. “装入” 10kg:
      • 你把 10kg 物品放入包裹(currentSum = 10)。
      • 你继续看下一个物品 (7kg),面临新决策…
        • “装入” 7kg:
          • 包裹总重 17kg (currentSum = 17)。
          • 你继续看下一个物品 (15kg)
            • “装入” 15kg:
              • 包裹总重 32kg (currentSum = 32)。
              • 超重了! (currentSum > target)。
              • “回溯” (Backtrack)! 你把 15kg 物品拿出来,撤销这个决策。
            • “不装” 15kg:
              • 包裹总重仍是 17kg
              • 你继续看下一个物品 (5kg)… (依此类推)
    2. “不装” 10kg:
      • 你跳过 10kg 物品(currentSum = 0)。
      • 你继续看下一个物品 (7kg),面临新决策…
        • “装入” 7kg:
          • 包裹总重 7kg (currentSum = 7)。
          • 你继续看下一个物品 (15kg)
            • “装入” 15kg:
              • 包裹总重 22kg (currentSum = 22)。
              • 目标达成! (currentSum == target)。你找到了一个解!

回溯法就是这样一个系统性地探索“决策树” (装入 vs 不装),并在“走不通”或“超重”时自动“退回”(Backtrack)到上一个路口,尝试另一种选择的算法。

在本教程中,你将学会:

  • 什么是回溯法:以及它“决策-探索-回溯”的核心思想。
  • 回溯函数的“三大支柱”:成功、失败、继续探索(递归)。
  • push_backpop_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])
      1. items[index] 放入 subset (subset.push_back())。
      2. 深入探索:调用自己 solve(..., index + 1, currentSum + items[index], ...)
      3. 如果这个探索成功了(返回 true),太好了!直接返回 true
      4. 如果这个探索失败了(返回 false),说明“装入” items[index] 是个错误决定。
      5. “回溯” (Backtrack)! 必须把 items[index]subset拿出来 (subset.pop_back()),**“撤销”**这个决策。
    • 决策 B:“不装” (items[index])
      1. 深入探索:调用自己 solve(..., index + 1, currentSum, ...)
      2. 返回这个探索的结果(truefalse)。

第二部分:“实战演练”——编写 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
  1. 设置断点:

    • 第39行subset.push_back(items[index]);)设置断点。
    • 第45行subset.pop_back();)设置关键断点
    • 第15行printSubset(subset);)设置成功断点。
  2. 启动“子弹时间”(F5):

    • 程序会在 main 调用 findSubsetSum 时,停在第39行
    • 观察“变量”窗口: index: 0, currentSum: 0, subset: {} (空的)
  3. 按下 F11 键(“Step Into”,步入) 执行 push_back(10)

  4. 按下 F11(步入递归调用)。

  5. 循环 1 (深入):

    • 程序再次停在第39行
    • 观察“变量”窗口: index: 1 (物品7), currentSum: 10, subset: {10}
    • F11, F11
  6. 循环 2 (深入):

    • 程序再次停在第39行
    • 观察“变量”窗口: index: 2 (物品15), currentSum: 17, subset: {10, 7}
    • F11, F11
  7. 循环 3 (深入):

    • 程序再次停在第39行
    • 观察“变量”窗口: index: 3 (物品5), currentSum: 32 (10+7+15), subset: {10, 7, 15}
    • “行内预警”: currentSum (32) 大于 target (22)
    • F11… 函数会进入第26行if (currentSum > target)),返回 false
    • “多米诺骨牌”倒下…
  8. “回溯”时刻!

    • 你会看到: 程序返回findSubsetSum (index=2, sum=17) 的第42行
    • if (findSubsetSum(...)) 的结果是 false
    • 按下 F10 键(“Step Over”)。
    • 你会看到: 程序停在了第45行(subset.pop_back();)!
  9. 开启“X光”(观察 pop_back):

    • 观察“变量”窗口(pop_back 执行 ):
      • subset: {10, 7, 15}
    • 按下 F10 键(执行 pop_back)。
    • 观察“变量”窗口(pop_back 执行 ):
      • subset: {10, 7}
    • 顿悟时刻: 你亲眼见证了“回溯”!程序撤销了“装入15”这个错误决策。
  10. 继续探索 (F10)…

    • 程序现在会执行第48行(“不装”15的决策),并调用 findSubsetSum(..., index=3, currentSum=17, ...)
    • …这个过程会一直持续,直到最终 index=3(物品5)的“装入”分支 (17+5=22) 击中了第15行的“成功”断点!
    • “X光”开启 (成功时):
      • currentSum: 22
      • subset: {10, 7, 5}

动手试试!(终极挑战:你的“全解查找器”)

在我们的“实战演练”中,函数在找到第一个解 ({10, 7, 5}) 后就立刻 return true; 停止了。
但我们知道,{7, 15, ...} 也是一个可能的路径(7+15=22)。

任务:
修改 findSubsetSum 函数,使其不再找到一个解就立即停止,而是继续搜索,直到遍历完所有可能的“决策树”分支,并打印出所有可能的解。

提示:

  1. currentSum == target 时,你仍然需要打印子集。
  2. 但是,打印之后,不要 return true;
  3. 如果你 return true,上层函数就会停止探索“不装”那条路。
  4. 你应该怎么做?(提示: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) 机制的终极考验。欢迎在评论区分享你的代码,看看你能找到多少个解!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐