目录
- 前言
- [01 背包](#01 背包)
- 完全背包
-
- [暴力搜索 DFS 方法](#暴力搜索 DFS 方法)
- [记忆化搜索 方法](#记忆化搜索 方法)
- [完全背包 DP 方法](#完全背包 DP 方法)
- 完全背包总结
- 多重背包
-
- [暴力搜索 DFS 方法](#暴力搜索 DFS 方法)
- [记忆化搜索 方法](#记忆化搜索 方法)
- [多重背包 DP 方法](#多重背包 DP 方法)
- 多重背包总结
- 结语
前言
:::epigraph[放在前面 ]
本文源自一位八年级 OIer 的真实学习笔记与教学反思,愿与你分享从困惑到通透的完整路径。
:::
背包问题是动态规划(DP)最经典的基石 。但回想我自己的学习经历,最大的障碍不是问题本身,而是大多数教程都从完美的状态方程 f i , j f_{i,j} fi,j 开始讲起,甚至有些教程直接从一维数组讲起,加大了学习者的理解难度,造成了学习者的巨大负担。
在本文中,我将介绍背包模型的 DFS 、记忆化搜索 、二维数组 、滚动数组 和其他优化,由浅入深。其中,本文最具特色的 是拥有 DFS 和记忆化搜索 的解释,在洛谷中背包 DP 的极少文章能拥有这两点。
以前,当我还是初学者 的时候,就是直接去看一维 DP ,导致背包理解不透彻,竟然以为是按照"背包容量"为状态变量。这篇文章的结构,就是复制了我本人的学习经验,那时从困惑 到顿悟 的学习路线,而不是从教科书上抄来的最优解。
我为什么发表这篇文章? 就是因为我的很多同学,它们直接奔着最优解而去,而恰恰忽略了朴素但强大的 DFS ,导致理解出问题。我不希望再重演。
01 背包
模型: 给定 N N N 种物品,第 i i i 种物品的体积为 w i w_i wi,价值为 v i v_i vi,每种物品只有 1 1 1 件。有一体积为 M M M 的背包,选择一些物品放入背包,使得在物品总体积不超过 M M M 的前提下,物品的价值总和最大。
暴力搜索 DFS 方法
很明显,我们面对第 i i i 件物品,有且只有 2 2 2 种选择------选 或不选 。
当我们理解这个思想的时候,就可以写出代码了。
选 是一种情况,不选也是一种情况,分别枚举即可。
n n n 代表物品数量, m m m 代表背包容量,数组 w , v w,v w,v 分别存储物品重量和价值、
整体代码如下(假设 n , m ⩽ 100 n,m\leqslant100 n,m⩽100):
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m;
int ans=0; //存储最终结果
int w[105],v[105];
void dfs(int i,int j,int cnt){
//i代表第i个物品,j代表当前背包剩余容量,cnt代表当前已选物品的总价值
if (i>n){ //选完所有物品
ans=max(ans,cnt); //更新最大价值
return;
}
//选择1:不选第i件物品
//剩余容量不变,当前物品总价值不变
dfs(i+1, j, cnt);
//选择2:选择第i件物品(前提是放得下,也就是当前背包容量j大于当前物品重量w[i])
if (j>=w[i]){
//剩余容量减少w[i],总价值增加v[i]。
dfs(i+1, j-w[i], cnt+v[i]);
}
}
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d",&w[i],&v[i]);
}
//从第1个物品选择,当前剩余容量为m,当前已选物品总价值为0(实际上还没选)
dfs(1,m,0);
printf("%d",ans);
return 0;
}
我们发现,物品有 2 2 2 种选择分支 ,物品总数为 n n n,因此结点数量为 2 n 2^n 2n。
如果我们用一个数组 f f f 记录下第一次计算 f i , j f_{i,j} fi,j 时得到的从该状态出发能获得的最大价值 ,那么当其他路径再次遇到这个状态时,就可以直接查表返回 ,避免重复的递归展开。这就是记忆化搜索。
记忆化搜索方法
在理解了 DFS 的重复计算问题后,一个自然的优化思路是:将已经计算过的子问题的答案保存起来,避免重复劳动。这就是记忆化搜索(Memoization)。
我们需要定义一个记忆化数组 f f f,其大小为 n × m n\times m n×m。这里 f i , j f_{i,j} fi,j 的含义与 DFS 中的子问题定义完全一致:从第 i i i 个物品开始考虑,剩余容量为 j j j 时,能获得的最大价值 。
实现方法 :将 f f f 数组初始化为 − 1 -1 −1,并用 f i , j ≠ − 1 f_{i,j}\ne-1 fi,j=−1 来判断。由于在大部分题目中,物品重量和物品价值都是正整数 ,因此如果用 − 1 -1 −1 代表未计算(非法)的情况,可以用于标记。
我们将 f f f 数组初始化为一个不可能取到的值 − 1 -1 −1 ,用 if (f[i][j]==-1) 来判断该状态是否已被计算过。
优化后的整体代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m,w[1005],v[1005],f[1005][1005]; // f将初始化为-1
int dfs_memo(int i, int j) {
if (i>n) return 0;
// 关键:f[i][j]!=-1表示已计算。因为任何合法解都是正整数,所以-1是安全的哨兵值。
if (f[i][j] != -1) return f[i][j];
// 不选第i个物品,
int res=dfs_memo(i+1, j);
// 决策2:选第i个物品(前提是能放下)
if (j>=w[i]){
res = max(res, dfs_memo(i+1,j-w[i])+v[i]);
}
return f[i][j] = res; //存储并返回
}
int main() {
memset(f, -1, sizeof f); //统一初始化为-1
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d",&w[i],&v[i]);
}
printf("%d",dfs_memo(1, m));
return 0;
}
朴素 DFS 的低效,根源在于对同一状态 ( i , j ) (i,j) (i,j) 的重复计算。那么,最直接的改进方案就是:为每个状态设立一个备忘录 。第一次计算出 ( i , j ) (i,j) (i,j) 的结果后,将它存起来;之后再遇到相同的状态,就直接查表返回结果,无需再次展开递归。这种带备忘录的递归就是记忆化搜索。
注意 :记忆化搜索有栈溢出 风险。系统调用栈的空间有限,当递归深度(与物品数 n n n 相关)较大时,极易引发 RE(运行时错误)。递推式的 DP 正好能避免这样的危险。
讲到这里你可能想问:"既然记忆化搜索有缺陷,为什么还要费劲讲它?"
因为我的目标是让你理解,而不是仅仅记住 。记忆化搜索的 f i , j f_{i,j} fi,j 和 DFS 的参数 ( i , j ) (i,j) (i,j) 是完全对应 的,这个状态 的定义是从问题里自然长出来 的,不是凭空变出来的。理解了它,你才能自己设计其他 DP 问题的状态。
这就是为什么我从DFS开始 :当你理解了"从第 i i i 个物品开始考虑,剩余容量为 j j j"这个最自然的子问题定义,你才能真正理解后续所有优化的为什么 ,而不仅仅是怎么做。
01 背包问题 DP 方法
阶段划分
很明显,问题规模受 n , m n,m n,m 的限制,我们如何划分阶段呢?
逐一考虑每件物品是否加入背包,阶段变量 i i i代表前 i i i 件物品,阶段变量 j j j 代表当前容量为 j j j。
这样定义的话, f i , j f_{i,j} fi,j 就代表当选择前 i i i 件物品,背包容量为 j j j 的情况下的最优选择。
状态定义
按照这样的阶段划分及最优子结构性质,我们可以如下定义:
若不选第 i i i 个物品,则
f i , j = f i − 1 , j f_{i,j}=f_{i-1,j} fi,j=fi−1,j
解释 : 既然不选第 i i i 个物品,那么前 i i i 个物品的最优解,自然就等于只考虑前 i − 1 i−1 i−1 个物品、且容量同样为 j j j 时的最优解。
若选第 i i i 个物品,则
f i , j = f i − 1 , j − w i + v i ,当 j ⩾ w i 时 f_{i,j}=f_{i-1,j-w_i}+v_i\text{,当 }j\geqslant w_i\text{ 时} fi,j=fi−1,j−wi+vi,当 j⩾wi 时
解释: j − w i j-w_i j−wi 代表在背包容量 j j j 下,我们可以舍弃掉 w i w_i wi 的背包容量,换取 v i v_i vi 的价值。
综上,根据最优子结构 性质, f i , j f_{i,j} fi,j 的值有上述的大者决定,即
f i , j = max { f i − 1 , j f i − 1 , j − w i + v i j ⩾ w i f_{i,j}=\max\begin{cases} f_{i-1,j}\\ f_{i-1,j-w_i}+v_i&j\geqslant w_i \end{cases} fi,j=max{fi−1,jfi−1,j−wi+vij⩾wi
代码实现
定义一个 f f f 数组,大小为 n × m n\times m n×m,初始化为 0 0 0,用来存储当前 f i , j f_{i,j} fi,j 的状态, n , m n,m n,m 的含义参见 01 背包模型,关键代码如下
cpp
for (int i=1;i<=n;++i){
for (int j=0;j<=m;++j){
f[i][j] = f[i-1][j]; //继承,表示如果不选第i个物品
if (j>=w[i]){ //一定要判断!否则数组越界!!
f[i][j]=max(f[i][j], f[i-1][j-w[i]]+v[i]); //比较最大值
}
}
}
最终答案存储在 f[n][m],输出即可。
01 背包滚动数组优化
很明显,上述代码时间复杂度为 O ( n m ) O(nm) O(nm),空间复杂度为 O ( n m ) O(nm) O(nm),我们可以使用滚动数组 进行优化空间复杂度。
观察这个状态转移方程:
f i , j = max { f i − 1 , j f i − 1 , j − w i + v i j ⩾ w i f_{i,j}=\max\begin{cases} f_{i-1,j}\\ f_{i-1,j-w_i}+v_i&j\geqslant w_i \end{cases} fi,j=max{fi−1,jfi−1,j−wi+vij⩾wi
我们可以发现,如果要确定第 i i i 行的数据,仅需知道第 i − 1 i-1 i−1 行的数据,其余数据均为无用数据 。
因此,我们使用两个数组,一个存第 i i i 行的数据,另一个存第 i − 1 i-1 i−1 行的数据,大小都是 m m m。
实际上,我们常定义一个二维数组 f f f,大小为 2 × m 2\times m 2×m,这样的连续内存可以使缓存命中率提高。
关键代码如下:
cpp
for (int i=1;i<=n;++i){
for (int j=0;j<=m;++j){
int t1=i%2; //当前行
int t2=(i-1)%2; //上一行
//可替换为 t1=i&1,t2=(i-1)&1,性能更佳
f[t1][j] = f[t2][j]; //继承,表示如果不选第i个物品
if (j>=w[i]){ //防止数组越界
f[t1][j]=max(f[t1][j], f[t2][j-w[i]]+v[i]);
}
}
}
由于我们是按照顺序处理的,在处理完第 n n n 个物品后,最终结果实际存储在 f[n%2][m]。
01 背包降维优化
请确保您已经彻底了解 01 背包基础状态转移方程!!!
直接给出降维优化 状态转移方程:
f j = max { f j f j − w i + v i f_j=\max\begin{cases} f_j\\ f_{j-w_i}+v_i \end{cases} fj=max{fjfj−wi+vi
关键代码如下:
cpp
for (int i=1;i<=n;++i){
for (int j=m;j>=w[i];--j){
f[j]=max(f[j], f[j-w[i]]+v[i]);
}
}
解释:
我们知道,01背包的要求就是每件物品只能选择 1 1 1 次。
那么,阶段变量 j j j 从 m m m 到 w i w_i wi(简称倒序遍历 ),可以保证 f j − w i + v i f_{j-w_i}+v_i fj−wi+vi 一定在 f j f_j fj 之后修改,这样就能保证只选 1 1 1 次。
试想一下,如果 j j j 从 w i w_i wi 到 m m m 遍历(简称正序遍历 ),可能会导致 f j − w i + v i f_{j-w_i}+v_i fj−wi+vi 可能在 f j f_j fj 之前被修改,导致每件物品可能选多次。
倒序遍历 能够保证在更新 f j f_j fj 时,用到的 f j − w i f_{j-w_i} fj−wi 一定是上一轮 的值,从而使每件物品只被考虑一次。
正序遍历和逆序遍历的后果如下:
实例演示 (物品:重量 2 2 2,价值 3 3 3;背包容量 5 5 5):
:::success[逆序遍历过程]
- j = 5 j=5 j=5: max ( f 5 , f 3 + 3 ) = max ( 0 , 0 + 3 ) = 3 \max(f_5,f_3+3)=\max(0,0+3)=3 max(f5,f3+3)=max(0,0+3)=3
- j = 4 j=4 j=4: max ( f 4 , f 2 + 3 ) = max ( 0 , 0 + 3 ) = 3 \max(f_4,f_2+3)=\max(0,0+3)=3 max(f4,f2+3)=max(0,0+3)=3
- j = 3 j=3 j=3: max ( f 3 , f 1 + 3 ) = max ( 0 , 0 + 3 ) = 3 \max(f_3,f_1+3)=\max(0,0+3)=3 max(f3,f1+3)=max(0,0+3)=3
- j = 2 j=2 j=2: max ( f 2 , f 0 + 3 ) = max ( 0 , 0 + 3 ) = 3 \max(f_2,f_0+3)=\max(0,0+3)=3 max(f2,f0+3)=max(0,0+3)=3
结果:每个物品只选了一次 !
:::
:::error[误用正序的后果]
- j = 2 j=2 j=2: max ( 0 , f 0 + 3 ) = 3 \max(0,f_0+3)=3 max(0,f0+3)=3
- j = 3 j=3 j=3: max ( 0 , f 1 + 3 ) = 3 \max(0,f_1+3)=3 max(0,f1+3)=3
- j = 4 j=4 j=4: max ( 0 , f 2 + 3 ) = 6 = 3 × 2 \max(0,f_2+3)=6=3\times2 max(0,f2+3)=6=3×2 ← ~~~\leftarrow ← 错误!选了两次!
:::
注意:我们不是只以背包容量作为阶段变量!只是降维!
推荐练习题目:
- 洛谷 P1048 采药 - 01背包模版
- 洛谷 P2871 魅力手链 - 01背包模版
- 洛谷 P1049 装箱问题 - 01背包变形
- 洛谷 P1060 开心的金明 - 01背包变形
01背包总结
| 实现方法 | 时间复杂度 | 空间复杂度 | 缓存友好度 | 代码复杂度 |
|---|---|---|---|---|
| 朴素 DFS | O ( 2 n ) O(2^n) O(2n) | O ( n ) O(n) O(n) | 差 | 极简 |
| 记忆化搜索 | O ( n m ) O(nm) O(nm) | O ( n m ) O(nm) O(nm) | 差 | 较复杂 |
| 二维数组 | O ( n m ) O(nm) O(nm) | O ( n m ) O(nm) O(nm) | 一般 | 简单 |
| 滚动数组 | O ( n m ) O(nm) O(nm) | O ( m ) O(m) O(m) | 较高 | 中等 |
| 降维优化 | O ( n m ) O(nm) O(nm) | O ( m ) O(m) O(m) | 高 | 简单 |
提示:
- 记忆化搜索由于递归深度,空间复杂度在 O ( n m ) O(nm) O(nm) 的基础上加 O ( n ) O(n) O(n)。
因空间复杂度的标准写法 ,这里不加上 O ( n ) O(n) O(n),只要知道就行,后面不再赘述。 - 记忆化搜索由于递归原因 ,时间复杂度的常数因子 比二维数组要大。
适用场景:
- 理解阶段:DFS 和记忆化搜索必须掌握。
- 学习阶段:推荐二维数组,易于理解。
- 竞赛小数据:滚动数组,平衡性能和可读性。
- 竞赛大数据:一维降维,极致性能。
:::warning[易错点]
- 记忆化搜索或朴素 DFS 由于 n n n 过大,导致栈溢出,引发 RE。
- 二维数组 和滚动数组 第 i i i 行没有继承第 i − 1 i-1 i−1 行导致错误。
- 滚动数组误用位运算导致结果错误。
- 降维优化 正序遍历 j j j 导致错误(应倒序遍历)。
- 降维优化 本质上是在一行内操作,仅省略了阶段变量 i i i,因此需要完全理解 二维数组的状态转移再进行学习降维优化。
:::
完全背包
:::success[如何理解完全背包]
我第一次 学完全背包时,和你想的一模一样:用一个循环 for (int k=0;k*w[i]<=j;k++) 来枚举选几件。这是最直观 、最符合暴力搜索思维 的方法。
但是后来我发现 :这种"枚举 k k k"的思路写出来的代码是三重循环,效率太低。
真正的突破 是当我发现:如果允许重复选,那么状态转移方程中,选第 i i i 件物品时,依赖的其实是 f i , j − w i f_{i,j-w_i} fi,j−wi (同一行 ),而不是 f i − 1 , j − w i f_{i-1,j-w_i} fi−1,j−wi(上一行)。
这个发现让我震惊:原来 01 背包和完全背包的本质区别 ,就是这一个下标的差别!这直接导致了代码中 j j j 的遍历方向相反。
所以,在本文中,我会先带你走我走过的路 ------从枚举 k k k 开始,理解问题本质;然后再带你看到更优美的优化 ------理解为什么只需要改一个下标。
:::
模型: 给定 n n n 种物品,其中第 i i i 种物品的体积为 w i w_i wi( w i ⩾ 1 w_i\geqslant1 wi⩾1),价值为 v i v_i vi,每种物品有无数件。有一体积为 m m m 的背包,请选择一些物品放入背包,使得在物品总体积不超过 m m m 的前提下,物品的价值总和最大。
暴力搜索 DFS 方法
首先,完全背包与01背包不同的是:01背包每件物品只能选择 1 1 1 次,而完全背包可以选择若干次 !
如果是01背包,我们直接枚举它选 或不选 即可,但完全背包呢?
我们可以用一个状态变量 k k k,表示第 i i i 件物品选 k k k 个。
根据实际情况, k k k 的取值范围应当为 0 ⩽ k ⩽ ⌊ j w i ⌋ 0\leqslant k\leqslant\left\lfloor\dfrac j{w_i}\right\rfloor 0⩽k⩽⌊wij⌋,表示最少选 0 0 0 个,枚举到装不下为止。
整体代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m;
int ans=0; //存储最终结果
int w[105],v[105];
void dfs(int i,int j,int cnt){
//i代表第i个物品,j代表当前背包剩余容量,cnt代表当前已选物品的总价值
if (i>n){ //选完所有物品
ans=max(ans,cnt); //更新最大价值
return;
}
//选择k个第i件物品,进行枚举(包含不选,也就是k=0的情况下)
for (int k=0;k*w[i]<=j;++k){
dfs(i+1,j-k*w[i],cnt+k*v[i]);
}
}
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d",&w[i],&v[i]);
}
//从第1个物品选择,当前剩余容量为m,当前已选物品总价值为0(实际上还没选)
dfs(1,m,0);
printf("%d",ans);
return 0;
}
由于完全背包 的第 i i i 件物品的决策 实际上有 ( k + 1 ) (k+1) (k+1) 个。
又因为最坏情况下 k = ⌊ m w i ⌋ k=\left\lfloor\dfrac m{w_i}\right\rfloor k=⌊wim⌋ ,同时 w i = 1 w_i=1 wi=1,则总时间复杂度为 O ( ( m + 1 ) n ) O((m+1)^n) O((m+1)n)。
观察完全背包的选择路线,我们可以发现,有很多重复计算的节点,因此,我们可以像01背包一样使用记忆化搜索 ,用一个 f f f 数组记录 f i , j f_{i,j} fi,j 时得到的从该状态出发能获得的最大价值,如果其他路径再次遇到这个状态,可以直接查表返回。
类似地,我们用直接枚举的方法,代码就是这样的:
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m;
int ans=0; //存储最终结果
int w[105],v[105];
void dfs(int i,int j,int cnt){
//i代表第i个物品,j代表当前背包剩余容量,cnt代表当前已选物品的总价值
if (i>n){ //选完所有物品
ans=max(ans,cnt); //更新最大价值
return;
}
dfs(i+1,j,cnt); //不选第i件物品
if (j>=w[i]){
dfs(i,j-w[i],cnt+v[i]); // 最特别地!!!
// 这里的i不加1,是因为我们还可以重复选择这件物品
// cnt+v[i] 表示我们选择了这件物品,增加了它的价值
}
}
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d",&w[i],&v[i]);
}
//从第1个物品选择,当前剩余容量为m,当前已选物品总价值为0(实际上还没选)
dfs(1,m,0);
printf("%d",ans);
return 0;
}
记忆化搜索 方法
首先,由于背包受到 i , j , k i,j,k i,j,k 三个阶段变量的制约,因此,我们可以很自然地想到:定义一个三维数组 f f f,大小为 n × m × k n\times m\times k n×m×k,实际操作中, f f f 数组的大小为 n × m × m n\times m\times m n×m×m。
但是,由于这样的代码实在过于复杂,而且空间复杂度到了不可承受的地步 ,只要 n , m n,m n,m 稍微大一点点,就会导致 MLE。
因此,我们可以换一种思路:不使用 k k k 来标记,用完全背包的最优子结构 性质,存储 f i , j f_{i,j} fi,j 的最优解。
整体代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int w[105],v[105],f[1005][10005];
int m,n;
int dfs_memo(int i, int j){
//i表示第i件物品,j表示当前背包剩余容量为j
if (i>n) return 0; //选完所有物品
if (f[i][j] != -1) return f[i][j];
int res = dfs_memo(i+1, j); //不选第i个物品
for (int k=1;k*w[i]<=j;++k){
//虽然不用k标记,但实际操作中需要使用k
int choose = dfs_memo(i+1, j-k*w[i])+k*v[i];
res = max(res, choose);
}
return f[i][j] = res;
}
int main()
{
memset(f,-1,sizeof f);
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d",&w[i],&v[i]);
}
printf("%d",dfs_memo(1,m));
return 0;
}
同理,我们也可以用直接枚举 的方法来书写代码。
整体代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int w[105],v[105],f[1005][10005];
int m,n;
int dfs_memo(int i, int j){
//i表示第i件物品,j表示当前背包剩余容量为j
if (i>n) return 0; //选完所有物品
if (f[i][j] != -1) return f[i][j];
int res = dfs_memo(i+1, j); //不选第i个物品
if (j>=w[i]){
res = max(res, dfs_memo(i,j-w[i],cnt+v[i]);)
//最重要的是i不能+1,否则就会退化为01背包,失去重复选择的特性
//因为还是i就表示还是选择第i件物品
}
return f[i][j] = res;
}
int main()
{
memset(f,-1,sizeof f);
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d",&w[i],&v[i]);
}
printf("%d",dfs_memo(1,m));
return 0;
}
同样地,由于 n n n 的增大,被占用的栈空间不断增多 ,最终耗尽栈空间 ,引发 RE。
这个时候就要用递推式的 DP 出场了。
完全背包 DP 方法
阶段划分
问题受 n , m n,m n,m 的限制,同时还要判断要选多少件,设为 k k k 件, k ⩾ 1 k\geqslant1 k⩾1。
参考01背包和朴素 DFS,我们可以列出如下基础完全背包状态转移方程:
f i , j = max { f i − 1 , j f i − 1 , j − w i × k + v i × k j ⩾ w i × k f_{i,j}=\max\begin{cases} f_{i-1,j}\\ f_{i-1,j-w_i\times k}+v_i\times k&j\geqslant w_i\times k \end{cases} fi,j=max{fi−1,jfi−1,j−wi×k+vi×kj⩾wi×k
其中, f i − 1 , j − w i × k + v i × k f_{i-1,j-w_i\times k}+v_i\times k fi−1,j−wi×k+vi×k 表示在背包容量为 j j j 的情况下,用去 w i × k w_i\times k wi×k 的体积,换取 v i × k v_i \times k vi×k 的价值。
由于放入的物品总体积不能超过 j j j,又选择 k k k 个,因此 j ⩾ w i × k j\geqslant w_i\times k j⩾wi×k。
代码实现
关键代码如下:
cpp
for (int i=1;i<=n;++i){
for (int j=0;j<=m;++j){
f[i][j] = f[i-1][j]; //继承,当k=0,即不选第i件物品的情况
for (int k=1;k*w[i]<=j;++k){
f[i][j]=max(f[i][j], f[i-1][j-k*w[i]]+k*v[i]); //比较选多少种好
}
}
}
最终结果存储在 f[n][m] 中,输出即可。
当然,在前面提到,我们可以用"不断放物品,直到放不下为止",也就是
f i , j = max { f i − 1 , j f i , j − w i + v i j ⩾ w i f_{i,j}=\max\begin{cases} f_{i-1,j}\\ f_{i,j-w_i}+v_i&j\geqslant w_i \end{cases} fi,j=max{fi−1,jfi,j−wi+vij⩾wi
这个公式正好对应了上面的 DFS 思路 :dfs(i, j-w[i], cnt+v[i]) 中的 i i i 不变,意味着我们可以继续考虑同一件物品;而 f i , j − w i f_{i,j-w_i} fi,j−wi 正是依赖同一行(当前物品)的之前状态。
关键代码如下:
cpp
for (int i=1;i<=n;++i){
for (int j=0;j<=m;++j){
f[i][j] = f[i-1][j]; //继承,当k=0,即不选第i件物品的情况
if (j>=w[i]){
f[i][j]=max(f[i][j], f[i][j-w[i]]+v[i]);
}
}
}
这样,时间复杂度优化为 O ( n m ) O(nm) O(nm),大大提高了效率。
完全背包滚动数组优化
我们发现,完全背包的第 i i i 行同样只依赖第 i − 1 i-1 i−1 行,因此可以进行滚动数组优化 。
定义一个数组 f f f,大小为 2 × m 2\times m 2×m。
关键代码如下:
cpp
for (int i=1;i<=n;++i){
int curr=i%2; //当前行
int prev=(i-1)%2; //上一行
for (int j=0;j<=m;++j){ //从0开始,因为j=0时只能不选
f[curr][j] = f[prev][j]; // 不选当前物品
if (j>=w[i]){
f[curr][j]=max(f[curr][j], f[curr][j-w[i]]+v[i]);
//这里依赖的是f[curr][j-w[i]],即当前行的值,体现了完全背包可重复选择的特性
}
}
}
该代码与01背包不同的就是当 j ⩾ w i j\geqslant w_i j⩾wi 时, f c u r r , j − w i + v i f_{curr,j-w_i}+v_i fcurr,j−wi+vi 体现了重复选择。
同样地,由于我们是按照顺序处理 的,所以答案同样存储在 f[n%2][m]。
完全背包降维优化
前面讲过,01背包需要倒序遍历才能使得只选一次,正序遍历会导致多次选择,完全背包正好符合正序遍历的要求,因此完全背包的降维优化是正序遍历 的。
f j = max { f j f j − w i + v i f_j=\max\begin{cases} f_j\\ f_{j-w_i}+v_i \end{cases} fj=max{fjfj−wi+vi
关键代码如下:
cpp
for (int i=1;i<=n;++i){
for (int j=w[i];j<=m;++j){
f[j]=max(f[j],f[j-w[i]]+v[i]);
}
}
最终结果存储在 f[m] 中,输出即可。
同样地,我们演示正序遍历 的结果:
实例演示 (物品:重量 2 2 2,价值 3 3 3;背包容量 5 5 5):
:::success[正序遍历过程]
- j = 2 j=2 j=2: max ( f 2 , f 0 + 3 ) = max ( 0 , 0 + 3 ) = 3 \max(f_2,f_0+3)=\max(0,0+3)=3 max(f2,f0+3)=max(0,0+3)=3。
- j = 3 j=3 j=3: max ( f 3 , f 1 + 3 ) = max ( 0 , 0 + 3 ) = 3 \max(f_3,f_1+3)=\max(0,0+3)=3 max(f3,f1+3)=max(0,0+3)=3。
- j = 4 j=4 j=4: max ( f 4 , f 2 + 3 ) = max ( 0 , 3 + 3 ) = 6 \max(f_4,f_2+3)=\max(0,3+3)=6 max(f4,f2+3)=max(0,3+3)=6。
- j = 5 j=5 j=5: max ( f 5 , f 3 + 3 ) = max ( 0 , 3 + 3 ) = 6 \max(f_5,f_3+3)=\max(0,3+3)=6 max(f5,f3+3)=max(0,3+3)=6。
结果 :物品被重复选择 ,最大价值 6 = 3 × 2 6=3\times2 6=3×2!
:::
推荐练习题目:
- 洛谷 P1616 疯狂的采药 - 完全背包模版
- 洛谷 P1941 飞扬的小鸟- 完全背包模型
- 洛谷 P5020 货币系统 - 完全背包模型
- 洛谷 P1474 货币系统 - 完全背包应用
- 洛谷 P1025 数的划分 - 完全背包应用
- 洛谷 P1679 神奇的四次方数 - 完全背包变形
完全背包总结
| 实现方法 | 时间复杂度 | 空间复杂度 | 缓存友好度 | 代码复杂度 |
|---|---|---|---|---|
| 朴素 DFS | O ( m + 1 ) n O(m+1)^n O(m+1)n | O ( n ) O(n) O(n) | 差 | 极简 |
| 记忆化搜索 | O ( n m ⋅ ⌊ m w i ⌋ ) O\left(nm\cdot\left\lfloor\dfrac m{w_i}\right\rfloor\right) O(nm⋅⌊wim⌋) | O ( n m ) O(nm) O(nm) | 差 | 较复杂 |
| 二维数组 1 1 1 | O ( n m ⋅ ⌊ m w i ⌋ ) O\left(nm\cdot\left\lfloor\dfrac m{w_i}\right\rfloor\right) O(nm⋅⌊wim⌋) | O ( n m ) O(nm) O(nm) | 一般 | 简单 |
| 二维数组 2 2 2 | O ( n m ) O(nm) O(nm) | O ( n m ) O(nm) O(nm) | 一般 | 简单 |
| 滚动数组 | O ( n m ) O(nm) O(nm) | O ( m ) O(m) O(m) | 较高 | 中等 |
| 降维优化 | O ( n m ) O(nm) O(nm) | O ( m ) O(m) O(m) | 高 | 简单 |
提示:
- 记忆化搜索 由于递归深度,空间复杂度在 O ( n m ) O(nm) O(nm) 的基础上加 O ( n ) O(n) O(n)。
- 记忆化搜索 由于递归原因 ,时间复杂度的常数因子 比二维数组要大。
- 如果二维数组 采用第 2 2 2 个方案,则时间复杂度为 O ( n m ) O(nm) O(nm)。
与 01 背包的关键区别:
- 01 背包:逆序遍历,保证只选一次。
- 完全背包:正序遍历,允许重复选择。
- 状态转移:01背包依赖上一行,完全背包可依赖当前行。
- 核心差异在于状态转移时所依赖的上一状态 是来自上一行(01背包)还是本行(完全背包),这直接决定了代码中 j j j 的遍历方向。
适用场景:
- 理解阶段:DFS 和记忆化搜索必须掌握。
- 学习阶段:推荐二维数组,易于理解。
- 竞赛小数据:滚动数组,平衡性能和可读性。
- 竞赛大数据:一维降维,极致性能。
:::warning[易错点]
- 记忆化搜索或朴素 DFS 由于 n n n 过大,导致栈溢出,引发 RE。
- 二维数组 和滚动数组 第 i i i 行没有继承第 i − 1 i-1 i−1 行导致错误。
- 二维数组 第 2 2 2 种方法的状态转移错误(应从 f i , j − w i f_{i,j-w_i} fi,j−wi 而不是 f i − 1 , j − w i f_{i-1,j-w_i} fi−1,j−wi 转移到 f i , j f_{i,j} fi,j)。
- 滚动数组误用位运算导致结果错误。
- 降维优化 倒序遍历 j j j 导致错误(应正序遍历)。
- 降维优化 本质上是在一行内完成操作,只是省略了阶段变量 i i i,因此需要深刻理解 二维数组的状态转移方程再进行学习降维优化。
:::
多重背包
模型: 给定 n n n 种物品,其中第 i i i 种物品的体积为 w i w_i wi,价值为 v i v_i vi,每种物品有 c i c_i ci 件。有一体积为 m m m 的背包,请选择一些物品放入背包,使得在物品总体积不超过 m m m 的前提下,物品的价值总和最大。
暴力搜索 DFS 方法
我们看到完全背包的朴素 DFS 中, 0 ⩽ k ⩽ ⌊ j w i ⌋ 0\leqslant k\leqslant\left\lfloor\dfrac j{w_i}\right\rfloor 0⩽k⩽⌊wij⌋,这是由于完全背包可以无限选择 所导致。
然而,多重背包还有物品数量的限制 c i c_i ci ,这就要求 k ≯ c i k\ngtr c_i k≯ci。
综上,在多重背包里, k k k 的取值范围为 0 ⩽ k ⩽ min ( c i , ⌊ j w i ⌋ ) 0\leqslant k\leqslant\min\left(c_i,~\left\lfloor\dfrac j{w_i}\right\rfloor\right) 0⩽k⩽min(ci, ⌊wij⌋)。这样保证每件物品选择数量不超过物品数量 c i c_i ci 和背包容量 ⌊ j w i ⌋ \left\lfloor\dfrac j{w_i}\right\rfloor ⌊wij⌋。
完整代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m,w[105],v[105],c[105];
int ans;
//这里的 w,v,c数组均与模型中描述的功能已知
void dfs(int i,int j,int cnt){
//i代表第i个物品
//j代表当前背包剩余容量为j
//cnt代表当前已选择的物品的总价值
if (i>n){
ans=max(ans, cnt);
return;
}
//k=0表示不选,其它的就是 k的取值范围
for (int k=0;k<=min(c[i],j/w[i]);++k){
dfs(i+1,j-k*w[i],cnt+k*v[i]);
}
}
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d%d",&w[i],&v[i],&c[i]);
}
dfs(1,m,0);
printf("%d",ans);
return 0;
}
由于多重背包的每件物品的决策有 k k k 个,参考完全背包的朴素 DFS,其中 k = ⌊ m w i ⌋ + 1 k=\left\lfloor\dfrac m{w_i}\right\rfloor+1 k=⌊wim⌋+1。因此时间复杂度为
O ( ( ⌊ m w i ⌋ + 1 ) n ) O\left(\left(\left\lfloor\dfrac m{w_i}\right\rfloor+1\right)^n\right) O((⌊wim⌋+1)n)
根据多重背包背包的最优子结构性质 ,我们可以使用一个 f f f 数组记录 f i , j f_{i,j} fi,j 时得到的从该状态出发能获得的最大价值,也就是记忆化搜索。
记忆化搜索 方法
完整代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m,w[105],v[105],c[105];
int f[105][40005];
int dfs_memo(int i,int j){
//i表示第i件物品,j表示当前背包剩余容量为j
if (i>n) return 0; //选完所有物品
if (f[i][j]!=-1) return f[i][j];
int res=dfs_memo(i+1,j); //不选第i件物品
for (int k=1;k<=min(c[i],j/w[i]);++k){
//选择物品
int choose = dfs_memo(i+1, j-k*w[i])+k*v[i];
res=max(res, choose);
}
return f[i][j] = res;
}
int main()
{
memset(f,-1,sizeof f);
scanf("%d%d",&n,&m);
for (int i=1;i<=n;++i){
scanf("%d%d%d",&v[i],&w[i],&c[i]);
}
printf("%d",dfs_memo(1,m));
return 0;
}
多重背包 DP 方法
阶段划分
问题受 n , m n,m n,m 的限制,同时还要考虑每件物品的数量限制 c i c_i ci。
参考完全背包,我们可以列出如下基础多重背包状态转移方程:
f i , j = max { f i − 1 , j f i − 1 , j − w i × k + v i × k k ⩽ min ( c i , ⌊ j w i ⌋ ) f_{i,j}=\max\begin{cases} f_{i-1,j}\\ f_{i-1,j-w_i\times k}+v_i\times k&k\leqslant\min\left(c_i,\left\lfloor\dfrac j{w_i}\right\rfloor\right) \end{cases} fi,j=max⎩ ⎨ ⎧fi−1,jfi−1,j−wi×k+vi×kk⩽min(ci,⌊wij⌋)
其中, k k k 表示选择当前物品的数量,不能超过物品的总数量 c i c_i ci,也不能超过背包容量限制 ⌊ j w i ⌋ \left\lfloor\dfrac j{w_i}\right\rfloor ⌊wij⌋。
代码实现
关键代码如下:
cpp
for (int i=1;i<=n;++i){
for (int j=0;j<=m;++j){
f[i][j] = f[i-1][j]; //继承,不选当前物品
for (int k=1;k<=min(c[i],j/w[i]);++k){
f[i][j]=max(f[i][j], f[i-1][j-k*w[i]]+k*v[i]);
}
}
}
最终结果存储在 f[n][m] 中,输出即可。
:::success[基础解法过程]
实例演示 (物品:重量 2 2 2,价值 3 3 3,数量 2 2 2;背包容量 5 5 5):
- 可选数量范围: min ( 2 , ⌊ 5 2 ⌋ ) = 2 \min\left(2, \left\lfloor\dfrac 52\right\rfloor\right)=2 min(2,⌊25⌋)=2
- 比较三种情况:
- 不选: f i , 5 = f i − 1 , 5 f_{i,5}=f_{i-1,5} fi,5=fi−1,5
- 选 1 1 1 个: f i − 1 , 3 + 3 f_{i-1,3}+3 fi−1,3+3
- 选 2 2 2 个: f i − 1 , 1 + 6 f_{i-1,1}+6 fi−1,1+6
- 取最大值作为最终结果
:::
多重背包滚动数组优化
与 01 背包、完全背包类似,多重背包也可以使用滚动数组优化空间。
关键代码:
cpp
for (int i=1;i<=n;++i){
int curr=i%2; //当前行(第i行)
int prev=(i-1)%2; //上一行(第i-1行)
for (int j=0;j<=m;++j){
f[curr][j] = f[prev][j]; // 不选当前物品
for (int k=1;k<=min(c[i],j/w[i]);++k){
f[curr][j]=max(f[curr][j], f[prev][j-k*w[i]]+k*v[i]);
}
//也可以让 k从 0到 min(c[i],j),这样就有不选的意思,但是我这样写可以更清晰
//两种写法性能差异不大
}
}
同样地,由于我们是按照顺序处理的,最终结果存放在 f[n%2][m],输出即可。
多重背包二进制优化
由于多重背包三重循环的时间复杂度是 O ( n m ⋅ min ( c i , m w i ) ) O\left(nm\cdot\min\left(c_i,~\dfrac m{w_i}\right)\right) O(nm⋅min(ci, wim)),在 c i c_i ci 较大时效率很低。
二进制优化原理 :
任何一个正整数都可以表示为若干个 2 2 2 的幂次之和。
通过二进制拆分,我们将数量为 c i c_i ci 的物品拆分成 log 2 c i \log_2c_i log2ci 个"新物品",每个新物品的数量是 2 2 2 的幂次,从而将多重背包转化为 01 背包问题。
二进制拆分示例 :
假设某物品有 13 13 13 个,我们拆分为 13 = 1 + 2 + 4 + 6 13=1+2+4+6 13=1+2+4+6(因为 1 + 2 + 4 = 7 < 13 1+2+4=7<13 1+2+4=7<13,剩余 6 6 6)
这样用 1 , 2 , 4 , 6 1,2,4,6 1,2,4,6 这四个"新物品"就能组合出 0 ∼ 13 0\sim13 0∼13 的所有数量!
为什么我们可以这样拆分?
因为 1 、 2 、 4 1、2、4 1、2、4 可以组合出 0 ∼ 7 0\sim7 0∼7 的所有数,加上 6 6 6 后就能覆盖 0 ∼ 13 0\sim13 0∼13 的全部范围。
二进制优化完整实现:
cpp
#include <bits/stdc++.h>
using namespace std;
int n,m;// 原始数据
vector<int> new_w,new_v; // 存储拆分后的物品
// 这里用vector,是因为我们不知道二进制拆分后的数据长度是多少
// 应该是这样吧
int main()
{
scanf("%d%d",&n,&m);
// 二进制拆分
for (int i=0;i<n;++i){
int w,v,c;
scanf("%d%d%d",&w,&v,&c);
// 二进制拆分
for (int k=1;k<=c;k*=2){
if (k*w>m) break; // 拆分出的单件物品已超总容量,无法放入,停止拆分
new_w.push_back(w*k);
new_v.push_back(v*k);
c-=k;
}
if (c>0){
new_w.push_back(w*c);
new_v.push_back(v*c);
}
}
// 转换为01背包
vector<int>f(m+5,0);
int new_n=new_w.size();
//按照01背包的步骤来做,只是替换为新的物品数量、价值和重量
for (int i=0;i<new_n;++i){
for (int j=m;j>=new_w[i];--j){
f[j]=max(f[j],f[j-new_w[i]]+new_v[i]);
}
}
printf("%d",f[m]);
return 0;
}
时间复杂度优化为 O ( n m ⋅ log c i ) O(nm\cdot\log c_i) O(nm⋅logci),大大提高了效率。
当然,在二进制拆分后,你也可以按照 01 背包的二维数组 、滚动数组进行计算。此处不再展开。
多重背包总结
| 实现方法 | 时间复杂度 | 空间复杂度 | 缓存友好度 | 代码复杂度 |
|---|---|---|---|---|
| 朴素 DFS | O ( ( ⌊ m w i ⌋ + 1 ) ) n O\left(\left(\left\lfloor\dfrac m{w_i}\right\rfloor+1\right)\right)^n O((⌊wim⌋+1))n | O ( n ) O(n) O(n) | 差 | 极简 |
| 记忆化搜索 | O ( n m ⋅ min ( c i , ⌊ m w i ⌋ ) ) O\left(nm\cdot\min\left(c_i,~\left\lfloor\dfrac m{w_i}\right\rfloor\right)\right) O(nm⋅min(ci, ⌊wim⌋)) | O ( n m ) O(nm) O(nm) | 差 | 差 |
| 二维数组 | O ( n m ⋅ min ( c i , ⌊ m w i ⌋ ) ) O\left(nm\cdot\min\left(c_i,~\left\lfloor\dfrac m{w_i}\right\rfloor\right)\right) O(nm⋅min(ci, ⌊wim⌋)) | O ( n m ) O(nm) O(nm) | 一般 | 简单 |
| 滚动数组 | O ( n m ⋅ min ( c i , ⌊ m w i ⌋ ) ) O\left(nm\cdot\min\left(c_i,~\left\lfloor\dfrac m{w_i}\right\rfloor\right)\right) O(nm⋅min(ci, ⌊wim⌋)) | O ( m ) O(m) O(m) | 较高 | 较高 |
| 二进制优化 | O ( m ⋅ ∑ log c i ) O(m\cdot\sum\log c_i) O(m⋅∑logci) | O ( m ) O(m) O(m) | 高 | 复杂 |
提示:
- 二进制优化 的时间复杂度中的 ∑ log c i \sum\log c_i ∑logci 表示拆分后的物品总数。
- 多重背包由
与其他背包的关系:
- 当所有 c i = 1 c_i=1 ci=1 时:退化 为 01 背包。
- 当所有 c i ⩾ ⌊ m w i ⌋ c_i\geqslant\left\lfloor\dfrac m{w_i}\right\rfloor ci⩾⌊wim⌋ 时:退化 为完全背包。
- 二进制优化后:转化 为 01 背包问题。
- 总结:01 背包和完全背包只是多重背包的特殊情况。
推荐练习题目:
- 洛谷 P1776 宝物筛选 - 多重背包模版
- 洛谷 P2347 砝码称重 - 多重背包应用
- 洛谷 P1782 旅行商的背包 - 多重背包+01 背包
- 洛谷 P1833 樱花 - 混合背包(含多重背包)
- 洛谷 P2347 砝码称重 - 多重背包应用
适用场景建议:
- 理解阶段:DFS 和记忆化搜索必须掌握。
- 学习阶段:基础解法,理解多重背包本质。
- 竞赛实战:二进制优化,平衡效率与实现难度。
:::warning[易错点]
- 记忆化搜索或朴素 DFS 未判断 k ⩽ c i k\leqslant c_i k⩽ci 的情况,导致退化为完全背包。
- 记忆化搜索或朴素 DFS 由于 n n n 过大,导致栈溢出,引发 RE。
- 二维数组 和滚动数组 第 i i i 行没有继承第 i − 1 i-1 i−1 行导致错误。
- 滚动数组误用位运算导致结果错误。
- 二进制拆分 后,问题已转化为对若干独立新物品 的01背包 问题,因此内层循环必须使用倒序枚举 j j j,这与 01 背包的要求完全一致。
- 复杂的单调队列 导致编写错误。
:::
结语
本文介绍了01 背包 、完全背包 、多重背包 的 DFS 暴力搜索 到动态规划,由浅入深,非常适合刚刚入门背包问题的新手学习。
其中,DFS 能够帮助初学者理解背包问题的本质,从而更好地学习背包优化 、背包变种问题。
致谢与创作谈:
- 感谢各位 OIer 的阅读。为求表达更流畅,本文使用了 DeepSeek 进行语句润色 ,但全文的知识脉络、代码实践、教学设计,皆出自我个人的学习沉淀。
- 我是怎么写这篇文章的? 我没有打草稿的习惯,习惯在脑子里把一个问题彻底想穿 ,同时边写边想:从 DFS 的递归树长啥样,到 f i , j f_{i,j} fi,j 这个状态到底在指什么,再到为什么 j j j 要倒着遍历------全都想明白了,才打开编辑器一气呵成,写完后进行改动。
在最开始的时候,这篇文章只有 DP 的内容;后来,我加上 DFS 和记忆化搜索 ,变得更符合初学者的学习路线,同时加上优秀作品 ,从 10000 10000 10000 字到了 24000 24000 24000 字。
因此,你看到的文章结构,就是我去年啃背包时大脑中的思考流程图。文中的每个"易错点",都是我提交记录里真实的 WA 与 RE 换来的教训。 - 对于本文中的所有代码 ,均在洛谷题目测试通过。由于部分题目的测试点数据量过多,因此 DFS 、记忆化搜索 、二维数组等未能全部通过测试点,因此仅能按照部分分来验证我的代码是否准确。
- 感谢各路大神对我的这篇文章的提醒。阴差阳错地,平台一次次的 AI 检测,倒是这篇文章的内容更加丰满。最后我才发现时长被 AI 检测的问题。
- 本文各个代码风格可能不尽相同,请读者见谅。
- 特别感谢《信息学奥林匹克竞赛实战笔记 B 册》(浙江大学出版社)这部指导书,作者在学习 DP 的时候就用到这本书,收获颇多。
- 由于作者水平有限及时间仓促,文中如有疏漏之处,欢迎批评指正和评论该文章。
- 作者一般节假日 和工作日晚上 9 9 9 点以后在线,会尽快处理各位读者的评论,若您的评论长时间未得到处理,请耐心等待。
- 希望您能够在这篇文章了解背包、学习背包、深悟背包。
- 如果你是蒟蒻,你可以从这篇文章学习到完整的学习路径的背包 DP。
- 如果你是神犇,你可以通过这篇文章来回顾自己的背包 DP。
洛谷其他背包 DP 优秀文章
- 背包问题 (附单调队列优化多重背包)(顾z 著)
评价: 2018 2018 2018 年 9 9 9 月 13 13 13 日发表,品质值得信赖,评论较多。 - 背包问题(weichenglu 著)
评价: 2025 2025 2025 年 10 10 10 月 21 21 21 日发表,品质优良,时效性强。 - 背包问题模板(残阳如血 著)
评价: 2025 2025 2025 年 1 1 1 月 31 31 31 日发表,图文并茂,解答详细。
站外背包 DP 优秀作品
常见问题解答
Q:为什么我复制你的代码后输出不正确?你不是说都测试过吗?
A :因为有些题目输入的顺序不同,以洛谷 P1048 采药为例,我的代码是先输入 n n n(物品总数),再输入 m m m(背包容量),但是很明显,这道题是反过来的。
我的建议:要深刻理解背包的思想,自己"手搓"出来的代码才是最真实的,最符合实际题目的。
Q:看完文章我还是不懂,是不是我太笨了?
A:绝对不是! 我当初学背包时,每个阶段都卡过:
- DFS 觉得这有什么用
- 记忆化搜索觉得好麻烦
- 二维 DP 觉得公式好复杂
- 降维优化觉得为什么 j j j 要倒着遍历。
我的建议:不要试图一次全懂。你可以这样:
- 今天看懂 DFS,写几道题
- 明天再看记忆化搜索
- 再过几天看二维 DP
给自己至少两周 的时间让这些概念在脑子里沉淀。动态规划是需要反刍的知识。
Q:为什么提交 DFS 或记忆化搜索代码会出现 RE?
A :这通常是由于递归深度过大导致栈溢出。系统递归栈的深度有限 ,当物品数 n n n 很大时,递归层数可能超出限制 。这正是一个生动的教训:我们学习 DFS 和记忆化是为了理解思想,但在竞赛实战中,必须将其转化为更稳定、高效的迭代 DP 代码。
Q:多重背包的二进制优化为什么有效?
A :其有效性基于一个数学事实 :任何正整数都可以由若干个 2 2 2 的幂次和一个剩余数组合表示。通过这种拆分,我们将选择 0 ∼ c 0\sim c 0∼c 个物品 的 O ( c ) O(c) O(c) 次决策,转化为对 O ( log 2 c ) O(\log_2c) O(log2c) 个新物品 的 01 背包问题。这本质上是一种信息压缩 ,用更少的状态表达了全部的可能性。
Q:背包问题中最容易忽略的关键点是什么?
A :初始化和状态定义的语义。这直接对应了问题的两种不同要求(以二维数组为例):
- 不要求装满 :整个 f f f 为 0 0 0。含义是任何容量的背包,在不装任何物品时价值为 0 0 0,且合法。
这个时候,结果存储在 f n , m f_{n,m} fn,m。 - 要求恰好装满 : f 0 , 0 = 0 f_{0,0}=0 f0,0=0,其余设为 − ∞ -\infty −∞。含义是只有容量为 0 0 0 的背包能被恰好装满 ,其他初始状态都不可达 。 − ∞ -\infty −∞ 保证了所有有效状态都必须从 f 0 f_0 f0 转移而来,从而确保背包被装满。
如果题目要求恰好装满 ,答案就在 f n , m f_{n,m} fn,m;如果 f n , m = − ∞ f_{n,m}=-\infty fn,m=−∞,则无法将背包恰好装满。
如果题目不要求装满 ,答案在 f n , 1 , f n , 2 , ... , f n , m − 1 , f n , m f_{n,1},f_{n,2},\dots,f_{n,m-1},f_{n,m} fn,1,fn,2,...,fn,m−1,fn,m 之中取最大值。
版权声明
本文采用 知识共享 署名-非商业性使用-相同方式共享 4.0 国际 许可协议 进行许可。
您需遵守下列条件:
- 署名 :您必须给出适当的署名,提供指向本许可协议的链接,并标明是否对原始作品作了修改。您可以用任何合理的方式来署名,但不得以任何方式暗示许可人为您或您的使用背书。
- 非商业性使用 :您不得将本作品用于商业目的。
- 相同方式共享 :如果您再混合、转换或者基于本作品进行创作,您必须基于与原先相同的许可协议分发您贡献的作品。
最后,欢迎各位读者引用本文,祝您背包 DP 之路一帆风顺!