LeetCode 40.组合总和II:含重复元素的组合问题去重策略详解
一、问题本质与核心差异
1.1 题目要求
给定一个可能含重复元素的整数数组candidates
和目标值target
,找出所有和为target
的组合,要求:
- 每个元素在每个组合中只能使用一次
- 解集不能包含重复的组合
1.2 与组合总和I的关键区别
对比项 | 组合总和I(39题) | 组合总和II(40题) |
---|---|---|
元素使用 | 可重复使用 | 每个元素只能用一次 |
输入特性 | 无重复元素 | 可能含重复元素 |
去重需求 | 无需去重(排序保证) | 必须显式去重 |
递归参数 | backtracking(i) | backtracking(i+1) |
二、去重核心逻辑:i > start的数学原理
2.1 代码中的去重关键段
for (int i = start; i < candidates.length; i++) {// 核心去重条件if (i > start && candidates[i] == candidates[i - 1]) {continue;}// 后续选择逻辑
}
2.2 去重条件的严格证明
定理:当数组排序后,若i > start
且candidates[i] == candidates[i-1]
,则选择candidates[i]
必然产生与选择candidates[i-1]
相同的组合
- 证明:
- 因数组已排序,故
candidates[i] == candidates[i-1]
i > start
表示i-1
在当前层已被处理过(start为当前层起始索引)- 若选择
i
,其后续递归路径与选择i-1
的路径完全对称(因元素值相同) - 因此会生成重复组合,需跳过
- 因数组已排序,故
2.3 去重场景可视化
以candidates=[1,1,2]
, target=2
为例:
排序后:[1,1,2]
第一层(start=0):
i=0(1): 选择→进入第二层(start=1)
i=1(1): i>start(1>0)且值相同→跳过
i=2(2): sum+2=2→记录[1,2]第二层(start=1):
i=1(1): 选择→进入第三层(start=2)
i=2(2): sum+2=2→记录[1,2](与上层重复?不,因start不同)
关键:去重条件仅跳过同一层中的重复元素,不同层的相同元素可正常选择
三、代码深度解析与状态管理
3.1 整体框架对比
// 组合总和I(可重复选)
backtracking(candidates, target, i, sum); // 递归时i不变// 组合总和II(不可重复选)
backtracking(candidates, target, i+1, sum); // 递归时i+1
3.2 状态转移图
根(0,sum=0)/ | \1 1 2/ | \ |1 2 ... 2/2 (sum=2)
- 同一层中第二个1被跳过(i=1>start=0且值相同)
- 不同层的1(如第一层选1后,第二层选1是允许的)
3.3 深拷贝与状态回溯
if (sum == target) {res.add(new ArrayList<>(temp)); // 必须深拷贝
}
// 回溯时撤销选择
sum -= candidates[i];
temp.removeLast();
注意:若不使用深拷贝,当回溯修改temp时,结果集中的引用会被同步修改
四、剪枝策略与复杂度分析
4.1 双重剪枝优化
- 值剪枝:
sum + candidates[i] > target
时break(同39题) - 重复剪枝:
i > start && candidates[i] == candidates[i-1]
时continue
4.2 时间复杂度精确计算
- 最坏情况(无重复元素且每个元素≤target):
- 组合数为C(n,0)+C(n,1)+…+C(n,k)≈2^n
- 总时间复杂度:O(2^n × k),k为平均组合长度
- 优化后复杂度:
- 重复元素越多,剪枝效果越明显
- 实际复杂度:O(2^m × k),m为去重后的唯一元素个数
4.3 空间复杂度
- 递归栈深度:O(target/min(candidates))
- 临时空间:O(target)(存储当前组合)
- 总空间复杂度:O(target + 2^m)
五、典型案例执行流程
5.1 案例输入
candidates=[10,1,2,7,6,1,5]
, target=8
排序后:[1,1,2,5,6,7,10]
5.2 递归执行轨迹
-
第一层(start=0):
- i=0(1): sum=1≤8→选择,进入start=1
- i=1(1): i>0且值相同→跳过
- i=2(2): sum=2≤8→选择,进入start=3
- …(中间过程省略)
- i=5(7): sum=7≤8→选择,进入start=6
- i=6(10): sum+10=17>8→break
-
第二层(start=1)处理i=2(2)时:
- sum=1+2=3≤8→选择,进入start=3
- i=3(5): sum+5=8→记录组合[1,2,5]
-
去重演示:
- 第一层i=0和i=1均为1,但i=1时i>start(0)→跳过
- 确保不会生成[1,1,…]和[1,1,…]的重复组合
5.3 最终解集
[[1,1,6], [1,2,5], [1,7], [2,6]]
六、去重逻辑的常见误区
6.1 错误去重条件对比
错误条件 | 问题表现 | 正确条件 |
---|---|---|
i > 0 && candidates[i]==candidates[i-1] | 跳过所有重复元素,包括不同层的合法选择 | i > start && ... |
不排序直接去重 | 无法保证重复元素相邻,去重失效 | 必须先排序 |
6.2 为什么必须先排序?
- 排序后重复元素相邻,才能通过
candidates[i]==candidates[i-1]
检测重复 - 示例:未排序数组
[1,2,1]
,去重条件无法检测到第一个和第三个1
七、扩展问题与解题模板
7.1 通用组合问题模板(含去重)
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
void backtrack(int[] nums, int target, int start) {if (target == 0) {res.add(new ArrayList<>(path));return;}for (int i = start; i < nums.length; i++) {// 剪枝1:值超过目标if (nums[i] > target) break;// 剪枝2:去重(核心)if (i > start && nums[i] == nums[i-1]) continue;path.add(nums[i]);backtrack(nums, target - nums[i], i + 1); // 不可重复选path.remove(path.size() - 1);}
}
7.2 变种问题处理
-
元素可重复且含重复元素:
- 递归参数改为
backtrack(i)
(同39题) - 去重条件不变,但允许不同层选相同元素
- 递归参数改为
-
组合顺序不同算不同解:
- 去掉
start
参数,每次从0开始搜索 - 如LeetCode 377.组合总和IV
- 去掉
八、调试与优化技巧
8.1 可视化去重过程
// 调试打印
System.out.println("当前层start=" + start + ", i=" + i + ", num=" + candidates[i] + ", 去重条件:" + (i > start && candidates[i] == candidates[i-1]));
8.2 大数据集优化
- 提前去重:
// 预处理去重+排序 Arrays.sort(candidates); List<Integer> unique = new ArrayList<>(); for (int i=0; i<candidates.length; i++) {if (i==0 || candidates[i]!=candidates[i-1]) {unique.add(candidates[i]);} }
- 记忆化搜索:对相同target的子问题缓存结果
九、总结:含重复元素的组合问题解题框架
-
三步核心流程:
- 排序数组使重复元素相邻
- 回溯时用
start
控制元素使用范围 - 通过
i > start && nums[i]==nums[i-1]
跳过重复选择
-
关键参数理解:
start
:当前层可选择的最小索引,避免重复组合i > start
:确保同一层中相同元素只处理一次i+1
:递归时跳过当前元素,实现"每个元素只用一次"
-
拓展应用:
- 子集问题(78题)去重
- 排列问题(46题)去重
- 组合总和III(216题)等变体
掌握这套去重策略,可系统性解决所有含重复元素的组合搜索问题,核心在于理解"层内去重"与"层间允许"的逻辑差异。