mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-26 23:46:29 +08:00
build
This commit is contained in:
parent
ec4f250847
commit
c27363ba50
2 changed files with 33 additions and 20 deletions
|
@ -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. 方法一:暴力搜索
|
||||
|
||||
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. 方法一:暴力搜索
|
||||
|
||||
搜索代码包含以下要素:
|
||||
|
||||
- **递归参数**:状态 $[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. 方法二:记忆化搜索
|
||||
|
||||
为了防止重复求解重叠子问题,我们借助一个记忆列表 `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. 方法三:动态规划
|
||||
|
||||
接下来就是体力活了,我们将“从顶至底”的记忆化搜索代码译写为“从底至顶”的动态规划代码。
|
||||
接下来,我们将“从顶至底”的记忆化搜索代码译写为“从底至顶”的动态规划代码。
|
||||
|
||||
=== "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$ 行的过程。建议你思考一下正序遍历和倒序遍历的区别。
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue