从模板到五道 LeetCode:完全背包的 Golang 教学笔记

从模板到五道 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]])

这里的"合并"取决于题目:可能是加法、minmax 或逻辑或。

1.2 一维优化与最常见模板

压缩掉物品这一维后,完全背包的基本形态是:

go 复制代码
for _, item := range items {
    for cap := item; cap <= target; cap++ { // 正序:允许重复使用 item
        dp[cap] = merge(dp[cap], dp[cap-item])
    }
}

这段模板并不是所有题都能原样套用。特别是计数题还要区分"组合"与"排列":

  • 物品在外、容量在内:同一组物品只按一种次序统计,得到组合数
  • 容量在外、物品在内:每个容量都尝试最后放入哪件物品,得到排列数

例如 1 + 22 + 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 = 5coins = [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+22+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+22+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+22+1 算一次
组合总和 IV 目标和 排列:1+22+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 位置/容量 逻辑或

最值得记住的结论:

  1. 物品无限使用 + 容量正序,是完全背包的骨架。
  2. 求组合数 :物品在外;求排列数:容量在外。
  3. 求最小值 :用 min,不可达状态用无穷大;题目不区分顺序时,两种外层顺序往往都正确。
  4. 判断可行性:用布尔状态;字符串题把"容量"替换为"前缀位置"。
  5. 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
        }
    }
}

适用:能否凑出某个目标;单词拆分只是在转移中额外加入"子串是否等于当前单词"的限制。

相关推荐
Wang's Blog1 小时前
Go-Zero项目开发33: IM实时通信前后端对接与微服务基础设施实践
开发语言·微服务·golang·go-zero
一只小菜鸡..12 小时前
南京大学 操作系统 (JYY) 学习笔记:进程、系统调用与状态机管理
笔记·学习
DeviceHub12 小时前
硬件工程师选型笔记:ALPS RK12L1230C0K 与 Tonevee RK12L1230C0K 电位器 PIN TO PIN 评估指南
笔记
whyTeaFo12 小时前
GAMES101: Lecture 13: Ray Tracing 1 (Whitted-Style Ray Tracing) ppt笔记
笔记
Wang's Blog12 小时前
Go-Zero 项目开发22:用户群聊功能的实现与完善
开发语言·golang
Wang's Blog15 小时前
Go-Zero项目开发24: 基于Bitmap实现群聊消息已读未读
开发语言·后端·golang
keep intensify17 小时前
最长有效括号
算法·leetcode·动态规划
CoderYanger17 小时前
A.每日一题:1979. 找出数组的最大公约数
java·程序人生·算法·leetcode·面试·职场和发展·学习方法
XS03010617 小时前
SpringMVC核心知识点实操笔记
笔记