This commit is contained in:
krahets
2023-12-02 06:24:05 +08:00
parent a4a23e2488
commit a7f5434009
93 changed files with 1463 additions and 1484 deletions

View File

@@ -10,7 +10,7 @@ comments: true
!!! question
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在不超过背包容量下能放入物品的最大价值。
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在限定背包容量下能放入物品的最大价值。示例如图 14-22 所示。
![完全背包问题的示例数据](unbounded_knapsack_problem.assets/unbounded_knapsack_example.png){ class="animation-figure" }
@@ -18,15 +18,15 @@ comments: true
### 1.   动态规划思路
完全背包和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。
完全背包问题和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。
- 在 0-1 背包中,每物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。
- 在完全背包中,每物品有无数个,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。
- 在 0-1 背包问题中,每物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。
- 在完全背包问题中,每物品的数量是无限的,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。
在完全背包的规定下,状态 $[i, c]$ 的变化分为两种情况。
在完全背包问题的规定下,状态 $[i, c]$ 的变化分为两种情况。
- **不放入物品 $i$** :与 0-1 背包相同,转移至 $[i-1, c]$ 。
- **放入物品 $i$** :与 0-1 背包不同,转移至 $[i, c-wgt[i-1]]$ 。
- **不放入物品 $i$** :与 0-1 背包问题相同,转移至 $[i-1, c]$ 。
- **放入物品 $i$** :与 0-1 背包问题不同,转移至 $[i, c-wgt[i-1]]$ 。
从而状态转移方程变为:
@@ -36,7 +36,7 @@ $$
### 2.   代码实现
对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致
对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致
=== "Python"
@@ -349,12 +349,12 @@ $$
### 3.   空间优化
由于当前状态是从左边和上边的状态转移而来,**因此空间优化后应该对 $dp$ 表中的每一行采取正序遍历**。
由于当前状态是从左边和上边的状态转移而来**因此空间优化后应该对 $dp$ 表中的每一行进行正序遍历**。
这个遍历顺序与 0-1 背包正好相反。请借助图 14-23 来理解两者的区别。
=== "<1>"
![完全背包空间优化后的动态规划过程](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step1.png){ class="animation-figure" }
![完全背包问题在空间优化后的动态规划过程](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step1.png){ class="animation-figure" }
=== "<2>"
![unbounded_knapsack_dp_comp_step2](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step2.png){ class="animation-figure" }
@@ -371,9 +371,9 @@ $$
=== "<6>"
![unbounded_knapsack_dp_comp_step6](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step6.png){ class="animation-figure" }
<p align="center"> 图 14-23 &nbsp; 完全背包空间优化后的动态规划过程 </p>
<p align="center"> 图 14-23 &nbsp; 完全背包问题在空间优化后的动态规划过程 </p>
代码实现比较简单,仅需将数组 `dp` 的第一维删除
代码实现比较简单,仅需将数组 `dp` 的第一维删除
=== "Python"
@@ -669,11 +669,11 @@ $$
## 14.5.2 &nbsp; 零钱兑换问题
背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。
背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。
!!! question
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ **每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数。如果无法凑出目标金额则返回 $-1$ 。
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ **每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数。如果无法凑出目标金额则返回 $-1$ 。示例如图 14-24 所示。
![零钱兑换问题的示例数据](unbounded_knapsack_problem.assets/coin_change_example.png){ class="animation-figure" }
@@ -681,21 +681,21 @@ $$
### 1. &nbsp; 动态规划思路
**零钱兑换可以看作完全背包的一种特殊情况**,两者具有以下联系与不同点。
**零钱兑换可以看作完全背包问题的一种特殊情况**,两者具有以下联系与不同点。
- 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
- 背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
- 两道题可以相互转换,“物品”对应“硬币”、“物品重量”对应“硬币面值”、“背包容量”对应“目标金额”。
- 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
- 完全背包问题是求“不超过”背包容量下的解,零钱兑换是求“恰好”凑到目标金额的解。
**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表**
状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数**,记为 $dp[i, a]$ 。
状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数**,记为 $dp[i, a]$ 。
二维 $dp$ 表的尺寸为 $(n+1) \times (amt+1)$ 。
**第二步:找出最优子结构,进而推导出状态转移方程**
本题与完全背包的状态转移方程存在以下两差异。
本题与完全背包问题的状态转移方程存在以下两差异。
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。
@@ -706,7 +706,7 @@ $$
**第三步:确定边界条件和状态转移顺序**
当目标金额为 $0$ 时,凑出它的最少硬币数为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。
当目标金额为 $0$ 时,凑出它的最少硬币数为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。
当无硬币时,**无法凑出任意 $> 0$ 的目标金额**,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令首行所有 $dp[0, a]$ 都等于 $+ \infty$ 。
@@ -714,9 +714,7 @@ $$
大多数编程语言并未提供 $+ \infty$ 变量,只能使用整型 `int` 的最大值来代替。而这又会导致大数越界:状态转移方程中的 $+ 1$ 操作可能发生溢出。
为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数最多为 $amt$
最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。
为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数最多为 $amt$ 。最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。代码如下所示:
=== "Python"
@@ -730,7 +728,7 @@ $$
# 状态转移:首行首列
for a in range(1, amt + 1):
dp[0][a] = MAX
# 状态转移:其余行列
# 状态转移:其余行
for i in range(1, n + 1):
for a in range(1, amt + 1):
if coins[i - 1] > a:
@@ -755,7 +753,7 @@ $$
for (int a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -784,7 +782,7 @@ $$
for (int a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -813,7 +811,7 @@ $$
for (int a = 1; a <= amt; a++) {
dp[0, a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -845,7 +843,7 @@ $$
for a := 1; a <= amt; a++ {
dp[0][a] = max
}
// 状态转移:其余行列
// 状态转移:其余行
for i := 1; i <= n; i++ {
for a := 1; a <= amt; a++ {
if coins[i-1] > a {
@@ -877,7 +875,7 @@ $$
for a in stride(from: 1, through: amt, by: 1) {
dp[0][a] = MAX
}
// 状态转移:其余行列
// 状态转移:其余行
for i in stride(from: 1, through: n, by: 1) {
for a in stride(from: 1, through: amt, by: 1) {
if coins[i - 1] > a {
@@ -908,7 +906,7 @@ $$
for (let a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (let i = 1; i <= n; i++) {
for (let a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -939,7 +937,7 @@ $$
for (let a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (let i = 1; i <= n; i++) {
for (let a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -968,7 +966,7 @@ $$
for (int a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -997,7 +995,7 @@ $$
for a in 1..= amt {
dp[0][a] = max;
}
// 状态转移:其余行列
// 状态转移:其余行
for i in 1..=n {
for a in 1..=amt {
if coins[i - 1] > a as i32 {
@@ -1029,7 +1027,7 @@ $$
for (int a = 1; a <= amt; a++) {
dp[0][a] = MAX;
}
// 状态转移:其余行列
// 状态转移:其余行
for (int i = 1; i <= n; i++) {
for (int a = 1; a <= amt; a++) {
if (coins[i - 1] > a) {
@@ -1064,7 +1062,7 @@ $$
for (1..amt + 1) |a| {
dp[0][a] = max;
}
// 状态转移:其余行列
// 状态转移:其余行
for (1..n + 1) |i| {
for (1..amt + 1) |a| {
if (coins[i - 1] > @as(i32, @intCast(a))) {
@@ -1084,7 +1082,7 @@ $$
}
```
图 14-25 展示了零钱兑换的动态规划过程,和完全背包非常相似。
图 14-25 展示了零钱兑换的动态规划过程,和完全背包问题非常相似。
=== "<1>"
![零钱兑换问题的动态规划过程](unbounded_knapsack_problem.assets/coin_change_dp_step1.png){ class="animation-figure" }
@@ -1135,7 +1133,7 @@ $$
### 3. &nbsp; 空间优化
零钱兑换的空间优化的处理方式和完全背包一致
零钱兑换的空间优化的处理方式和完全背包问题一致
=== "Python"
@@ -1467,7 +1465,7 @@ $$
!!! question
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。示例如图 14-26 所示。
![零钱兑换问题 II 的示例数据](unbounded_knapsack_problem.assets/coin_change_ii_example.png){ class="animation-figure" }
@@ -1475,7 +1473,7 @@ $$
### 1. &nbsp; 动态规划思路
相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。
相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。
当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为:
@@ -1609,7 +1607,7 @@ $$
for i := 0; i <= n; i++ {
dp[i][0] = 1
}
// 状态转移:其余行列
// 状态转移:其余行
for i := 1; i <= n; i++ {
for a := 1; a <= amt; a++ {
if coins[i-1] > a {
@@ -1836,7 +1834,7 @@ $$
### 3. &nbsp; 空间优化
空间优化处理方式相同,删除硬币维度即可
空间优化处理方式相同,删除硬币维度即可
=== "Python"