目录

题目描述

LCR 081. 组合总和

题意分析

给一个元素互不相同的正整数数组 candidates 和一个正整数 target,要求列出所有和恰好等于 target 的选法。每个数字可以被选任意多次,最终返回的是「组合」而不是「排列」——[2,2,3][3,2,2] 视为同一个答案,只能出现一次。

题目要的是全部方案本身,不是方案数量,这一点直接排除了计数型递推:必须把每一条选择路径真正走出来并记录下来。凡是「输出所有具体方案」的题,时间复杂度天然与答案规模同阶,只能靠搜索加剪枝。

数据规模给得极小:数组长度不超过 30,每个元素在 [2, 40] 之间,target 不超过 40。这组约束透露了两个关键信号。第一,元素全部大于等于 2 且为正,所以沿着一条路径累加的和是严格单调递增的,一旦超过 target 就再无回头可能,可以立刻剪掉——这也是为什么本题不需要担心「无限重复选取」导致的死循环。第二,target / min(candidates) 最多 20 层,搜索树深度受控,指数级枚举在这个规模下完全跑得动。

去重是本题唯一真正的难点。同一个数可以重复使用,所以不能简单地「用过就标记」;而排列顺序不同却元素相同的路径必须被认为是重复。边界上要注意:数组元素互不相同(这是与「组合总和 II」的分水岭),target 可能小于所有元素从而无解,此时返回空列表而不是包含空组合的列表。

解法:回溯搜索

核心思路

最朴素的想法是:枚举每个位置放哪个数字,一层一层往下填,填到和等于 target 为止。但这样枚举出来的是序列——2→2→33→2→2 会被当成两条不同的路径分别记录。瓶颈就在这里:搜索天然带顺序,而答案不带顺序。

关键观察是:任何一个多重集合,都有且只有一种「按下标非递减排列」的写法。既然如此,只要强制搜索只沿着「下标不减」的方向前进,每个合法集合就恰好被走到一次,去重问题在搜索结构层面被彻底消解,完全不需要事后用哈希集合过滤。

于是给递归函数配三个参数:s 表示当前路径上已选数字之和,u 表示本层允许选取的最小下标t 表示当前路径。要维持的不变量是:t 中的下标序列非递减,s 恒等于 t 中所有元素之和,且 t 里最后一个元素的下标不超过 u。选中下标 i 之后向下递归时传入的仍然是 i 而不是 i + 1,因为同一个数允许被再次选取;传入 i 而不是 0,就把「回头选更小下标」这条路封死了。

递归的终止与剪枝同样由不变量推出:s == target 时当前路径正好构成一个答案,拷贝一份存入结果;s > target 时由于元素全为正,继续往下走只会让 s 更大,这条分支必然无解,直接返回。这两个判断合起来保证了递归一定会终止。

解题步骤

  • 准备结果容器与共享状态answer 存放全部答案,candidatestarget 提升为成员变量。之所以不层层传参,是因为它们在整个搜索过程中恒定不变,作为共享状态可以让递归签名只保留真正会变化的三个量,读代码时一眼能看出「什么在变」。
  • 命中答案时深拷贝路径s == target 时执行 answer.add(new ArrayList<>(t))。必须新建一份副本,因为 t 是全程复用的同一个对象,回溯时会被不断修改;直接把 t 的引用塞进结果,最后所有答案都会变成同一个空列表。
  • 超额时立刻返回s > target 直接剪枝。这一步的前提是元素全为正——如果允许出现 0 或负数,这个剪枝不成立,而且「可无限重复选取」会导致搜索不终止。
  • 从下标 u 开始横向枚举for (int i = u; i < candidates.length; ++i)。起点是 u 而不是 0,这正是去重的实现;终点是数组末尾,保证每个候选都有机会被选。
  • 选择、递归、撤销三连t.add(c) 记录选择,dfs(s + c, i, t) 带着新的和向下走,t.remove(t.size() - 1) 撤销选择。撤销这一步不可省——t 是共享的可变对象,不还原就会把当前分支的残留带进兄弟分支。递归传入 i 而非 i + 1,正是「同一元素可重复选取」的直接编码。

candidates = [2, 3, 6, 7]target = 7 走一遍。

根节点 dfs(s=0, u=0, t=[]),从下标 0 开始横向枚举。

先选下标 0 的 2,进入 dfs(2, 0, [2]);再选 2,进入 dfs(4, 0, [2,2]);再选 2,进入 dfs(6, 0, [2,2,2]);再选 2 得 s=8 > 7,剪枝返回;改选下标 1 的 3 得 s=9 > 7,剪枝;6、7 更大同样剪枝,于是 [2,2,2] 这一支全部失败,回溯到 dfs(4, 0, [2,2])

dfs(4, 0, [2,2]) 里改选下标 1 的 3,s = 4 + 3 = 7 正好命中,记下第一个答案 [2,2,3];再试下标 2 的 6 得 10、下标 3 的 7 得 11,均剪枝。回溯到 dfs(2, 0, [2])

dfs(2, 0, [2]) 里改选 3 得 dfs(5, 1, [2,3]),其下 3 得 8、6 得 11、7 得 12 全部超额,无解;再改选 6 得 8、7 得 9,均剪枝。注意这里 u = 1,所以 [2,3] 之下不会再回头去选下标 0 的 2——[2,3,2] 这条与 [2,2,3] 重复的路径被结构性地屏蔽了。

回到根节点,改选下标 1 的 3:dfs(3,1,[3]) 之下 3 得 6,再往下 3 得 9、6 得 12、7 得 13 全超额;6 得 9、7 得 10 超额,这一支无解。改选下标 2 的 6:dfs(6,2,[6]) 之下 6 得 12、7 得 13,无解。改选下标 3 的 7:s = 7 命中,记下第二个答案 [7]

最终 answer = [[2,2,3],[7]]。若把递归里的 i 误写成 0[3,2,2][2,3,2] 都会被额外产出;若误写成 i + 1[2,2,3] 就再也搜不到,答案只剩 [7]

代码实现

class Solution {
    private List<List<Integer>> answer;
    private int target;
    private int[] candidates;

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        answer = new ArrayList<>();
        this.target = target;
        this.candidates = candidates;
        dfs(0, 0, new ArrayList<>());
        return answer;
    }

    // s:当前路径和;u:本层允许选取的最小下标;t:当前路径。
    private void dfs(int s, int u, List<Integer> t) {
        if (s == target) {
            // t 全程复用,必须深拷贝一份再存入结果。
            answer.add(new ArrayList<>(t));
            return;
        }
        if (s > target) {
            // 元素全为正,继续向下只会更大,直接剪枝。
            return;
        }
        for (int i = u; i < candidates.length; ++i) {
            int c = candidates[i];
            t.add(c);
            // 传 i 而非 i + 1:同一元素允许重复选取;传 i 而非 0:禁止回头,实现组合去重。
            dfs(s + c, i, t);
            t.remove(t.size() - 1);
        }
    }
}
func combinationSum(candidates []int, target int) [][]int {
    var answer [][]int

    // s:当前路径和;u:本层允许选取的最小下标;t:当前路径。
    var dfs func(s, u int, t []int)
    dfs = func(s, u int, t []int) {
        if s == target {
            // t 底层数组会被后续 append 覆写,必须拷贝一份再存入结果。
            answer = append(answer, append([]int(nil), t...))
            return
        }
        if s > target {
            // 元素全为正,继续向下只会更大,直接剪枝。
            return
        }
        for i := u; i < len(candidates); i++ {
            c := candidates[i]
            t = append(t, c)
            // 传 i 而非 i+1:同一元素允许重复选取;传 i 而非 0:禁止回头,实现组合去重。
            dfs(s+c, i, t)
            t = t[:len(t)-1]
        }
    }

    var t []int
    dfs(0, 0, t)
    return answer
}

复杂度分析

  • 时间复杂度:$O(S \times L)$,其中 $S$ 是答案个数,$L$ 是单个答案的平均长度,拷贝路径的开销摊在每个答案上。若用搜索树的粗略上界表达,设 $m$ 为候选个数、$d = target / \min(candidates)$ 为最大深度,则节点数不超过 $O(m^d)$;本题 $d \le 20$ 且剪枝极强,实际访问的节点远少于该上界。输出全部方案的题目,时间复杂度必然不低于答案总规模。
  • 空间复杂度:$O(d)$,其中 $d = target / \min(candidates)$ 是递归最大深度,路径 t 的长度与递归栈深度同阶。返回值 answer 本身是题目要求的输出,按惯例不计入额外空间。

关键点总结

  • 「组合去重」的通用手法是给搜索强加一个下标非递减的方向,让每个多重集只有唯一一条生成路径。这个模板可以原样迁移到子集、组合、组合总和系列的所有题目,比事后用 Set 去重高效得多,也是面试官想听到的答案。
  • i 还是 i + 1 决定了元素能否复用,是这一族题目的开关:可重复选取传 i,每个元素最多用一次传 i + 1。面试时被追问「如果每个数只能用一次呢」,改一个字符即可,能当场答出来是很直接的加分项。
  • 剪枝的合法性依赖题目约束s > target 就返回,成立的前提是所有元素为正。面试中主动说出这个前提,比单纯写出剪枝更能体现严谨性。
  • 回溯三件套「选择 → 递归 → 撤销」必须成对出现,共享可变路径是回溯节省内存的原因,也是必须手动还原现场的原因。
  • 记录答案时必须深拷贝,这是所有「收集路径」类回溯题的固定动作,与题目本身无关。

易错点总结

  • 递归时传 0 而不是 icandidates = [2,3]target = 5 会同时产出 [2,3][3,2],答案数量翻倍,判定直接失败。
  • 递归时传 i + 1 而不是 icandidates = [2,3,6,7]target = 7 会丢掉 [2,2,3],只剩 [7],因为每个元素最多被用一次。
  • 忘记 t.remove(t.size() - 1)candidates = [2,3]target = 5 时路径 [2,2] 失败后不还原,兄弟分支会带着残留的 2 继续,产出 [2,2,3] 这种和为 7 的非法结果。
  • 直接把 t 存进 answer 而不深拷贝candidates = [2,3,6,7]target = 7 最终会得到两个空列表 [[],[]],因为搜索结束时 t 已被回溯清空,两条结果指向同一个对象。
  • Go 里写成 answer = append(answer, t)t 与后续 append 共享底层数组,[2,2,3] 存进去之后会被兄弟分支覆写成 [2,3,6] 之类的乱值,必须 append([]int(nil), t...) 拷贝。
  • s > target 的判断放在 s == target 之后却写成 s >= targetcandidates = [7]target = 7s == target 的分支永远进不去,答案为空。
  • s == target 之外的方式判终止,例如先递归再判断target = 1candidates = [2] 时递归会先入栈再判断,虽然结果正确,但少了 s > target 剪枝时 candidates 含小值会让深度暴涨到栈溢出。
  • 为了去重先排序再用 Set<List<Integer>> 过滤candidates = [2,3,6,7]target = 7 结果虽对,但搜索树规模仍是排列级,target 稍大就超时,而且面试官会认为没抓住组合去重的本质。
  • 误以为数组已经有序而加了 candidates[i] > target - sbreak:题目并未保证 candidates 有序,candidates = [7,2,3,6] 时会在下标 0 处直接 break,漏掉 [2,2,3];要用这个更强的剪枝,必须先自己排序。

相似题目

题目 难度 考察点
39. 组合总和 中等 与本题完全同题,代码可原样提交
40. 组合总和 II 中等 候选含重复元素且每个只能用一次,需先排序并在同层跳过相同值
77. 组合 中等 固定长度 k 的组合,终止条件从「和达标」换成「长度达标」
216. 组合总和 III 中等 候选固定为 1~9 且元素不可复用,同时限制个数与总和两个维度
LCR 080. 组合 中等 与 77 同题,是本题去掉「和」约束后的最简形态
LCR 082. 组合总和 II 中等 与 40 同题,重点对比「传 i」与「传 i+1 加同层去重」的写法差异
78. 子集 中等 无任何和的限制,每个搜索节点都直接是答案,是本题模板的退化版
377. 组合总和 Ⅳ 中等 顺序不同视为不同方案且只要方案数,应改用完全背包计数 DP 而非回溯