404 lines
14 KiB
Markdown
404 lines
14 KiB
Markdown
---
|
||
title: 背包dp
|
||
description: 背包动态规划问题
|
||
date: 2025-05-04T22:28:00+08:00
|
||
slug: 背包dp
|
||
# image: helena-hertz-wWZzXlDpMog-unsplash.jpg
|
||
categories:
|
||
- 算法题
|
||
tags: [
|
||
"背包",
|
||
"动态规划",
|
||
"算法"
|
||
]
|
||
math: true
|
||
---
|
||
|
||
# 01背包
|
||
|
||
## 问题描述
|
||
|
||
有 $n$ 个物品,第 $k$ ($k=1,2,3,\cdots,n$)个物品有一个重量 $weight(k)$ 和一个价值 $value(k)$ 。现在有一个小偷,他有一个容量为 $W$ 的背包,问小偷如何选择物品放入背包,使得背包中的总价值最大。
|
||
|
||
## 状态定义
|
||
|
||
我们假设小偷从第 $k$ 个物品开始往前偷(为了方便,我们假设 $k$ 从 $1$ 开始而不是 $0$ ),当前的背包容量为 $w$ ,此时能偷的最大价值为 $dp(k,w)$ 。那么有两种情况:
|
||
|
||
- 如果 $w<weight\left( k \right) $,代表背包放不下第 $k$ 个物品,此时 $dp(k,w)=dp(k-1,w)$ 。
|
||
- 如果 $w\geqslant weight\left( k \right) $,代表背包可以放下第 $k$ 个物品,此时:
|
||
- 可以偷这个物品:$dp\left( k,w \right) =dp\left( k-1,w-weight\left( k \right) \right) +value\left( k \right) $
|
||
- 也可以不偷:$dp\left( k,w \right) =dp\left( k-1,w \right) $
|
||
|
||
因此,$dp\left( k,w \right) =max\left( 偷, 不偷 \right) $
|
||
|
||
## 递归+记忆化搜索实现
|
||
|
||
我们可以用最直观的方式,用递归+记忆化搜索实现:
|
||
|
||
```java
|
||
public class Solution {
|
||
private static Integer[][] memo;
|
||
// 用Integer数组而不用int数组,是因为int数组默认初始化为0,而Integer数组默认初始化为null
|
||
|
||
public static int knapsack(int[] weight, int[] value, int W) {
|
||
int n = weight.length;
|
||
memo = new Integer[n + 1][W + 1];
|
||
return dfs(weight, value, W, n);
|
||
}
|
||
|
||
private static int dfs(int[] weight, int[] value, int W, int k) {
|
||
if (k == 0) return 0;
|
||
if (memo[k][W] != null) return memo[k][W];
|
||
if (W < weight[k - 1]) {
|
||
memo[k][W] = dfs(weight, value, W, k - 1);
|
||
} else {
|
||
memo[k][W] = Math.max(dfs(weight, value, W, k - 1), dfs(weight, value, W - weight[k - 1], k - 1) + value[k - 1]);
|
||
}
|
||
return memo[k][W];
|
||
}
|
||
|
||
public static void main(String[] args) {
|
||
int[] weight = {2, 3, 4, 5};
|
||
int[] value = {3, 4, 5, 6};
|
||
int W = 5;
|
||
System.out.println(knapsack(weight, value, W));
|
||
}
|
||
}
|
||
```
|
||
|
||
## 二维数组实现
|
||
|
||
```java
|
||
public int knapsack(int[] weight, int[] value, int W) {
|
||
int n = weight.length;
|
||
int[][] dp = new int[n + 1][W + 1];
|
||
// Java 默认初始化数组为 0
|
||
|
||
// 注意,k对应前面假设中的1,2,...,对应weight和value数组中的k-1下标
|
||
// 定义中weight(1)对应weight[0],value(1)对应value[0]
|
||
for (int k = 1; k <= n; k++) {
|
||
for (int w = 1; w <= W; w++) {
|
||
if (w < weight[k - 1]) {
|
||
dp[k][w] = dp[k - 1][w];
|
||
} else {
|
||
dp[k][w] = Math.max(dp[k - 1][w], dp[k - 1][w - weight[k - 1]] + value[k - 1]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return dp[n][W];
|
||
}
|
||
```
|
||
|
||
假设我们有5个物品,重量和价值分别如下:
|
||
|
||
| k | 重量 | 价值 |
|
||
|------|------|------|
|
||
| 1 | 2 | 1 |
|
||
| 2 | 3 | 2 |
|
||
| 3 | 4 | 3 |
|
||
| 4 | 5 | 2 |
|
||
| 5 | 6 | 4 |
|
||
|
||
我们可以看一下dp数组的每个值:
|
||
|
||
| k\w | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|
||
|-----|---|---|---|---|---|---|---|
|
||
| 1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 |
|
||
| 2 | 0 | 1 | 2 | 2 | 3 | 3 | 3 |
|
||
| 3 | 0 | 1 | 2 | 3 | 3 | 4 | 5 |
|
||
| 4 | 0 | 1 | 2 | 3 | 3 | 4 | 5 |
|
||
| 5 | 0 | 1 | 2 | 3 | 3 | 4 | 5 |
|
||
|
||
## 一维数组实现
|
||
|
||
一维数组实现主要是为了节省空间,并没有减少时间复杂度。我们把k消去,只留下w,这样一来,二维数组实现需要 $O(nW)$ 的空间,而一维数组实现只需要 $O(W)$ 的空间。
|
||
|
||
```java
|
||
public int knapsack(int[] weight, int[] value, int W) {
|
||
int n = weight.length;
|
||
int[] dp = new int[W + 1];
|
||
// Java 默认初始化数组为 0
|
||
|
||
for (int k = 1; k <= n; k++) {
|
||
for (int w = W; w >= weight[k - 1]; w--) {
|
||
// w >= weight[k - 1]:当背包可以放下第k个物品时才更新
|
||
// 内层应该从后往前遍历,因为dp[w]依赖于dp[w - weight[k - 1]],如果从前往后遍历,dp[w - weight[k - 1]]会被先更新,导致dp[w]的结果不正确
|
||
// dp[w] (更新前) 相当于二维 DP 中的 dp[k-1][w] (不放物品 k 的情况)
|
||
// dp[w - weights[k]] (更新前) 相当于二维 DP 中的 dp[k-1][w - weights[k]]
|
||
dp[w] = Math.max(dp[w], dp[w - weight[k - 1]] + value[k - 1]);
|
||
}
|
||
// 经过内层 w 循环后,dp 数组已经更新,
|
||
// 此时 dp[w] 存储的就是考虑前 k 个物品、容量为 w 的最大价值
|
||
}
|
||
|
||
// 或者可以简写成这样,方便记忆:
|
||
for (int k = 0; k < n; k++) {
|
||
for (int w = W; w >= weight[k]; w--) {
|
||
dp[w] = Math.max(dp[w], dp[w - weight[k]] + value[k]);
|
||
}
|
||
}
|
||
|
||
return dp[W];
|
||
}
|
||
|
||
```
|
||
|
||
# 完全背包
|
||
|
||
## 问题描述
|
||
|
||
有 $n$ 个物品,第 $k$ ($k=1,2,3,\cdots,n$)个物品有一个重量 $weight(k)$ 和一个价值 $value(k)$ 。现在有一个小偷,他有一个容量为 $W$ 的背包,**每个物品可以选择无数次放入背包**,问小偷如何选择物品放入背包,使得背包中的总价值最大。
|
||
|
||
## 状态定义
|
||
|
||
这个问题和01背包问题很相似,区别在于每个物品可以选择无数次放入背包,即**允许重复选择**。因此,我们只需要更改“放”的逻辑,从“放一个”改为“放多个”。
|
||
|
||
我们假设小偷从第 $k$ 个物品开始往前偷(为了方便,我们假设 $k$ 从 $1$ 开始而不是 $0$ ),当前的背包容量为 $w$ ,此时能偷的最大价值为 $dp(k,w)$ 。那么有两种情况:
|
||
|
||
- 如果 $w<weight\left( k \right) $,代表背包放不下第 $k$ 个物品,此时 $dp(k,w)=dp(k-1,w)$ 。
|
||
- 如果 $w\geqslant weight\left( k \right) $,代表背包可以放下第 $k$ 个物品,此时:
|
||
- 可以偷这个物品:$dp\left( k,w \right) =dp\left( k,w-weight\left( k \right) \right) +value\left( k \right) $(唯一区别:dp的第一个参数使用k而不是k-1,表示当前物品k可以被重复选择)
|
||
- 也可以不偷:$dp\left( k,w \right) =dp\left( k-1,w \right) $
|
||
|
||
因此,$dp\left( k,w \right) =max\left( 偷, 不偷 \right) $
|
||
|
||
## 二维数组实现
|
||
|
||
```java
|
||
public int knapsack(int[] weight, int[] value, int W) {
|
||
int n = weight.length;
|
||
int[][] dp = new int[n + 1][W + 1];
|
||
// Java 默认初始化数组为 0
|
||
|
||
for (int k = 1; k <= n; k++) {
|
||
for (int w = 1; w <= W; w++) {
|
||
if (w < weight[k - 1]) {
|
||
dp[k][w] = dp[k - 1][w];
|
||
} else {
|
||
dp[k][w] = Math.max(dp[k - 1][w], dp[k][w - weight[k - 1]] + value[k - 1]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return dp[n][W];
|
||
}
|
||
```
|
||
|
||
## 一维数组实现
|
||
|
||
一维数组消去了k,只保留w,这样一来,其核心运算代码与01背包的一维数组实现完全相同,唯一不同的是内层遍历顺序从后往前变成了从前往后,来确保每个物品可以被多次选择。
|
||
|
||
```java
|
||
public int knapsack(int[] weight, int[] value, int W) {
|
||
int n = weight.length;
|
||
int[] dp = new int[W + 1];
|
||
// Java 默认初始化数组为 0
|
||
// 注意,k对应前面假设中的1,2,...,对应weight和value数组中的k-1下标
|
||
// 定义中weight(1)对应weight[0],value(1)对应value[0]
|
||
for (int k = 1; k <= n; k++) {
|
||
for (int w = weight[k - 1]; w <= W; w++) {
|
||
dp[w] = Math.max(dp[w], dp[w - weight[k - 1]] + value[k - 1]);
|
||
}
|
||
}
|
||
|
||
// 或者可以简写成这样,方便记忆:
|
||
for (int k = 0; k < n; k++) {
|
||
for (int w = weight[k]; w <= W; w++) {
|
||
dp[w] = Math.max(dp[w], dp[w - weight[k]] + value[k]);
|
||
}
|
||
}
|
||
|
||
return dp[W];
|
||
}
|
||
|
||
```
|
||
# 多重背包
|
||
|
||
## 问题描述
|
||
|
||
有 $n$ 个物品,第 $k$ ($k=1,2,3,\cdots,n$)个物品有一个重量 $weight(k)$ 和一个价值 $value(k)$ 。现在有一个小偷,他有一个容量为 $W$ 的背包,**每个物品放入背包的次数不超过$count(k)$**,问小偷如何选择物品放入背包,使得背包中的总价值最大。
|
||
|
||
这个问题其实就是分组。最直观的方法是把每个相同的物品都直接看作一个独立的物品(暴力拆解),然后使用01背包的解法。
|
||
|
||
## 暴力拆解
|
||
|
||
```java
|
||
public static int multipleBag(int[] weight, int[] value, int[] num, int W) {
|
||
int n = weight.length;
|
||
int[] dp = new int[W + 1];
|
||
// Java 默认初始化数组为 0
|
||
|
||
for (int k = 0; k < n; k++) {
|
||
for (int c = 1; c <= num[k]; c++) { // 拆成 num[k] 件物品
|
||
for (int w = W; w >= weight[k]; w--) {
|
||
dp[w] = Math.max(dp[w], dp[w - weight[k]] + value[k]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return dp[W];
|
||
}
|
||
```
|
||
这种暴力拆解的方法时间复杂度为 $O(nW\sum_{i=1}^{n}num[i])$ ,当数据量较大时,时间复杂度会非常高。
|
||
|
||
## 二进制拆解
|
||
|
||
二进制拆解的思路是将每个物品的个数拆成若干个2的幂次方,然后使用01背包的解法。依据一个事实:任何整数都可以表示成若干个2的幂次方之和,如13=1+4+8。
|
||
|
||
```java
|
||
public static int multipleBag(int[] weight, int[] value, int[] num, int W) {
|
||
int n = weight.length;
|
||
int[] dp = new int[W + 1];
|
||
// Java 默认初始化数组为 0
|
||
|
||
for (int k = 0; k < n; k++) {
|
||
int m = 1; // 用来生成1,2,4,8… 的“拆分基数”
|
||
int c = num[k]; // 当前物品的个数
|
||
while (c > 0) {
|
||
int count = Math.min(c, m); // 取c和m中较小者,避免拆分过多
|
||
int newWeight = weight[k] * count;
|
||
int newValue = value[k] * count;
|
||
// 标准01背包
|
||
for (int w = W; w >= newWeight; w--) {
|
||
dp[w] = Math.max(dp[w], dp[w - newWeight] + newValue);
|
||
}
|
||
c -= count; // 减去已经拆分出去的件数
|
||
m <<= 1; // 下一轮基数翻倍:1→2→4→8…
|
||
}
|
||
}
|
||
|
||
return dp[W];
|
||
}
|
||
```
|
||
这种方法把每个物品的个数拆成了若干个2的幂次方,时间复杂度为 $O(nW\sum_{i=1}^{n}log(num[i]))$ ,大大降低了时间复杂度。
|
||
|
||
## 单调队列优化
|
||
|
||
单调队列优化的时间复杂度为 $O(nW)$ ,比暴力拆解和二进制拆解都要快。
|
||
|
||
```java
|
||
|
||
/**
|
||
* 多重背包——单调队列优化
|
||
* @param weight 每种物品重量数组 w[i]
|
||
* @param value 每种物品价值数组 v[i]
|
||
* @param num 每种物品最大数量数组 c[i]
|
||
* @param W 背包总容量
|
||
* @return 背包能装下的最大价值
|
||
*/
|
||
public static int multipleBag(int[] weight, int[] value, int[] num, int W) {
|
||
int n = weight.length;
|
||
// dp[j] 表示当前考虑完若干物品后,容量恰好为 j 时的最大价值
|
||
int[] dp = new int[W + 1];
|
||
|
||
// 枚举每一类物品
|
||
for (int k = 0; k < n; k++) {
|
||
int w = weight[k], v = value[k], c = num[k];
|
||
|
||
// 对同一种物品,根据 j mod w 分成 w 条子序列
|
||
for (int r = 0; r < w; r++) {
|
||
// 维护一个双端队列,队列元素是索引 m,以及对应的 f(m) = dp_old[r + m*w] - m*v
|
||
Deque<Integer> deque = new ArrayDeque<>();
|
||
|
||
// m = 0,1,2,...;对应 j = r + m*w
|
||
// 我们需要遍历这一条“序列”上的所有 j
|
||
for (int j = r, m = 0; j <= W; j += w, m++) {
|
||
// 1) 计算当前点的 f(m)
|
||
int fm = dp[j] - m * v;
|
||
|
||
// 2) 将 fm 进队尾,保持队列单调递减(队头是最大)
|
||
while (!deque.isEmpty()) {
|
||
int mTail = deque.peekLast();
|
||
int fTail = dp[r + mTail * w] - mTail * v;
|
||
if (fTail <= fm) {
|
||
deque.pollLast();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
deque.offerLast(m);
|
||
|
||
// 3) 弹出过期元素(下标 < m - c)
|
||
if (deque.peekFirst() < m - c) {
|
||
deque.pollFirst();
|
||
}
|
||
|
||
// 4) 队头就是窗口 [m-c, m] 内最大的 f(u)
|
||
int bestM = deque.peekFirst();
|
||
int bestF = dp[r + bestM * w] - bestM * v;
|
||
|
||
// 5) 恢复成 dp_new[j]
|
||
dp[j] = bestF + m * v;
|
||
}
|
||
}
|
||
}
|
||
|
||
return dp[W];
|
||
}
|
||
```
|
||
我们的总体思路是:
|
||
* `dp[j]`:表示“当前已经处理完第 0…k 类物品后,恰好装满容量 j 的最大价值”。
|
||
* 对每一类物品 `k`,我们要用单调队列优化下面这个转移:
|
||
$$
|
||
dp_{\text{new}}[j]
|
||
= \max_{0 \le t \le c_k,\; j - t w_k \ge 0}
|
||
\bigl(dp_{\text{old}}[\,j - t w_k\,] + t\,v_k\bigr).
|
||
$$
|
||
|
||
首先按 “模 w” 分组,
|
||
```java
|
||
for (int r = 0; r < w; r++) {
|
||
// 序列:j = r, r+w, r+2w, …
|
||
}
|
||
```
|
||
|
||
把所有容量 `j` 按 `j % w == r` 分成 `w` 条独立的序列,这样处理起来只需线性扫一遍。然后我们定义 f(m),令
|
||
$$
|
||
m = \frac{j - r}{w},\quad
|
||
j = r + m\,w;
|
||
$$
|
||
|
||
定义
|
||
|
||
$$
|
||
f(m) = dp_{\text{old}}[r + m\,w] - m\,v_k.
|
||
$$
|
||
|
||
那么转移就变成
|
||
|
||
$$
|
||
dp_{\text{new}}[r + m\,w]
|
||
= \max_{m-c_k \le u \le m}\bigl(f(u)\bigr) + mv_k,
|
||
$$
|
||
|
||
即在下标 `u ∈ [m - c, m]` 区间里取最大。
|
||
|
||
然后用单调队列维护窗口最大值。
|
||
|
||
* **队列存什么?** 存下标 `u`,真正的比较量是 `dp[r + u*w] - u*v`。
|
||
* **进队**(保持递减):
|
||
|
||
```java
|
||
while (!deque.isEmpty() && f(tail) <= f(m)) deque.pollLast();
|
||
deque.offerLast(m);
|
||
```
|
||
|
||
这样队头永远是窗口内最大的 `f(u)`。
|
||
* **出队过期**:
|
||
|
||
```java
|
||
if (deque.peekFirst() < m - c) deque.pollFirst();
|
||
```
|
||
|
||
确保窗口大小不超过 `c+1`。
|
||
|
||
最后恢复 dp 新值,队头 `bestM = deque.peekFirst()`,对应最大 `f(bestM)`。
|
||
|
||
$$
|
||
dp[j] = f(\text{bestM}) + m\,v
|
||
= \bigl(dp_{\text{old}}[r + bestM\,w] - bestM\,v\bigr) + m\,v.
|
||
$$
|
||
|
||
这样优化后,时间复杂度为 $O(nW)$ 。
|