LeetCode 40中等回溯 · 去重
组合总和 II 图解题解
这道题到底在问什么
给定候选数组 candidates(可能含重复)和目标数 target,找出所有「每个数字只用一次」且和为 target 的不重复组合。
- candidates
- [10,1,2,7,6,1,5]
- target
- 8
- 输出
- [[1,1,6],[1,2,5],[1,7],[2,6]]
最优解:一步一步想明白
- 3记住这三件事:传 i+1(只用一次)→ 同层跳重(去重)→ 超额就剪。下面每一帧就是其中一个动作。
- 4这一层从下标 0 的 1 往下钻:把它加进 path,剩余从 8 减到 7。
- 5path → [1]。进入下一层从下标 1 继续——传 i+1,所以每个数只用一次。
- 6这一层从下标 1 的 1 往下钻:把它加进 path,剩余从 7 减到 6。
- 7path → [1,1]。进入下一层从下标 2 继续——传 i+1,所以每个数只用一次。
- 8这一层从下标 2 的 2 往下钻:把它加进 path,剩余从 6 减到 4。
- 9path → [1,1,2]。进入下一层从下标 3 继续——传 i+1,所以每个数只用一次。
- 10剩余只要凑 4,但下标 3 的 5 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 11这条分支走完,撤销 2(path → [1,1]),回到上一层换下一个候选。
- 12这一层从下标 3 的 5 往下钻:把它加进 path,剩余从 6 减到 1。
- 13path → [1,1,5]。进入下一层从下标 4 继续——传 i+1,所以每个数只用一次。
- 14剩余只要凑 1,但下标 4 的 6 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 15这条分支走完,撤销 5(path → [1,1]),回到上一层换下一个候选。
- 16这一层从下标 4 的 6 往下钻:把它加进 path,剩余从 6 减到 0。
- 17path → [1,1,6]。进入下一层从下标 5 继续——传 i+1,所以每个数只用一次。
- 18剩余 = 0,path [1,1,6] 之和正好 = 8!收进结果。这是一条合法组合。
- 19这条分支走完,撤销 6(path → [1,1]),回到上一层换下一个候选。
- 20剩余只要凑 6,但下标 5 的 7 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 21这条分支走完,撤销 1(path → [1]),回到上一层换下一个候选。
- 22这一层从下标 2 的 2 往下钻:把它加进 path,剩余从 7 减到 5。
- 23path → [1,2]。进入下一层从下标 3 继续——传 i+1,所以每个数只用一次。
- 24这一层从下标 3 的 5 往下钻:把它加进 path,剩余从 5 减到 0。
- 25path → [1,2,5]。进入下一层从下标 4 继续——传 i+1,所以每个数只用一次。
- 26剩余 = 0,path [1,2,5] 之和正好 = 8!收进结果。这是一条合法组合。
- 27这条分支走完,撤销 5(path → [1,2]),回到上一层换下一个候选。
- 28剩余只要凑 5,但下标 4 的 6 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 29这条分支走完,撤销 2(path → [1]),回到上一层换下一个候选。
- 30这一层从下标 3 的 5 往下钻:把它加进 path,剩余从 7 减到 2。
- 31path → [1,5]。进入下一层从下标 4 继续——传 i+1,所以每个数只用一次。
- 32剩余只要凑 2,但下标 4 的 6 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 33这条分支走完,撤销 5(path → [1]),回到上一层换下一个候选。
- 34这一层从下标 4 的 6 往下钻:把它加进 path,剩余从 7 减到 1。
- 35path → [1,6]。进入下一层从下标 5 继续——传 i+1,所以每个数只用一次。
- 36剩余只要凑 1,但下标 5 的 7 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 37这条分支走完,撤销 6(path → [1]),回到上一层换下一个候选。
- 38这一层从下标 5 的 7 往下钻:把它加进 path,剩余从 7 减到 0。
- 39path → [1,7]。进入下一层从下标 6 继续——传 i+1,所以每个数只用一次。
- 40剩余 = 0,path [1,7] 之和正好 = 8!收进结果。这是一条合法组合。
- 41这条分支走完,撤销 7(path → [1]),回到上一层换下一个候选。
- 42剩余只要凑 7,但下标 6 的 10 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 43这条分支走完,撤销 1(path → []),回到上一层换下一个候选。
- 44下标 1 的 1 和同层前一个下标 0 的 1 相同,这一层已经从前一个 1 开过分支了,再选会产出重复组合 → 画 ✗ 剪掉。
- 45这一层从下标 2 的 2 往下钻:把它加进 path,剩余从 8 减到 6。
- 46path → [2]。进入下一层从下标 3 继续——传 i+1,所以每个数只用一次。
- 47这一层从下标 3 的 5 往下钻:把它加进 path,剩余从 6 减到 1。
- 48path → [2,5]。进入下一层从下标 4 继续——传 i+1,所以每个数只用一次。
- 49剩余只要凑 1,但下标 4 的 6 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 50这条分支走完,撤销 5(path → [2]),回到上一层换下一个候选。
- 51这一层从下标 4 的 6 往下钻:把它加进 path,剩余从 6 减到 0。
- 52path → [2,6]。进入下一层从下标 5 继续——传 i+1,所以每个数只用一次。
- 53剩余 = 0,path [2,6] 之和正好 = 8!收进结果。这是一条合法组合。
- 54这条分支走完,撤销 6(path → [2]),回到上一层换下一个候选。
- 55剩余只要凑 6,但下标 5 的 7 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 56这条分支走完,撤销 2(path → []),回到上一层换下一个候选。
- 57这一层从下标 3 的 5 往下钻:把它加进 path,剩余从 8 减到 3。
- 58path → [5]。进入下一层从下标 4 继续——传 i+1,所以每个数只用一次。
- 59剩余只要凑 3,但下标 4 的 6 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 60这条分支走完,撤销 5(path → []),回到上一层换下一个候选。
- 61这一层从下标 4 的 6 往下钻:把它加进 path,剩余从 8 减到 2。
- 62path → [6]。进入下一层从下标 5 继续——传 i+1,所以每个数只用一次。
- 63剩余只要凑 2,但下标 5 的 7 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 64这条分支走完,撤销 6(path → []),回到上一层换下一个候选。
- 65这一层从下标 5 的 7 往下钻:把它加进 path,剩余从 8 减到 1。
- 66path → [7]。进入下一层从下标 6 继续——传 i+1,所以每个数只用一次。
- 67剩余只要凑 1,但下标 6 的 10 已经更大——再选必然超 8,升序下它后面的更大,整层一起放弃,画 ✗。
- 68这条分支走完,撤销 7(path → []),回到上一层换下一个候选。
- 69搜索结束。所有「每个数只用一次、同层不重、不超 8」的路径都试过了,收集到 4 条组合:[[1,1,6],[1,2,5],[1,7],[2,6]]。
⚠️ 容易写错的地方
✗ 错:递归传 i(可重复选)
✓ 对:传 i + 1
LC40 每个数只能用一次,传 i 会把同一位置重复选,混入 LC39 的解
✗ 错:写成 i > 0 判重
✓ 对:是 i > start
i>0 会把「同一条路径上用两个不同位置的 1」也误剪,[1,1,6] 就丢了
✗ 错:不排序就去重 / 剪枝
✓ 对:必须先 sort
不相邻时 nums[i]==nums[i-1] 失效;不升序也无法用 break 剪枝
完整代码(Python / C++ / Java)
Python
def combinationSum2(candidates, target):
candidates.sort() # 排序: 相同数字相邻 + 便于剪枝
res, path = [], []
def bt(start, remain):
if remain == 0:
res.append(path[:]); return
for i in range(start, len(candidates)):
if i > start and candidates[i] == candidates[i-1]:
continue # 同层去重
if candidates[i] > remain:
break # 超 target 剪枝
path.append(candidates[i])
bt(i + 1, remain - candidates[i]) # 传 i+1: 每个数只用一次
path.pop() # 回溯撤销
bt(0, target)
return resC++
class Solution {
public:
vector<vector<int>> res; vector<int> path;
vector<vector<int>> combinationSum2(vector<int>& c, int target){
sort(c.begin(), c.end());
bt(c, 0, target); return res;
}
void bt(vector<int>& c, int start, int remain){
if(remain == 0){ res.push_back(path); return; }
for(int i = start; i < (int)c.size(); i++){
if(i > start && c[i] == c[i-1]) continue; // 同层去重
if(c[i] > remain) break; // 剪枝
path.push_back(c[i]);
bt(c, i + 1, remain - c[i]); // 传 i+1: 只用一次
path.pop_back(); // 回溯
}
}
};Java
public class Main {
static List<List<Integer>> res = new ArrayList<>();
static List<Integer> path = new ArrayList<>();
public static List<List<Integer>> combinationSum2(int[] c, int target){
Arrays.sort(c); // 排序: 相同数字相邻 + 便于剪枝
bt(c, 0, target);
return res;
}
static void bt(int[] c, int start, int remain){
if(remain == 0){ res.add(new ArrayList<>(path)); return; }
for(int i = start; i < c.length; i++){
if(i > start && c[i] == c[i-1]) continue; // 同层去重
if(c[i] > remain) break; // 超 target 剪枝
path.add(c[i]);
bt(c, i + 1, remain - c[i]); // 传 i+1: 每个数只用一次
path.remove(path.size() - 1); // 回溯撤销
}
}
}复杂度
时间
O(2ⁿ · n)
n 个候选每个选/不选构成搜索树,剪枝后远小于此;每条组合拷贝 O(n)
空间
O(n)
递归栈 + path 深度最多 n(不计结果)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 组合总和 II 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
和 LC39 组合总和有什么区别?+
LC39 每个数可重复选:递归传 i、无需同层去重。LC40 每个数只用一次:递归传 i+1,且排序后同层去重 i>start && nums[i]==nums[i-1]。
为什么 i>start 能正确去重又不误伤 [1,1,6]?+
第一个 1 递归进去时 start 变成 1,下一层 i 从 1 起、i==start,不触发去重,所以能再选第二个 1;被剪的只是「同一层」并列的第二个 1 起点。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 组合总和 II 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。