算法刷题系列(三十二):组合总和——在回溯中处理复用与去重
算法刷题系列(三十二):组合总和——在回溯中处理复用与去重
算法刷题系列(三十二):组合总和——在回溯中处理复用与去重
组合总和把回溯的几个关键决定放在同一道题里:选择后是否允许重复使用元素、答案中的顺序是否重要、相同候选值会不会造成重复路径。
本文约定候选数是正整数,组合按数值多重集比较([2, 3, 2] 与 [2, 2, 3] 是同一个组合)。每个候选值可以重复使用。例:候选 [2, 3, 6, 7]、目标 7,答案为 [2, 2, 3] 和 [7]。
状态与剪枝
递归状态由三个量组成:当前搜索起点 start、尚未凑出的 remaining、当前路径 path。在排序后的候选数组中,从 start 起挑数 x:
- 若
x > remaining,由于后面的数更大,后续分支也不可能成立,可以停止这一层循环; - 递归仍传入当前下标
i,因此x可以再次使用; - 下一次只能从
i或更大的下标取数,因此组合保持非递减顺序,不会把不同排列重复计数。
当 remaining == 0 时,当前路径就是一个解。若数组中有重复候选值,先排序去重,避免相同数值从不同下标出发形成相同答案。
Java 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
public class CombinationSumSearch {
public static List<List<Integer>> combinationSum(int[] candidates, int target) {
if (target < 0) {
throw new IllegalArgumentException("target must be non-negative");
}
TreeSet<Integer> unique = new TreeSet<>();
for (int value : candidates) {
if (value <= 0) {
throw new IllegalArgumentException("candidates must be positive");
}
unique.add(value);
}
int[] values = unique.stream().mapToInt(Integer::intValue).toArray();
List<List<Integer>> answer = new ArrayList<>();
search(values, 0, target, new ArrayList<>(), answer);
return answer;
}
private static void search(
int[] values, int start, int remaining,
List<Integer> path, List<List<Integer>> answer
) {
if (remaining == 0) {
answer.add(new ArrayList<>(path));
return;
}
for (int i = start; i < values.length; i++) {
int value = values[i];
if (value > remaining) {
break;
}
path.add(value);
search(values, i, remaining - value, path, answer);
path.remove(path.size() - 1);
}
}
}
Python 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
if target < 0:
raise ValueError("target must be non-negative")
if any(value <= 0 for value in candidates):
raise ValueError("candidates must be positive")
values = sorted(set(candidates))
answer: list[list[int]] = []
path: list[int] = []
def search(start: int, remaining: int) -> None:
if remaining == 0:
answer.append(path.copy())
return
for index in range(start, len(values)):
value = values[index]
if value > remaining:
break
path.append(value)
search(index, remaining - value)
path.pop()
search(0, target)
return answer
assert combination_sum([2, 3, 6, 7], 7) == [[2, 2, 3], [7]]
assert combination_sum([2, 2, 3], 0) == [[]]
正确性与复杂度
每个合法组合都可唯一写成非递减序列。递归按这个序列逐项选择;允许从当前下标继续,保证重复使用;禁止回到更小下标,避免排列重复。达到剩余值为零时收集答案,因此每个合法组合都会被枚举且不会以不同顺序重复出现。
最小候选值为 m 时,路径深度最多 target / m。候选为正保证剩余值严格下降,也保证搜索终止。搜索树和答案数可能呈指数增长,输出本身也可能很大;额外递归空间为 O(target / m),不计结果。
常见错误与边界
- 忘记正数前提:若允许
0,递归可能无限;若允许负数,按排序剪枝也不再成立。 - 为了复用而传
i + 1:这会变成每个候选最多使用一次;复用版本需要传i。 - 从
0重新开始:会生成[2,3]与[3,2]这样的重复排列。 - 只去重结果:先排序、统一递归顺序并去掉重复候选,通常更简单也更省搜索。
- 目标为零:空组合是有效解,应返回
[[]];空候选且正目标则返回空列表。
如果题目规定“每个候选只能用一次”,递归起点应改成 i + 1,并在同一层跳过重复值。这一处变化对应的是题意中的资源复用规则,不是随意的模板差异。
本文由作者按照 CC BY 4.0 进行授权