From 3a8af3d6ca780e9054a9db8f73639c6d0a9ed06b Mon Sep 17 00:00:00 2001 From: ember <1279347317@qq.com> Date: Mon, 5 May 2025 02:37:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/post/算法题/背包dp.md | 403 ++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 content/post/算法题/背包dp.md diff --git a/content/post/算法题/背包dp.md b/content/post/算法题/背包dp.md new file mode 100644 index 0000000..ed32381 --- /dev/null +++ b/content/post/算法题/背包dp.md @@ -0,0 +1,403 @@ +--- +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[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[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 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)$ 。