当前位置: 首页 > news >正文

今日算法(组合问题III)(回溯的使用)

题目描述

找出所有相加之和为nk个数的组合,且满足下列条件:

  • 只使用数字 1 到 9
  • 每个数字最多使用一次
  • 返回所有可能的有效组合的列表,列表不能包含相同的组合两次,组合可以以任何顺序返回

核心思路:带双重剪枝的回溯法

这道题是LeetCode 77. 组合的进阶版,本质还是组合枚举问题,只是在 "选 k 个数" 的基础上,增加了 "和为 n" 的约束条件。

回溯三要素

  1. 路径:用path保存当前正在构建的组合,sum保存当前组合的和
  2. 选择列表:从start位置开始选择数字(保证组合递增,避免重复)
  3. 终止条件
    • path.size() == ksum == n:找到有效组合,加入结果集
    • path.size() == ksum != n:无效组合,直接返回

双重剪枝优化(必加,大幅提升效率)

  1. 和剪枝:如果当前和sum + i > n,后面的数字都比i大,加起来肯定超过n,直接终止循环
  2. 数量剪枝:剩余需要选need = k - path.size()个数字,因此i最大只能到9 - need + 1,否则后面不够选need个数字

完整代码实现(C++)

cpp

class Solution { public: vector<vector<int>> combinationSum3(int k, int n) { vector<vector<int>> res; // 存储所有结果 vector<int> path; // 存储当前正在构建的组合 backtrack(k, n, 1, 0, path, res); return res; } private: // start: 本轮可以选择的起始位置 // sum: 当前组合的和 void backtrack(int k, int n, int start, int sum, vector<int>& path, vector<vector<int>>& res) { // 1. 递归终止条件:选够了k个数 if (path.size() == k) { if (sum == n) { res.push_back(path); // 和为n,加入结果集 } return; } // 2. 双重剪枝优化 // 剪枝1:数量剪枝,剩余数字不够选need个 int need = k - path.size(); // 剪枝2:和剪枝,当前和+i超过n,后面更大的数字不用看了 for (int i = start; i <= 9 - need + 1 && sum + i <= n; ++i) { // 3. 选择当前数字 path.push_back(i); // 4. 递归:下一轮从i+1开始选择(避免重复) backtrack(k, n, i + 1, sum + i, path, res); // 5. 回溯:撤销选择,尝试下一个数字 path.pop_back(); } } };

详细执行流程解析

以示例 2k=3, n=9为例,模拟回溯过程:

初始状态

  • res = []path = []start = 1sum = 0

递归过程

  1. 第一层递归(start=1,path=[],sum=0)
    • need = 3,循环i179-3+1=7
    • i=1
      • path = [1]sum = 1,进入第二层递归,start=2
      • 第二层need = 2,循环i28
        • i=2
          • path = [1,2]sum = 3,进入第三层递归,start=3
          • 第三层need = 1,循环i39
            • i=3sum+3=6 < 9path=[1,2,3],长度 3,和 6≠9,返回
            • i=4sum+4=7 < 9path=[1,2,4],和 7≠9,返回
            • i=5sum+5=8 < 9path=[1,2,5],和 8≠9,返回
            • i=6sum+6=9 == 9path=[1,2,6],加入res,返回
          • 回溯,path=[1,2]
        • i=3
          • path = [1,3]sum = 4,进入第三层递归,start=4
          • i=5sum+5=9 == 9path=[1,3,5],加入res
        • i=4
          • path = [1,4]sum = 5,进入第三层递归,start=5
          • 所有i加起来都超过 9,无结果
      • 回溯,path=[1]
    • i=2
      • path = [2]sum = 2,进入第二层递归,start=3
      • i=3
        • path = [2,3]sum = 5,进入第三层递归,start=4
        • i=4sum+4=9 == 9path=[2,3,4],加入res
    • 后续i均无有效组合

最终结果

res = [[1,2,6],[1,3,5],[2,3,4]],与示例输出完全一致。


关键细节与易错点

  1. start参数的作用每次递归从start位置开始选择,保证组合中的元素是递增的,避免出现[2,1,3]这种重复组合。

  2. 双重剪枝的必要性

    • 数量剪枝:避免无效的循环,例如当还需要选 3 个数字时,i最大只能到 7,因为 8 和 9 只能选 2 个,不够 3 个
    • 和剪枝:提前终止不可能满足和条件的分支,大幅减少递归次数
  3. sum的传递方式这里用值传递sum,每次递归传递sum + i,不需要在回溯时手动减i,代码更简洁;如果用引用传递,需要在path.pop_back()后加上sum -= i

  4. 与 LeetCode 77. 组合的对比

    • 77 题:只需要选 k 个数,没有和的约束
    • 216 题:在 77 题的基础上,增加了和为 n 的约束,以及对应的和剪枝

复杂度分析

  • 时间复杂度:\(O(C(9, k) \times k)\)
    • 最多有 \(C(9, k)\) 个有效组合
    • 每个组合需要复制一次到结果集,时间复杂度为 \(O(k)\)
  • 空间复杂度:\(O(k)\)
    • 递归调用栈的深度等于k,临时路径path的最大长度也为k,不包含结果集的额外空间。

总结

组合总和 III 是组合问题的经典进阶题,核心解法是带双重剪枝的回溯法。通过start参数保证组合不重复,通过数量剪枝和和剪枝大幅优化效率。这道题的思路可以推广到所有组合枚举类问题,是掌握回溯法的必练题目。


迭代法核心思想

递归回溯的本质是深度优先搜索(DFS),而 DFS 可以用来模拟实现。我们把递归过程中每一层的状态(起始位置、当前和、当前路径)都保存在栈中,通过不断弹出栈顶元素、压入新状态的方式,模拟递归的入栈和出栈过程。

栈中需要保存的状态

  • start:本轮可以选择的起始数字(保证组合递增,避免重复)
  • sum:当前组合的和
  • path:当前正在构建的组合

迭代法的剪枝优化

和递归法完全一致,我们同样可以加入双重剪枝,大幅减少无效的状态入栈:

  1. 数量剪枝:剩余需要选need = k - path.size() - 1个数字,因此i最大只能到9 - need + 1
  2. 和剪枝:如果sum + i > n,后面的数字都比i大,加起来肯定超过n,直接终止循环

完整迭代法代码实现(C++)

cpp

class Solution { public: vector<vector<int>> combinationSum3(int k, int n) { vector<vector<int>> res; // 栈中保存:(起始位置start, 当前和sum, 当前路径path) stack<tuple<int, int, vector<int>>> stk; // 初始状态:从1开始选,和为0,路径为空 stk.emplace(1, 0, vector<int>()); while (!stk.empty()) { // 弹出栈顶元素(模拟递归返回) auto [start, sum, path] = stk.top(); stk.pop(); // 1. 终止条件:选够了k个数 if (path.size() == k) { if (sum == n) { res.push_back(path); // 和为n,加入结果集 } continue; } // 2. 双重剪枝优化 int need = k - path.size(); // 还需要选need个数字 // 遍历所有可能的选择 for (int i = start; i <= 9 - need + 1 && sum + i <= n; ++i) { // 3. 生成新状态,压入栈(模拟递归调用) vector<int> new_path = path; new_path.push_back(i); stk.emplace(i + 1, sum + i, new_path); } } return res; } };

代码逐行解析

  1. 栈的初始化

    cpp

    stack<tuple<int, int, vector<int>>> stk; stk.emplace(1, 0, vector<int>());

    我们用一个三元组栈保存每一层的状态,初始时压入最外层的状态:从数字 1 开始选,当前和为 0,路径为空。

  2. 主循环:模拟递归过程

    cpp

    while (!stk.empty()) { auto [start, sum, path] = stk.top(); stk.pop(); // ... }

    只要栈不为空,就不断弹出栈顶元素进行处理,这对应递归过程中函数的返回。

  3. 终止条件处理

    cpp

    if (path.size() == k) { if (sum == n) { res.push_back(path); } continue; }

    和递归法完全一致:当选够 k 个数时,检查和是否为 n,是的话加入结果集,然后直接跳过后续处理。

  4. 状态生成与压栈

    cpp

    for (int i = start; i <= 9 - need + 1 && sum + i <= n; ++i) { vector<int> new_path = path; new_path.push_back(i); stk.emplace(i + 1, sum + i, new_path); }

    这对应递归过程中的选择递归调用

    • 生成新的路径new_path,加入当前选择的数字i
    • 生成新的状态:下一轮从i+1开始选,和为sum+i,路径为new_path
    • 将新状态压入栈,等待后续处理

详细执行流程解析

我们还是以示例 2k=3, n=9为例,模拟迭代法的执行过程:

初始状态

  • 栈:[(1, 0, [])]
  • 结果集:[]

第一步:弹出初始状态

  • 弹出(1, 0, [])
  • path.size()=0 < 3need=3
  • 循环i从 1 到 7(9-3+1=7
  • 压入新状态:(2,1,[1])(3,2,[2])(4,3,[3])(5,4,[4])(6,5,[5])(7,6,[6])(8,7,[7])
  • 栈现在:[(2,1,[1]), (3,2,[2]), (4,3,[3]), (5,4,[4]), (6,5,[5]), (7,6,[6]), (8,7,[7])]

第二步:弹出栈顶(8,7,[7])

  • path.size()=1 < 3need=2
  • 循环i从 8 到9-2+1=8
  • sum+i=7+8=15 > 9,不满足和剪枝,循环不执行
  • 栈现在:[(2,1,[1]), (3,2,[2]), (4,3,[3]), (5,4,[4]), (6,5,[5]), (7,6,[6])]

第三步:弹出栈顶(7,6,[6])

  • path.size()=1 < 3need=2
  • 循环i从 7 到 8
  • i=7sum+i=6+7=13 >9,不满足
  • 循环不执行
  • 栈现在:[(2,1,[1]), (3,2,[2]), (4,3,[3]), (5,4,[4]), (6,5,[5])]

第四步:弹出栈顶(6,5,[5])

  • path.size()=1 < 3need=2
  • 循环i从 6 到 8
  • i=6sum+i=5+6=11 >9,不满足
  • 循环不执行
  • 栈现在:[(2,1,[1]), (3,2,[2]), (4,3,[3]), (5,4,[4])]

第五步:弹出栈顶(5,4,[4])

  • path.size()=1 < 3need=2
  • 循环i从 5 到 8
  • i=5sum+i=4+5=9,压入(6,9,[4,5])
  • i=6sum+i=4+6=10 >9,终止循环
  • 栈现在:[(2,1,[1]), (3,2,[2]), (4,3,[3]), (6,9,[4,5])]

第六步:弹出栈顶(6,9,[4,5])

  • path.size()=2 < 3need=1
  • 循环i从 6 到 9
  • sum+i=9+6=15 >9,不满足
  • 循环不执行
  • 栈现在:[(2,1,[1]), (3,2,[2]), (4,3,[3])]

第七步:弹出栈顶(4,3,[3])

  • path.size()=1 < 3need=2
  • 循环i从 4 到 8
  • i=4sum+i=3+4=7,压入(5,7,[3,4])
  • i=5sum+i=3+5=8,压入(6,8,[3,5])
  • i=6sum+i=3+6=9,压入(7,9,[3,6])
  • i=7sum+i=3+7=10 >9,终止循环
  • 栈现在:[(2,1,[1]), (3,2,[2]), (5,7,[3,4]), (6,8,[3,5]), (7,9,[3,6])]

后续过程

继续按照这个逻辑弹出和压入状态,最终会依次找到[2,3,4][1,3,5][1,2,6]三个有效组合,和示例输出完全一致。


递归法 vs 迭代法对比

表格

对比维度递归回溯法迭代法
代码简洁度非常简洁,逻辑清晰稍复杂,需要手动管理栈
理解难度容易理解,符合人类思维需要理解递归的栈模拟过程
栈溢出风险递归深度最大为 9,无风险无栈溢出风险
效率几乎一致(都有双重剪枝)几乎一致
适用场景大多数回溯问题递归深度过大的场景,或不允许使用递归的场景

总结

迭代法实现回溯的核心是用栈模拟递归的状态转移。对于这道题来说,递归法已经足够优秀,但掌握迭代法可以让我们更深入地理解回溯的本质,也能应对一些特殊场景(如递归深度限制)。

http://www.jsqmd.com/news/880252/

相关文章:

  • 2026最新免费在线去除视频水印保姆级教程,不用下载软件一步到位!
  • Go语言ORM框架GORM深度解析
  • 2026最新免费在线去水印工具详细教程,在线去本地视频水印保姆级指南
  • 哈夫曼树:高效压缩数据的秘密武器
  • 蛋白质设计新范式:QUBO建模与迭代学习框架解析
  • 2026深度测评10款降AIGC工具红黑榜!优缺点全公开,达标率硬刚行业巅峰
  • 风暴崛起 Tempest Rising修改器2026官方正版最新版pc免费下载(看到请立即转存 资源随时失效)
  • 别再盲目调max_tokens!资深架构师压测23种分块策略后,锁定最优chunk_size=384+overlap=64的硬核依据
  • 宝藏合集!2026一键生成论文工具大盘点(覆盖 99% 论文写作需求)
  • 2026保姆级免费照片去水印教程:不用下载App,微信小程序3步搞定!
  • Windows视觉效果关不关?电脑卡顿这样优化最快
  • 技术人的职业规划:打造成功的职业生涯
  • Gemini LTV建模实战手册:从POC验证、规模化推理、监管审计到知识沉淀——覆盖7大关键节点的稀缺性价值锚定法
  • 【ChatGPT新闻稿写作黄金模板】:20年公关总监亲授——5大结构+3类风险规避+1套即用话术库
  • 技术人的沟通技巧:如何与非技术人员有效沟通
  • DeepSeek模型版本选择终极决策树(2024Q3权威更新):输入你的GPU型号/任务类型/预算,3步锁定最优解
  • 2026Q2上海老房翻新装修公司TOP5排行榜|业主实测高口碑旧房改造实力榜单 - 品牌智鉴榜
  • 鸿蒙健身计划页面构建:一周训练表、营养目标、近期打卡与训练提示模块详解
  • 仅剩72小时!OpenAI即将关闭旧版Prompt调试接口:立即掌握新一代结构化提示词(JSON Schema+Role-Chain双范式)
  • Gemini能替代初级开发者吗?:2024最新实测数据揭示代码生成准确率、可维护性与安全边界
  • 【DeepSeek生产环境性能崩塌预警】:7类高频OOM错误代码级定位图谱(含torch.compile失效的3个隐藏触发条件)
  • HTML 基础:列表、表格与多媒体元素
  • 丈母娘只要第一眼看不上女婿,即使后面结婚了,大概率也会一直看不上,大家觉得对吗?——为什么有些丈母娘总是挑女婿的不是,没事就发货大吼?——
  • 鸿蒙PC:Qt适配OpenHarmony实战【花账】:从一笔支出开始,做一个本地记账小应用
  • 云原生事件驱动架构:构建高效的事件处理系统
  • AGC013 部分题目题解
  • 5.24
  • 鸿蒙PC:Qt适配OpenHarmony实战【度量间】:把长度、重量、温度三类换算装进 Qt Quick
  • 有些女的就是只配孤独终老,一说话就伤人,我觉得没有必要相处,没必要去改变一些人,林子大了,什么鸟都有。。。——拉开距离,减少纠缠,建立边界,降低期待
  • 2026Q2上海浦东新房装修公司TOP5排行榜|口碑实力双优实测榜单 - 品牌智鉴榜