一、背景与问题
1.1 组合求和的超时烦恼
你在刷算法题的时候,肯定碰到过这种场景:给你一个不重复的整数数组,比如 [2, 3, 6, 7],还有一个目标数 target = 7,让你找出所有加起来等于7的组合,并且每个数字可以用无数次。听起来很简单,对吧?很多同学上来就写一个递归回溯,结果一跑,小数据还行,一旦数组变大或者 target 变大,程序直接卡死,或者跑个几分钟才出结果,甚至直接超时。这就是典型的组合求和超时问题。
为什么会超时?原因是递归树太大了,而且生成了大量重复的答案。比如 [2, 2, 3] 这个组合,通过先取2再取3,和先取3再取2,在回溯的时候都会被算作两个不同的路径,但最终输出时它们其实是同一个组合(因为顺序不重要)。这种重复不仅浪费计算资源,还会导致答案列表里出现一模一样的组合,然后你还要再去重,又多了很多操作。
1.2 重复解从何而来
让我们用生活化的例子理解重复解。想象你去超市买东西,要凑满7块钱。货架上有2块钱的苹果、3块钱的梨、6块钱的西瓜和7块钱的哈密瓜。你每次可以拿任意一样,但拿了之后可以回头再拿。如果你用最笨的办法——每步都从第一个商品开始选,那么你可能会经历:先拿苹果(2元),再拿苹果(2元),再拿梨(3元);或者先拿苹果(2元),再拿梨(3元),再拿苹果(2元);或者先拿梨(3元),再拿苹果(2元),再拿苹果(2元)。这三个路径在“组合”的意义上完全一样,都是两个苹果一个梨,但因为拿的顺序不同,回溯算法会把它们当作不同的候选路径全部枚举一遍。
这种重复的本质是:当我们允许数字重复使用,且没有限制选取顺序时,同样的元素集合会产生多种排列。而组合问题只关心集合,不关心顺序。所以如果不做处理,递归树会爆炸式增长,超时就在所难免了。
二、基础回溯实现
2.1 暴力回溯代码
我现在用 Python 写一个最简单的回溯实现,不加任何剪枝。技术栈就是 Python。这段代码能跑,但效率极低。
def combination_sum_brute(candidates, target):
"""
暴力回溯:不限制选择顺序,会导致重复组合和大量无效搜索
candidates: 候选数字列表(无重复)
target: 目标和
return: 所有组合的列表
"""
result = [] # 存放最终结果
path = [] # 记录当前路径
def backtrack(remain):
# remain: 当前还需要凑多少
if remain == 0:
# 找到一个有效组合,需要复制path,防止后续修改
result.append(path[:])
return
if remain < 0:
# 超出目标,剪枝(但这里只做了最基本的)
return
# 遍历所有候选数字
for num in candidates:
# 无条件选择这个数字
path.append(num)
backtrack(remain - num)
path.pop() # 撤销选择
backtrack(target)
return result
# 测试一下
cands = [2, 3, 6, 7]
target = 7
print(combination_sum_brute(cands, target))
# 输出结果:[[2,2,3], [2,3,2], [3,2,2], [7]]
# 你看,前面三个其实是同一个组合(2,2,3)的不同排列
上面这段代码注释很清晰,但结果里出现了重复组合。而且如果 target 变大,比如 target = 10,递归次数会指数级增长,很快超时。
2.2 为什么慢?重复与无剪枝
除了重复组合导致多走路之外,没有剪枝还意味着很多路径明明已经不可能凑出目标了,但还在盲目尝试。比如当 remain 变成负数时,虽然我们返回了,但在此之前已经做了很多无用功。更严重的是,因为没有限制选择的下标,每次递归都从 candidates 的第一个元素开始,这导致即使当前已经选了很大的数字,后面还可能回头选小的,形成大量重复。
另外,当 candidates 里有 0 的话,程序会陷入无限递归(因为0永远不会让 remain 减少)。所以基础暴力写法甚至连边界都没处理好,实际运行时漏洞百出。
三、对称性剪枝策略
3.1 核心思想:限制选择顺序
要解决重复解问题,我们需要引入“对称性剪枝”。这个名字听起来专业,其实道理很朴素:既然组合不关心顺序,那我们就强迫每次选择时,只从当前选择过的数字或后面的数字里选,不能回头去选更小的数字。这样,同样的元素集合只会被一种顺序生成(比如从小到大)。这样就避免了不同排列的重复。
具体到代码里,就是在递归时传入一个 start_index,表示当前可以从哪个下标开始选。这样每次递归只能选当前下标及之后的数字,不会选之前已经选过的。这就是“索引递增剪枝”。
3.2 具体做法:索引递增剪枝
我们修改一下上面的代码,加入 start_index 参数。同时还需要对 candidates 排序(因为要保证从小到大的顺序,而且排序后还能配合另一种剪枝:当当前数字已经大于剩余目标时,可以提前终止循环)。注意,排序不会改变原数组的话,最好用新的列表。
3.3 还有哪些剪枝?提前终止
除了对称性剪枝,另一个常用剪枝是“提前终止”。因为数组是排序过的,当我们遍历到某个数字已经大于剩余目标时,后面更大的数字肯定也大于剩余目标,可以直接退出循环。这个配合对称性剪枝,能大幅减少无效递归。
另外,如果 target 本身是 0,直接返回空组合?其实组合求和通常允许空组合吗?一般题目要求非空组合,但如果我们允许空组合,需要特殊处理。我们这里按常规题目,要求组合非空,且 target 为正整数。但为了边界,我们还是要考虑 candidates 为空的情况。
四、实战应用与边界处理
4.1 完整示例:带剪枝的组合求和
下面展示一个完整的、带对称性剪枝和提前终止的组合求和实现。技术栈依然是 Python。
def combination_sum(candidates, target):
"""
组合求和(带对称性剪枝 + 提前终止)
candidates: 候选整数列表(无重复)
target: 目标和
return: 所有不重复组合的列表
"""
# 边界处理:如果candidates为空,直接返回空列表
if not candidates:
return []
# 先排序,方便剪枝
candidates.sort()
result = []
path = []
def backtrack(start, remain):
"""
start: 当前可选的起始下标(只能选>=start的数字)
remain: 还需要凑的目标值
"""
if remain == 0:
# 找到一组有效组合,记录path的副本
result.append(path[:])
return
# 从start开始遍历,确保不回头
for i in range(start, len(candidates)):
num = candidates[i]
# 提前终止:如果当前数字已经大于剩余目标,后面更大的就不用看了
if num > remain:
break
# 做出选择
path.append(num)
# 递归下一步,注意下一个start仍然是i(因为数字可以重复使用)
backtrack(i, remain - num)
# 撤销选择
path.pop()
backtrack(0, target)
return result
# 测试
cands = [2, 3, 6, 7]
target = 7
res = combination_sum(cands, target)
print(res) # 输出 [[2,2,3], [7]] 没有重复了!
代码注释解释得很清楚。这里的关键是 backtrack(i, remain - num) 中的 i 不变,而不是 i+1。因为题目允许重复使用同一个数字,所以下一次还可以选当前数字(即同一个下标)。如果题目不允许重复,就要传 i+1 了。
4.2 边界情况:空数组、0、大数
我们来测试一些边界情况,看看代码是否健壮。
# 边界测试
print(combination_sum([], 7)) # [] 空数组
print(combination_sum([2], 1)) # [] 没有组合
print(combination_sum([1], 2)) # [[1,1]]
print(combination_sum([3, 4, 5], 2)) # [] 所有数都大于target
print(combination_sum([1, 2], 0)) # 一般题目target为正数,但如果target=0呢?根据题意,可能没有解,或者空组合。我们代码里target=0时,backtrack(0,0)会立即将[]加入结果,所以输出[[]]。但通常组合求和题规定target为正整数,这里作为边界自己注意。
对于 target=0,很多题目会默认返回空列表或空组合。如果我们要忽略空组合,可以在 backtrack 里加判断 if remain == 0 and path: 再加结果。但这里我们保持通用性,让调用者自己过滤。
4.3 性能对比与测试
我们跑一个中等规模的例子,比如 candidates = [2, 3, 5, 7, 10, 15], target = 30。暴力版本(不加剪枝)几乎跑不出来,而带剪枝的版本瞬间就能给出所有组合。你可以自己试一下,用 time 模块测量。下面给一个简单的性能测试代码。
import time
candidates = [2, 3, 5, 7, 10, 15]
target = 30
start = time.time()
res1 = combination_sum_brute(candidates, target)
print("暴力版耗时:", time.time() - start) # 取决于机器,可能很久
start = time.time()
res2 = combination_sum(candidates, target)
print("剪枝版耗时:", time.time() - start) # 通常小于0.01秒
注意暴力版可能会跑几分钟甚至更长,所以测试时小心别让程序卡死。在实际开发中,我们当然要用剪枝版。
五、技术优缺点分析
5.1 优点
- 避免重复解:对称性剪枝从根源上防止了因顺序不同而产生的重复组合,省去了后期去重的麻烦,也大幅减少了递归分支。
- 大幅提升性能:排序 + 提前终止剪枝让搜索空间显著缩小,尤其是当 target 较大、candidates 较多时,效果极其明显。
- 代码简洁易懂:只增加了一个
start参数,逻辑改动很小,易于理解和维护。 - 通用性强:这种剪枝思想不仅适用于组合求和,还可以推广到其他需要去重的回溯问题,比如子集、排列(略加调整)等。
5.2 缺点
- 需要排序:排序需要 O(n log n) 的时间,但相比剪枝带来的指数级收益,这个成本几乎可以忽略。不过如果 candidates 本身很大且 target 很小,排序可能稍微有点浪费,但影响不大。
- 只适用于组合,不适用于排列:如果你需要的是排列(不同顺序算不同解),这种剪枝不能加,加了会丢失解。
- 数字可重复使用的限制:如果题目不允许重复使用数字,start 要改为 i+1,这里需要根据题意灵活调整,容易搞混。
- 对 0 的处理需额外注意:如果 candidates 里包含 0,会导致无限递归(因为 remain 永远不会减少)。通常题目会保证无 0,但现实场景可能需要检查并过滤 0。
六、注意事项
- 排序时机:不要在原数组上直接排序,如果题目要求不能修改原数组,请先复制一份再排序。
- start 参数传递:允许重复使用数字时传
i,不允许时传i+1,这个区别是很多新手踩坑的地方。建议写注释说明。 - 提前终止的条件:必须依赖于排序,否则
num > remain不意味着后面都大于,因为数组无序。 - 结果去重:即使加了对称性剪枝,如果 candidates 本身有重复数字,仍可能产生重复组合(因为数字相同但下标不同)。题目通常说 candidates 无重复,如果有重复,需要先对 candidates 去重,或者在循环中加
if i > start and candidates[i] == candidates[i-1]: continue来跳过同级重复元素。 - 栈溢出风险:深度递归可能导致 Python 的递归栈溢出(默认递归深度约1000)。如果 target 很大且允许很多层递归,可以考虑改用迭代或手动限制深度。实际刷题时 target 一般不会太大。
七、文章总结
今天我们从一个常见的算法超时问题入手,一步步分析了组合求和中重复解和效率低下的根源。然后给出了暴力回溯的代码,并指出其缺陷。接着重点介绍了对称性剪枝策略——通过限制选择顺序(索引递增)来避免重复组合,再结合排序后的提前终止剪枝,一举两得。我们提供了完整的 Python 示例,并讨论了边界情况和性能对比。
这种剪枝思路非常实用,不仅解决了组合求和超时的问题,还能让你在遇到类似回溯题(如子集、组合总和 II等)时,快速套用。记住核心:组合不讲顺序,那就用 start 参数强行规定一个顺序。再加上排序提前终止,你的回溯算法就能从“龟速”变成“兔速”。
希望这篇文章能帮你彻底搞懂组合求和的剪枝方法,以后面试或者实战中再也不怕超时了。如果你有更多疑问,欢迎在评论区留言讨论。
Comments