This commit is contained in:
krahets 2023-07-06 04:09:06 +08:00
parent ec4f250847
commit c27363ba50
2 changed files with 33 additions and 20 deletions

View file

@ -4,15 +4,15 @@ comments: true
# 13.3.   0-1 背包问题
背包问题是学习动态规划的一个非常好的入门题目,其涉及到“选择与不选择”和“限制条件下的最优化”等问题,是动态规划中最常见的问题形式。
背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0-1 背包问题、完全背包问题、多重背包问题等。
背包问题具有很多变种,例如 0-1 背包问题、完全背包问题、多重背包问题等。在本节中,我们先来学习最简单的 0-1 背包问题。
在本节中,我们先来学习基础的的 0-1 背包问题。
!!! question
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$ 、价值为 $val[i-1]$ ,现在有个容量为 $cap$ 的背包,请求解在不超过背包容量下背包中物品的最大价值。
请注意,物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。
请注意,物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。
下图给出了一个 0-1 背包的示例数据,背包内的最大价值为 $220$ 。
@ -20,16 +20,12 @@ comments: true
<p align="center"> Fig. 0-1 背包的示例数据 </p>
接下来,我们仍然先从回溯角度入手,先给出暴力搜索解法;再引入记忆化处理,得到记忆化搜索和动态规划解法。
## 13.3.1. &nbsp; 方法一:暴力搜索
0-1 背包问题是一道典型的“选或不选”的问题0 代表不选、1 代表选。我们可以将 0-1 背包看作是一个由 $n$ 轮决策组成的搜索过程,对于每个物体都有不放入和放入两种决策。不放入背包,背包容量不变;放入背包,背包容量减小。由此可得:
在 0-1 背包问题中,每个物体都有不放入和放入两种决策。不放入背包,背包容量不变;放入背包,背包容量减小。由此可得:
- **状态包括物品编号 $i$ 和背包容量 $c$**,记为 $[i, c]$ 。
- 状态 $[i, c]$ 对应子问题**前 $i$ 个物品在容量为 $c$ 背包中的最大价值**记为 $dp[i, c]$ 。
- 状态 $[i, c]$ 对应子问题的解为:**前 $i$ 个物品在容量为 $c$ 背包中的最大价值**,记为 $dp[i, c]$ 。
当我们做出物品 $i$ 的决策后,剩余的是前 $i-1$ 个物品的子问题,因此状态转移分为两种
我们可以将 0-1 背包求解过程看作是一个由 $n$ 轮决策组成的过程。从物品 $n$ 开始,当我们做出物品 $i$ 的决策后,剩余的是前 $i-1$ 个物品的决策。因此,状态转移分为两种情况
- **不放入物品 $i$** :背包容量不变,状态转移至 $[i-1, c]$
- **放入物品 $i$** :背包容量减小 $wgt[i-1]$ ,价值增加 $val[i-1]$ ,状态转移至 $[i-1, c-wgt[i-1]]$
@ -40,11 +36,15 @@ $$
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
$$
以下是暴力搜索的实现代码,其中包含以下要素:
需要注意的是,若当前物品重量 $wgt[i - 1]$ 超出剩余背包容量 $c$ ,则只能选择不放入背包。
## 13.3.1. &nbsp; 方法一:暴力搜索
搜索代码包含以下要素:
- **递归参数**:状态 $[i, c]$ **返回值**:子问题的解 $dp[i, c]$ 。
- **终止条件**:当已完成 $n$ 轮决策或背包无剩余容量为时,终止递归并返回价值 $0$ 。
- **剪枝**:若当前物品重量 $wgt[i - 1]$ 超出剩余背包容量 $c$ ,则只能选择不放入背包。
- **终止条件**:当物品编号越界 $i = 0$ 或背包剩余容量为 $0$ 时,终止递归并返回价值 $0$ 。
- **剪枝**:若当前物品重量 $wgt[i - 1]$ 超出剩余背包容量 $c$ ,则不能放入背包。
=== "Java"
@ -134,7 +134,7 @@ $$
## 13.3.2. &nbsp; 方法二:记忆化搜索
为了防止重复求解重叠子问题,我们借助一个记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 表示前 $i$ 个物品在容量为 $c$ 背包中的最大价值。当再次遇到相同子问题时,直接从 `mem` 中获取记录
为了防止重复求解重叠子问题,我们借助一个记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 记录解 $dp[i, c]$
=== "Java"
@ -218,7 +218,7 @@ $$
[class]{}-[func]{knapsackDFSMem}
```
引入记忆化之后,所有子问题最多只被计算一次,**因此时间复杂度取决于子问题数量**,也就是 $O(n \times cap)$ 。
引入记忆化之后,所有子问题只被计算一次,**因此时间复杂度取决于子问题数量**,也就是 $O(n \times cap)$ 。
![0-1 背包的记忆化搜索递归树](knapsack_problem.assets/knapsack_dfs_mem.png)
@ -226,7 +226,7 @@ $$
## 13.3.3. &nbsp; 方法三:动态规划
接下来就是体力活了,我们将“从顶至底”的记忆化搜索代码译写为“从底至顶”的动态规划代码。
接下来,我们将“从顶至底”的记忆化搜索代码译写为“从底至顶”的动态规划代码。
=== "Java"
@ -308,7 +308,7 @@ $$
[class]{}-[func]{knapsackDP}
```
观察下图,动态规划的过程本质上就是填充 $dp$ 列表(矩阵)的过程,时间复杂度也为 $O(n \times cap)$ 。
如下图所示,**动态规划本质上就是填充 $dp$ 矩阵的过程**,时间复杂度也为 $O(n \times cap)$ 。
=== "<1>"
![0-1 背包的动态规划过程](knapsack_problem.assets/knapsack_dp_step1.png)
@ -352,9 +352,9 @@ $$
=== "<14>"
![knapsack_dp_step14](knapsack_problem.assets/knapsack_dp_step14.png)
**接下来考虑状态压缩**。以上代码中的 $dp$ 矩阵占用 $O(n \times cap)$ 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。代码省略,有兴趣的同学可以自行实现。
**最后考虑状态压缩**。以上代码中的 $dp$ 矩阵占用 $O(n \times cap)$ 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。代码省略,有兴趣的同学可以自行实现。
那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由左上方或正上方的格子转移过来的。假设只有一个数组,当遍历到第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态,为了避免左边区域的格子被覆盖,我们应采取倒序遍历,这样方可实现正确的状态转移
那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由左上方或正上方的格子转移过来的。假设只有一个数组,当遍历到第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态,为了避免左边区域的格子在状态转移中被覆盖,我们应采取倒序遍历。
以下动画展示了在单个数组下从第 $i=1$ 行转换至第 $i=2$ 行的过程。建议你思考一下正序遍历和倒序遍历的区别。

View file

@ -169,7 +169,20 @@ comments: true
=== "Swift"
```swift title="top_k.swift"
[class]{}-[func]{topKHeap}
/* 基于堆查找数组中最大的 k 个元素 */
func topKHeap(nums: [Int], k: Int) -> [Int] {
// 将数组的前 k 个元素入堆
var heap = Array(nums.prefix(k))
// 从第 k+1 个元素开始,保持堆的长度为 k
for i in stride(from: k, to: nums.count, by: 1) {
// 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆
if nums[i] > heap.first! {
heap.removeFirst()
heap.insert(nums[i], at: 0)
}
}
return heap
}
```
=== "Zig"