python 组合求和 (回溯-中等)含源码(十七)
问题说明(含示例)
问题描述:给定一个无重复元素的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为 target
的所有不同组合。要求同一数字可无限制重复选取,且两种组合若至少一个数字的选取数量不同,则视为不同组合;最终结果以列表形式返回,顺序可任意,且保证组合数少于 150 个。
示例
输入 | 输出 | 解释 |
---|---|---|
candidates = [2,3,6,7], target = 7 | [[2,2,3],[7]] | 2 可重复选取,2+2+3=7 ;7 直接选取,7=7 ,仅这两种组合满足条件 |
candidates = [2,3,5], target = 8 | [[2,2,2,2],[2,3,3],[3,5]] | 2 重复选 4 次(2×4=8 )、2 选 1 次 + 3 选 2 次(2+3×2=8 )、3 选 1 次 + 5 选 1 次(3+5=8 ),共三种组合 |
candidates = [2], target = 1 | [] | 2 > 1,无法通过任何选取得到和为 1 的组合,返回空列表 |
解题关键
核心思路是利用回溯法,通过 “选择 - 递归 - 撤销” 的逻辑探索所有可能组合,同时通过 “起始索引控制” 避免顺序重复(如 [2,3]
和 [3,2]
),通过 “剪枝” 减少无效计算,具体步骤如下:
特殊情况处理:
- 若
candidates
为空或target <= 0
,直接返回空列表(无有效组合); - 过滤
candidates
中大于target
的数字(选取后必然超目标,无需进入递归,提前剪枝)。
- 若
初始化变量:
result
:用列表存储所有符合条件的组合(最终返回结果);- 回溯函数参数:
current_sum
:当前组合的数字和(初始为 0);current_list
:当前正在构建的组合(初始为空列表);start
:当前选取数字的起始索引(初始为 0,用于控制 “不回头选前面的数字”,避免重复组合)。
回溯核心步骤(递归实现):
- 终止条件 1:若
current_sum == target
,将current_list
的副本加入result
(避免引用修改),直接返回; - 终止条件 2:若
current_sum > target
,直接返回(剪枝,无需继续累加); - 遍历选择:从
start
开始遍历过滤后的candidates
(避免回头选,防止重复);- 选择:将当前数字
value
加入current_list
,并累加current_sum
; - 递归:调用回溯函数,传入更新后的
current_sum
、current_list
,且start
设为当前索引i
(允许重复选当前数字); - 回溯:撤销选择,即
current_sum
减去value
,current_list
移除最后一个元素(恢复状态,尝试下一个数字)。
- 选择:将当前数字
- 终止条件 1:若
启动与返回:调用回溯函数(初始参数
current_sum=0, current_list=[], start=0
),最终返回result
。
对应代码
from typing import Listclass Solution:def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:# 特殊情况处理:空数组或目标无效,直接返回空列表if not candidates or target <= 0:return []# 提前剪枝:过滤大于target的数字(修正:曾未过滤,增加无效计算)valid_candidates = [num for num in candidates if num <= target]result = [] # 存储所有有效组合def backtrack(current_sum: int, current_list: List[int], start: int) -> None:# 终止条件1:和等于目标,保存组合副本(修正:曾漏写copy()或误写copy)if current_sum == target:result.append(current_list.copy()) # ✔ 正确:调用copy()保存副本# ✘ 曾错误1:result.append(current_list) → 直接加引用,结果被污染# ✘ 曾错误2:result.append(current_list.copy) → 加方法对象,类型错误return# 终止条件2:和超过目标,剪枝(避免无效计算)if current_sum > target:return# 从start开始遍历,避免回头选(修正:曾从0开始,或递归传错参数)for i in range(start, len(valid_candidates)):value = valid_candidates[i]# 1. 选择:累加和 + 加入组合current_sum += valuecurrent_list.append(value)# 2. 递归:传i而非start,允许重复选当前数字,且不回头(修正:曾传start)backtrack(current_sum, current_list, i) # ✔ 正确:传i# ✘ 曾错误:backtrack(current_sum, current_list, start) → 传start导致重复# 3. 回溯:撤销选择,恢复状态current_sum -= valuecurrent_list.pop()# 初始调用:和0、空组合、起始索引0backtrack(0, [], 0)return result
对应的基础知识
实现组合总和算法需掌握以下核心基础概念,是理解代码逻辑的前提:
1. 回溯法的 “三步框架”
回溯法是探索 “多路径选择” 问题的通用模板,核心是 “尝试 - 回退 - 再尝试”,对应代码中的三步:
- 选择:将当前数字加入组合(
current_list.append(value)
),更新当前和(current_sum += value
); - 递归:基于当前选择,探索后续可能的组合(
backtrack(...)
); - 回溯:撤销当前选择(
current_list.pop()
、current_sum -= value
),回到上一步尝试其他数字。这一框架适用于排列、组合、子集等 “穷举类” 问题。
2. 列表的 “引用特性” 与 copy()
的必要性
Python 中列表是可变对象(引用类型),若直接将 current_list
加入 result
,后续对 current_list
的 append
/pop
会同步修改 result
中的元素(例如 current_list
后续添加数字,已存入的组合也会跟着变化)。通过 current_list.copy()
创建列表副本,副本的修改不会影响原列表,确保 result
中存储的是固定、完整的组合。
3. “起始索引 start
” 的核心作用
start
的唯一目的是避免顺序重复的组合(如 [2,3]
和 [3,2]
):
- 遍历从
start
开始,意味着 “仅能选择当前数字及之后的数字”,无法回头选前面的数字; - 例:选了
3
(索引1
)后,后续只能选3
及之后的数字(如5
),不会再选2
(索引0
),从而杜绝[3,2]
这类与[2,3]
重复的组合。
4. 剪枝的基本思想
“剪枝” 是减少无效计算的优化手段,代码中两处体现:
- 提前过滤:
valid_candidates
排除大于target
的数字(如target=7
时排除8
),避免这些数字进入递归; - 终止剪枝:
current_sum > target
时直接返回(如current_sum=8
、target=7
时,无需继续加数字),减少不必要的递归调用。
对应的进阶知识
组合总和问题的背后,涉及算法复杂度、回溯优化与问题本质的深层理解:
1. 时间与空间复杂度分析
时间复杂度:
O(n^(k))
(n
为valid_candidates
长度,k
为组合的最大长度,k = target / min(valid_candidates)
)组合的最大长度由 “最小数字” 决定(例如target=7
、最小数字2
,最大组合长度为 4,即2×4=8>7
);每个步骤有n
种选择(但因start
控制,实际分支数递减),总时间与递归树的节点数成正比,即O(n^k)
。空间复杂度:
O(k)
(k
为组合的最大长度)- 递归调用栈:深度等于组合的最大长度
k
(构建最长组合需k
层递归); - 当前组合列表:
current_list
的最大长度为k
;注意:存储结果的result
列表属于 “输出必要空间”,不计入算法额外空间。
- 递归调用栈:深度等于组合的最大长度
2. “重复选数字” 与 “避免重复组合” 的平衡
- 允许重复选数字:通过递归时传递
start=i
实现 —— 选了当前数字value
(索引i
)后,下次仍能从i
开始选,即重复选value
; - 避免重复组合:通过
start
控制遍历范围实现 —— 不能选i
之前的数字,避免因顺序不同导致的重复(如[2,3]
和[3,2]
)。这一设计精准平衡了 “重复选” 和 “去重” 的需求,无需额外的 “已选标记数组”(如used
),简化代码且提升效率。
3. 回溯法与迭代法的对比
除回溯法外,组合总和也可通过 “迭代法” 实现(用队列 / 栈存储每个组合的状态),但两者各有优劣:
对比维度 | 回溯法 | 迭代法 |
---|---|---|
代码可读性 | 高(直观体现 “选择 - 回溯” 逻辑) | 低(需手动维护队列中的状态,如当前和、组合、起始索引) |
空间效率 | 高(仅用递归栈和当前组合列表) | 低(队列需存储所有中间状态,空间占用与中间组合数成正比) |
适用场景 | 优先用于 “需要回溯探索” 的问题(如组合、子集) | 适用于递归深度过大(可能栈溢出)的场景 |
实际解题中,回溯法因代码简洁、逻辑清晰,是组合总和的首选方案。 |
4. 边界情况的完整性处理
代码通过两处边界判断确保逻辑完整:
- 空输入 / 无效目标:
if not candidates or target <= 0
直接返回空列表,避免无效递归; - 超目标剪枝:
if current_sum > target
直接返回,避免继续累加数字(如current_sum=7
时,再加任何正数都会超目标)。边界处理是算法 “正确性” 的关键,可避免漏解或错解(如空输入返回空列表,而非报错)。
编程思维与启示
组合总和的解题过程,体现了多种通用编程思维,可迁移到 “组合总和 Ⅱ(含重复元素)”“目标和” 等类似问题:
1. “问题拆解” 的简化思路
将 “找和为 target 的组合” 这一复杂问题,拆解为 “选当前数字 / 不选当前数字” 的子问题:
- 选当前数字:加入组合,累加和,递归探索后续数字;
- 不选当前数字:回溯后跳过当前数字,遍历下一个数字;通过子问题的逐一解决,降低整体问题的复杂度,符合 “分而治之” 的编程思想。
2. “效率优先” 的剪枝意识
剪枝是提升回溯算法效率的核心手段,代码中 “提前过滤大于 target 的数字” 和 “超目标时终止”,本质是 “主动排除无效路径”:
- 提前过滤:从源头上减少递归的输入规模(如
candidates
有 100 个数字,过滤后可能只剩 10 个); - 终止剪枝:在递归中途终止无效路径(如
current_sum=8
、target=7
时,无需继续递归);这种 “能省则省” 的剪枝意识,在处理大规模输入时尤为重要。
3. “状态控制” 的精准性
回溯法的核心是 “状态的维护与恢复”:
- 维护状态:
current_sum
和current_list
记录当前组合的核心信息; - 恢复状态:
current_sum -= value
和current_list.pop()
撤销选择,确保回溯后状态与之前一致;若状态恢复不完整(如漏写current_sum -= value
),会导致后续计算的和错误,直接引发结果偏差。
4. “去重逻辑” 的灵活设计
组合问题的 “去重” 无需依赖额外数据结构(如集合),可通过 “索引控制” 实现:
- 与 “全排列” 的区别:全排列需用
remaining
或used
确保每个数字只用一次,组合问题需用start
确保不回头选; - 优势:相比 “用集合去重”(需生成所有组合后再去重),“索引控制” 在递归过程中直接避免重复,时间效率更高;这一设计体现了 “根据问题特性选择最优去重方式” 的思维,而非依赖通用工具。