从模板到五道 LeetCode:完全背包的 Golang 教学笔记
覆盖题目:518. 零钱兑换 II、322. 零钱兑换、377. 组合总和 IV、279. 完全平方数、139. 单词拆分。
目标:不靠死记模板,而是从「状态含义、初始化、转移、遍历顺序」四个维度,判断一题为什么是完全背包、该怎样写一维 DP。
大家好,欢迎来到晚风喧嚣的Coding频道,今天给大家带来的是完全背包的教程,希望能对大家的算法学习起到一定帮助!

1. 先建立完整模型:什么是完全背包?
背包问题由两类对象组成:
- 物品:硬币、数字、平方数、单词等;
- 背包容量 :金额、目标和、整数
n、字符串前缀长度等。
如果每种物品可使用任意次,就是完全背包 。例如金额 11 可以使用任意多个面值为 1 的硬币;单词 "apple" 也可以在一次拆分中反复使用。
与 0-1 背包相比,决定循环方向的关键区别是:
| 模型 | 每种物品可用次数 | 一维 DP 遍历容量 |
|---|---|---|
| 0-1 背包 | 最多一次 | 从大到小 |
| 完全背包 | 无限次 | 从小到大 |
完全背包容量正序的原因很直接:当计算 dp[j] 时,dp[j-w] 可以是本轮物品刚刚更新过的值,于是当前物品自然能够重复使用。
1.1 二维状态如何推导
设第 i 类物品的代价为 w[i],目标容量为 C。
text
dp[i][j] = 只使用前 i 类物品,得到容量/目标 j 的答案
不选第 i 类物品时,答案来自 dp[i-1][j];至少选一个第 i 类物品时,去掉一个它之后仍然可以继续选它,因此来自同一行的 dp[i][j-w[i]]:
text
dp[i][j] = 合并(dp[i-1][j], dp[i][j-w[i]])
这里的"合并"取决于题目:可能是加法、min、max 或逻辑或。
1.2 一维优化与最常见模板
压缩掉物品这一维后,完全背包的基本形态是:
go
for _, item := range items {
for cap := item; cap <= target; cap++ { // 正序:允许重复使用 item
dp[cap] = merge(dp[cap], dp[cap-item])
}
}
这段模板并不是所有题都能原样套用。特别是计数题还要区分"组合"与"排列":
物品在外、容量在内:同一组物品只按一种次序统计,得到组合数;容量在外、物品在内:每个容量都尝试最后放入哪件物品,得到排列数。
例如 1 + 2 和 2 + 1:在组合问题中应只算一次,在排列问题中应算两次。
2. 写完全背包前,先回答四个问题
不要先背循环。先按下面四步建模,遍历顺序通常就会自己浮现。
2.1 dp[j] 到底表示什么?
常见的四种语义:
| 问题类型 | dp[j] 的含义 |
常用转移 |
|---|---|---|
| 组合数 | 凑出 j 的无序方案数 |
dp[j] += dp[j-item] |
| 排列数 | 凑出 j 的有序方案数 |
dp[j] += dp[j-item] |
| 最小值 | 凑出 j 的最少物品数 |
dp[j] = min(dp[j], dp[j-item]+1) |
| 可行性 | 是否能凑出/拼出 j |
`dpj = dpj |
转移写法可能相同,但状态含义不同,外层循环也可能不同。
2.2 基础状态如何初始化?
dp[0] 是完全背包最容易漏掉的地方:
| 目标 | 初始化 | 原因 |
|---|---|---|
| 方案数 | dp[0] = 1 |
凑出 0 有一种方案:什么都不选;它是后续计数的起点 |
| 最小值 | dp[0] = 0,其余为无穷大 |
凑出 0 不需要物品,未知状态不能误参与最小值比较 |
| 可行性 | dp[0] = true |
空前缀/容量 0 天然可达 |
2.3 为什么完全背包一定是正序容量?
以硬币 coin = 2 为例,正序计算 dp[2]、dp[4]、dp[6]:
text
dp[4] 会读取本轮已经更新的 dp[2]
dp[6] 会读取本轮已经更新的 dp[4]
这正是"同一种硬币可以多次使用"的来源。反序遍历会隔离本轮更新,效果更像 0-1 背包。
2.4 什么时候物品在外,什么时候容量在外?
text
要统计方案数吗?
├─ 否:最小值 / 可行性
│ ├─ 普通数值目标:两种外层顺序通常都可正确求值,推荐物品在外,容量正序
│ └─ 字符串前缀:位置在外,单词在内
└─ 是:是否把选择顺序算作不同答案?
├─ 否:组合数 → 物品在外,容量在内
└─ 是:排列数 → 容量在外,物品在内
下面五题正好覆盖这四种语义。
3. 518. 零钱兑换 II:求组合数
题目描述
给定整数 amount 和硬币数组 coins。每种硬币可以使用无限次,问凑出总金额 amount 的组合数。硬币使用顺序不同但面值集合相同,只算一种组合。
例如 amount = 5、coins = [1, 2, 5],答案为 4:
text
5
2 + 2 + 1
2 + 1 + 1 + 1
1 + 1 + 1 + 1 + 1
建模
- 物品:硬币面值;
- 容量:金额;
dp[j]:只使用已遍历过的硬币,凑出金额j的组合数;- 转移:最后再加入一枚面值为
coin的硬币,dp[j] += dp[j-coin]; - 初始化:
dp[0] = 1。
必须让硬币在外层 。这样处理到面值 2 时,dp[5] 只会使用不大于当前处理顺序的硬币类型,不会把 1+2 和 2+1 重复统计。
Golang 代码
go
func change(amount int, coins []int) int {
dp := make([]int, amount+1)
dp[0] = 1
for _, coin := range coins { // 物品在外:统计组合
for sum := coin; sum <= amount; sum++ { // 正序:硬币可重复使用
dp[sum] += dp[sum-coin]
}
}
return dp[amount]
}
复杂度与易错点
- 时间复杂度:
O(len(coins) * amount);空间复杂度:O(amount)。 - 如果交换两层循环,就会统计排列。例如
1+2和2+1都被计入,答案会变大。 dp[0]必须是1;若为0,所有状态都无法累加出方案数。
4. 322. 零钱兑换:求最少硬币数
题目描述
给定硬币数组 coins 和金额 amount,每种硬币无限使用。求凑出 amount 所需的最少硬币数;若无法凑出则返回 -1。
建模
- 物品:硬币;容量:金额;
dp[j]:凑出金额j所需的最少硬币数;- 转移:
dp[j] = min(dp[j], dp[j-coin] + 1); - 初始化:
dp[0] = 0,其余状态设为一个足够大的值。
这里不再统计不同顺序,因此"硬币在外"与"金额在外"都可以得到最小值。为了和完全背包模板保持一致,下面使用硬币在外、金额正序。
Golang 代码
go
func coinChange(coins []int, amount int) int {
const inf = int(1e9)
dp := make([]int, amount+1)
for i := 1; i <= amount; i++ {
dp[i] = inf
}
for _, coin := range coins {
for sum := coin; sum <= amount; sum++ {
if dp[sum-coin] != inf {
dp[sum] = min(dp[sum], dp[sum-coin]+1)
}
}
}
if dp[amount] == inf {
return -1
}
return dp[amount]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
为什么不能把所有状态初始为 0?
0 在这里代表"凑出 0 元只需 0 枚",不是"尚不可达"。如果 dp[7] 初始就是 0,最小值转移会错误地把它当成一个很优的答案。
常见写法也可以用 amount + 1 作为无穷大:最坏情况下,如果金额能凑出,最多使用 amount 枚面值为 1 的硬币,因此 amount + 1 一定更差。
复杂度与易错点
- 时间复杂度:
O(len(coins) * amount);空间复杂度:O(amount)。 - 这是"最优化"而不是"计数",不要写
+=。 - 返回
-1前,应判断目标状态是否仍是无穷大。
5. 377. 组合总和 IV:求排列数
题目描述
给定元素互不相同的正整数数组 nums 和目标值 target。每个数字可重复使用,求和为 target 的有序序列数量。
例如 nums = [1, 2, 3]、target = 4:
text
1 + 1 + 2
1 + 2 + 1
2 + 1 + 1
这三种顺序不同,因此是三种不同答案。
建模
- 物品:
nums中的数字;容量:目标和; dp[sum]:凑出和sum的有序序列数;- 转移:若最后一个数选
num,前面部分为sum-num,故dp[sum] += dp[sum-num]; - 初始化:
dp[0] = 1。
关键在于"最后一个数"。为了让每个目标和都能枚举最后放哪个数字,必须让容量在外层、物品在内层。
Golang 代码
go
func combinationSum4(nums []int, target int) int {
dp := make([]int, target+1)
dp[0] = 1
for sum := 1; sum <= target; sum++ { // 容量在外:统计排列
for _, num := range nums {
if sum >= num {
dp[sum] += dp[sum-num]
}
}
}
return dp[target]
}
与零钱兑换 II 的本质区别
两题转移式都写成 dp[sum] += dp[sum-item],但含义不同:
| 题目 | 外层循环 | 得到的答案 |
|---|---|---|
| 零钱兑换 II | 硬币 | 组合:1+2 与 2+1 算一次 |
| 组合总和 IV | 目标和 | 排列:1+2 与 2+1 算两次 |
所以不要只背转移式;循环顺序本身就是状态定义的一部分。
复杂度与易错点
- 时间复杂度:
O(target * len(nums));空间复杂度:O(target)。 - 数组元素必须为正数。若允许 0 或负数,无限选取会引入环,方案数可能不再有限。
- 题目保证答案在整数范围内;若题目未给这个保证,应考虑
int64或取模。
6. 279. 完全平方数:把"物品"隐式生成
题目描述
给定正整数 n,求和为 n 的完全平方数(1, 4, 9, 16, ...)的最少数量。每个平方数可以重复使用。
例如 n = 12,可以拆成 4 + 4 + 4,答案为 3。
建模
这题没有直接给物品数组,但平方数就是物品:
text
1², 2², 3², ..., floor(sqrt(n))²
- 物品:不超过
n的完全平方数; - 容量:
n; dp[j]:和为j的最少完全平方数数量;- 转移:
dp[j] = min(dp[j], dp[j-square] + 1); - 初始化:
dp[0] = 0,其余为无穷大。
它和零钱兑换完全同构:把"硬币面值"换成"平方数"即可。
Golang 代码
go
func numSquares(n int) int {
inf := n + 1
dp := make([]int, n+1)
for i := 1; i <= n; i++ {
dp[i] = inf
}
for base := 1; base*base <= n; base++ {
square := base * base
for sum := square; sum <= n; sum++ {
dp[sum] = min(dp[sum], dp[sum-square]+1)
}
}
return dp[n]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
复杂度与易错点
- 平方数种类有
O(sqrt(n))个,因此时间复杂度为O(n * sqrt(n));空间复杂度为O(n)。 base*base <= n是生成物品的边界。- 这也是最小值问题,容量在外、平方数在内同样可以正确求最小值;这里保留"物品在外"的标准完全背包写法,便于和零钱兑换对应。
7. 139. 单词拆分:背包容量变成字符串位置
题目描述
给定字符串 s 和单词字典 wordDict。每个字典单词可重复使用。若能由一个或多个字典单词按顺序拼接出 s,返回 true;否则返回 false。
例如:
text
s = "applepenapple"
wordDict = ["apple", "pen"]
apple + pen + apple → true
为什么它也是完全背包?
每个单词可以重复使用,符合完全背包的"物品无限使用"。区别在于:容量不是金额,而是字符串前缀长度。
text
dp[i] = s 的前 i 个字符,是否能被字典单词拼出
若最后一个单词为 word,长度为 L,就要满足:
text
dp[i-L] 为 true
且 s[i-L : i] == word
因此:
text
dp[i] = dp[i] || (dp[i-L] && s[i-L:i] == word)
Golang 代码
go
func wordBreak(s string, wordDict []string) bool {
n := len(s)
dp := make([]bool, n+1)
dp[0] = true
for end := 1; end <= n; end++ { // 字符串位置 / 背包容量在外
for _, word := range wordDict {
length := len(word)
if end >= length &&
dp[end-length] &&
s[end-length:end] == word {
dp[end] = true
break
}
}
}
return dp[n]
}
为什么不能简单地"先遍历单词、再遍历位置"?
下面的单次扫描会错误限制不同单词种类的使用顺序:
text
for each word:
for each end position:
update dp[end]
反例就是 "applepenapple"。若先处理 apple,只能得到前缀长度 5;处理 pen 后才得到长度 8,但此时已不会再回头使用 apple,无法到达长度 13。
单词拆分要表达的是"当前前缀能否由某个合法的最后单词到达",所以应让位置在外层、单词在内层。这与组合总和 IV 的"枚举最后一个数字"有相似之处,但它是布尔可行性问题,并且每一次转移都必须额外检查字符串切片是否匹配。
复杂度与易错点
- 若字典有
m个单词、平均长度为L,比较字符串的写法时间复杂度约为O(n * m * L),空间复杂度为O(n)。 - 上面代码遵循 LeetCode 的英文小写字母约束,
len和字符串切片按字节工作。若题目允许任意 Unicode 文本,需要按[]rune重新建模。 dp[0] = true表示空前缀可由零个单词拼出,是第一个单词能够落地的前提。
8. 五题横向对比:把差异放在一张表里
| 题目 | 物品 | 背包容量/目标 | dp[j] 含义 |
初始化 | 外层循环 | 核心转移 |
|---|---|---|---|---|---|---|
| 518 零钱兑换 II | 硬币 | 金额 | 组合数 | dp[0]=1 |
物品 | += |
| 322 零钱兑换 | 硬币 | 金额 | 最少硬币数 | dp[0]=0,其余 inf |
物品(容量在外也可) | min |
| 377 组合总和 IV | 数字 | 目标和 | 排列数 | dp[0]=1 |
容量 | += |
| 279 完全平方数 | 平方数 | 整数 n |
最少平方数数目 | dp[0]=0,其余 inf |
物品(容量在外也可) | min |
| 139 单词拆分 | 单词 | 前缀长度 | 能否拼出前缀 | dp[0]=true |
位置/容量 | 逻辑或 |
最值得记住的结论:
- 物品无限使用 + 容量正序,是完全背包的骨架。
- 求组合数 :物品在外;求排列数:容量在外。
- 求最小值 :用
min,不可达状态用无穷大;题目不区分顺序时,两种外层顺序往往都正确。 - 判断可行性:用布尔状态;字符串题把"容量"替换为"前缀位置"。
dp[0]不是可有可无的细节,而是所有转移的起点。
9. 四个可复用的 Golang 模板
9.1 完全背包:组合数
go
dp := make([]int, target+1)
dp[0] = 1
for _, item := range items {
for j := item; j <= target; j++ {
dp[j] += dp[j-item]
}
}
适用:零钱兑换 II、目标和组合数量(不区分顺序)。
9.2 完全背包:排列数
go
dp := make([]int, target+1)
dp[0] = 1
for j := 1; j <= target; j++ {
for _, item := range items {
if j >= item {
dp[j] += dp[j-item]
}
}
}
适用:组合总和 IV、爬楼梯类"顺序不同算不同方案"的题目。
9.3 完全背包:最小物品数
go
inf := target + 1
dp := make([]int, target+1)
for j := 1; j <= target; j++ {
dp[j] = inf
}
for _, item := range items {
for j := item; j <= target; j++ {
dp[j] = min(dp[j], dp[j-item]+1)
}
}
适用:零钱兑换、完全平方数。
9.4 完全背包:可行性
go
dp := make([]bool, target+1)
dp[0] = true
for j := 1; j <= target; j++ {
for _, item := range items {
if j >= item && dp[j-item] {
dp[j] = true
break
}
}
}
适用:能否凑出某个目标;单词拆分只是在转移中额外加入"子串是否等于当前单词"的限制。