40. 组合总和 II(Combination Sum II)

难度:中等 | 主题:39 + 同层去重 + 不可重复选

题目

给定一个数组 candidates 和一个目标数 target,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。

示例

 
输入:candidates = [10,1,2,7,6,1,5], target = 8
 
输出:[[1,1,6],[1,2,5],[1,7],[2,6]]
 

思路

先讲个故事:菜市场买鸡蛋,每个只能用一次

你和邻居都去菜市场买菜,预算都是 8 块。

菜摊上有标价的菜,但每样菜只能拿一份(和 39 题不同)。

而且有些菜价格相同(比如有两个 1 块的鸡蛋),但它们是不同个——如果你拿第一个鸡蛋做番茄炒蛋,拿第二个鸡蛋做紫菜蛋花汤,那是同一种组合。

关键:同一层上,相同价格的菜,只用第一份试试就够了。


引导式推导:39 升级了两点

与 39 题的关键区别:

特性3940
元素是否重复无重复可能有重复
能否重复选同一个可重复每个只能用一次
递归参数dfs(i, ...)dfs(i+1, ...)
去重不需要同层去重

同层去重为什么是 i > start 而不是 i > 0


graph TD

    subgraph 同层兄弟

        A["start=0, nums=[1,1,2]"] --> B["i=0,x=1 ✓ 选"]

        A --> C["i=1,x=1 ✗ i>start 跳过"]

        A --> D["i=2,x=2 ✓ 选"]

    end

    subgraph 父子延伸

        E["选了第一个1后递归"] --> F["start=1, i=1,x=1 ✓ <br/>i==start 不跳过"]

        F --> G["得到 [1,1,6]"]

    end

i > start 区分了两种场景:

  • 同层兄弟i=1, start=0i > start → 跳过同值兄弟

  • 父子延伸i=1, start=1i == start → 不跳过,允许 [1,1,6]


决策流程图


flowchart TD

    A["排序 + dfs(0, target)"] --> B["for i in range(start, n)"]

    B --> C{"x > remain?"}

    C -->|"是"| D["break 剪枝"]

    C -->|"否"| E{"i > start<br/>且 x == nums[i-1]?"}

    E -->|"是"| F["continue 去重"]

    E -->|"否"| G["选 x, dfs(i+1, remain-x)"]

    G --> H["pop 回溯"]

    H --> B


代码

 
class Solution:
 
    def combinationSum2(self, candidates: list[int], target: int) -> list[list[int]]:
 
        candidates.sort()
 
        res, path = [], []
 
        def dfs(start: int, remain: int):
 
            if remain == 0:
 
                res.append(path[:])
 
                return
 
            for i in range(start, len(candidates)):
 
                x = candidates[i]
 
                if x > remain:
 
                    break
 
                if i > start and x == candidates[i - 1]:
 
                    continue
 
                path.append(x)
 
                dfs(i + 1, remain - x)
 
                path.pop()
 
        dfs(0, target)
 
        return res
 

复杂度

指标解释
时间O(2ⁿ × n)最坏每个元素选或不选
空间O(n)递归栈深度

实战考量

频率分析

出现在:字节/美团 回溯去重题,约 30% 常会从 39 追问到这题。核心考点是 i > start 的理解

延伸思考

Q:为什么是 i > start 而不是 i > 0

A:i > start 区分同层兄弟(跳过)和父子延伸(允许)。[1,1,6] 中第二个 1 是第一个 1 的子节点,如果用 i > 0 会把 [1,1,6] 砍掉。

Q:去重用 continue 还是 break

A:continue!同层后面可能还有不同值。[1,1,2,3] 跳过第二个 1 后还有 2 和 3。break 用于 x > remain 的剪枝。

Q:为什么必须排序?

A:不排序相同元素不挨着,nums[i] == nums[i-1] 去重失效。

Q:和 39 题的区别?

A:39 无重复 + 可重复选(dfs(i));40 有重复 + 不可重复选(dfs(i+1) + 同层去重)。

Q:dfs(i+1)dfs(i) 的区别?

A:dfs(i+1) 表示当前元素用完后不能再用,dfs(i) 表示可以重复用。

易错点

  • i > start 写成 i > 0

  • 去重用 continue 不用 break

  • dfs(i+1) 不是 dfs(i)

  • 排序不能忘


生活类比

菜市场买菜,同价菜只尝一份

你和邻居预算相同,各自买菜。

菜摊上可能有两个 1 块的鸡蛋。你拿第一个鸡蛋做番茄炒蛋,即使再拿第二个鸡蛋,做出来的还是番茄炒蛋(同样的组合)。

所以同层上相同价格的菜,试第一份就够了,后面的跳过。

但如果你已经拿了第一个鸡蛋,可以在下一层再拿第二个鸡蛋(做成 [鸡蛋, 鸡蛋] 的组合)。

兄弟去重,父子照用——这就是 i > start 的精髓。


相关题目

题目关系
39组合总和无重复基础(可重复选)
90子集II同样的排序+同层去重模板
40组合总和II本题

→ 返回题单:LeetCode学习路线图 > 十、回溯

速记卡(面试闪卡)

Q1:一句话讲清「40. 组合总和 II(Combination Sum II)」到底是什么?

A:在可含重复数的数组里凑出和为 target 的组合,每个数只用一次,同层要去重。

Q2:题目:菜市场同价菜(combination sum with duplicates) —— 怎么理解?

A:你和邻居预算相同去买菜,每样菜只能拿一份,且两个 1 块鸡蛋是不同个——拿第一个做番茄炒蛋、再拿第二个还是同种组合。所以同层相同价只试第一份。

Q3:思路:同层去重 i>start(backtracking dedup) —— 怎么理解?

A:在 39 基础上加两点:递归 dfs(i+1) 保证每个数只用一次;同层用 i>start and x==nums[i-1] 跳过重复兄弟,但父子延伸(i==start)放行,从而保留 [1,1,6]。像兄弟去重、父子照用。

Q4:代码:排序 + 剪枝(sort + prune) —— 怎么理解?

A:先排序让相同数相邻;for i in range(start,n)x>remain 就 break 剪枝,重复则 continue(不是 break)。选 x 后 dfs(i+1, remain-x),回溯 pop。

Q5:复杂度与实战(O(2ⁿ·n) time) —— 怎么理解?

A:时间最坏 O(2ⁿ·n),空间 O(n)。字节美团约 30% 从 39 追问到此,核心考点就是 i>start 而非 i>0;同层去重用 continue 别用 break。

Q6:核心速记主线有哪些?

  • 题目:数组凑 target 组合,每数用一次、同层去重

  • 思路:dfs(i+1) 不重复选,i>start 跳同值兄弟

  • 代码:先排序,x>remain break,重复 continue

  • 复杂度:时间 O(2ⁿ·n)、空间 O(n)

  • 实战:i>start 非 i>0,去重用 continue

口诀

A:同价菜只尝一份先,

兄弟重复跳一边;

子承父业接着选,

组合不漏也不偏。

相关链接