题目描述
思路解析
一句话答案:LeetCode 377 组合总和 Ⅳ 名为组合、实为排列计数:顺序不同算不同。一维 DP,dp[t]=凑出 t 的排列数,外层枚举总和、内层枚举末尾数,累加 dp[t−num]。时间 O(target×n)、空间 O(target)。
题目叫组合,为什么算的是排列
给数组 nums(无重复正整数)和目标 target,求和为 target 的方案数。名字叫「组合」,示例却拆穿它:nums = [1,2,3]、target = 6 时答案是 24,而 1+2 和 2+1 被算成两种。顺序不同即不同,本质是排列。
真正要数的是「有序的凑数序列」有多少条,摆错循环顺序就把排列数得成组合数。
为什么不能把所有序列枚举一遍
最笨的办法是把每条凑数序列都摆出来数,可分支数随 target 指数级膨胀,稍大就数不完。慢在重复:凑出总和 5 的序列,算总和 6、7 时又被反复重走。既然「凑出总和 t 的排列数」能独立回答,存下来复用即可。
dp 的下标是总和,值是排列数
定义 dp[t] 为「凑出总和 t 的排列数」。下标 t 是总和值不是位置,长度 target+1。
起点 dp[0] = 1,凑出总和 0 只有一种方案——一个数都不选的空序列。它是所有递推的源头,若误设成 0,整张表全归零,答案变 0。
转移为什么按最后一步分类相加
凑出 t 的每条排列,最后一个数总是确定的,就按这个末尾数分堆。末尾是 num 的那堆,把末尾去掉,剩下的正好是一条凑出 t−num 的排列,两者一一对应,所以这堆恰好有 dp[t−num] 条。对每个 num ≤ t 各分一堆,堆间不重、合起来不漏,于是 dp[t] = 各个 dp[t−num] 相加。
命门在循环顺序。这里外层遍历总和 t、内层遍历 nums,对每个 t 都把「末尾可以是任意一个 num」枚举一遍,同一个数在不同位置被反复计入,数出的是排列。LeetCode 518 零钱兑换 II 求组合数,循环正好反过来——外层遍历硬币、内层遍历金额,每种硬币只按固定次序出现,顺序被抹掉,得到组合。所以循环顺序不是细节:外层放谁,数出来就是谁。
手工演算:拿 nums=[1,2,3] 凑到 6
先铺 dp[0] = 1,往后每个 t 把 num ≤ t 的 dp[t−num] 加起来。
dp[1] = dp[0] = 1。dp[2] = dp[1] + dp[0] = 2。dp[3] = dp[2] + dp[1] + dp[0] = 2 + 1 + 1 = 4。dp[4] = dp[3] + dp[2] + dp[1] = 4 + 2 + 1 = 7。dp[5] = dp[4] + dp[3] + dp[2] = 7 + 4 + 2 = 13。dp[6] = dp[5] + dp[4] + dp[3] = 13 + 7 + 4 = 24。
dp[6] = 24,即凑出 6 的全部排列数,和示例输出一致。
复杂度怎么算,循环写反和溢出两个坑
外层 target 个总和、内层扫 n 个数,时间 O(target×n);只用一条 target+1 长的数组,空间 O(target)。
两个坑。一是循环顺序写反:外层遍历 nums、内层遍历 t,数出来是组合数而非排列数,会偏小。二是整数溢出:target 大时排列数会超出 32 位整数范围,C++/Java 要用 long 存 dp。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
记住一句:dp[t] 把「最后一步选谁」全枚举一遍,每个选择把对应 dp[t−num] 加进来。
边界:先填边界:凑出和 0,只有"一个数都不选"这一种 → dp[0]=1。
dp[1] 来路·选 1:凑和 1:最后一个数选 1,剩下要凑 0 → 把 dp[0]=1 加进来。
落子 dp[1]=1:1 条来路全加完:dp[1] = 1。
dp[2] 来路·选 1:凑和 2:最后一个数选 1,剩下要凑 1 → 把 dp[1]=1 加进来。
dp[2] 来路·选 2:凑和 2:最后一个数选 2,剩下要凑 0 → 把 dp[0]=1 加进来。
落子 dp[2]=2:2 条来路全加完:dp[2] = 2。
dp[3] 来路·选 1:凑和 3:最后一个数选 1,剩下要凑 2 → 把 dp[2]=2 加进来。
dp[3] 来路·选 2:凑和 3:最后一个数选 2,剩下要凑 1 → 把 dp[1]=1 加进来。
dp[3] 来路·选 3:凑和 3:最后一个数选 3,剩下要凑 0 → 把 dp[0]=1 加进来。
落子 dp[3]=4:3 条来路全加完:dp[3] = 4。
dp[4] 来路·选 1:凑和 4:最后一个数选 1,剩下要凑 3 → 把 dp[3]=4 加进来。
dp[4] 来路·选 2:凑和 4:最后一个数选 2,剩下要凑 2 → 把 dp[2]=2 加进来。
dp[4] 来路·选 3:凑和 4:最后一个数选 3,剩下要凑 1 → 把 dp[1]=1 加进来。
落子 dp[4]=7:3 条来路全加完:dp[4] = 7。
dp[5] 来路·选 1:凑和 5:最后一个数选 1,剩下要凑 4 → 把 dp[4]=7 加进来。
dp[5] 来路·选 2:凑和 5:最后一个数选 2,剩下要凑 3 → 把 dp[3]=4 加进来。
dp[5] 来路·选 3:凑和 5:最后一个数选 3,剩下要凑 2 → 把 dp[2]=2 加进来。
落子 dp[5]=13:3 条来路全加完:dp[5] = 13。
dp[6] 来路·选 1:凑和 6:最后一个数选 1,剩下要凑 5 → 把 dp[5]=13 加进来。
dp[6] 来路·选 2:凑和 6:最后一个数选 2,剩下要凑 4 → 把 dp[4]=7 加进来。
dp[6] 来路·选 3:凑和 6:最后一个数选 3,剩下要凑 3 → 把 dp[3]=4 加进来。
落子 dp[6]=24:3 条来路全加完:dp[6] = 24。
答案:dp[6]=24 就是凑出 6 的全部组合数。一维 DP 一遍扫完,O(target × nums) 搞定。
边界三连:凑不出时不是报错,而是自然得 0——因为没有任何 num 能落进 dp[t] 的累加,那一格保持初始的 0。
面试追问:把「为什么是排列」「负数为何失效」「与完全背包的循环顺序关系」讲清,是这题最容易被追问的三点。
参考代码
class Solution: def combinationSum4(self, nums, target): dp = [0] * (target + 1) dp[0] = 1 # 凑出 0 有空方案这一种 for t in range(1, target + 1): # 外层:总和 t for num in nums: # 内层:最后取哪个 num if num <= t: dp[t] += dp[t - num] # 累加 dp[t-num] return dp[target]复杂度
- 时间复杂度:O(target × n),每个总和 t 都扫一遍 nums(n 个数),共 target 个总和
- 空间复杂度:O(target),一维 dp 数组,长度 target+1
易错点
面试追问把动画讲成自己的话
追问题目叫「组合」,为什么算的是排列?
追问如果 nums 含负数会怎样?
追问和完全背包求方案数有什么联系?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
零钱兑换 II
LeetCode 518 · 中等 · 沿着 完全背包套路 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题