背包
一般代码只是例子,具体使用依据题目来, DP是一种思想,代码都以属性为最大值等等为例子
01背包
最基本的背包
简单说就是有n个物品和容量为m的包,求其max/min/方案数等等即属性
一般转移方程为f[i][j]意思为在前i个里容量为j的情况下的要求的属性
(可忽略)一般这里的转移是在f[i][j],第i个数取与不取
时间复杂度O(n*m)
一般代码
for (int i = 1; i <= n; i ++ ) // 枚举当前几个物品
{
int v, w;
cin >> v >> w;
for (int j = 1; j <= m; j ++ )
{
f[i][j] = f[i - 1][j];
if (j >= v) f[i][j] = max(f[i][j], f[i - 1][j - v] + w);
}
}
一维优化
这个优化是根据转移方程而来,所以无论是求方案数还是最值都成立,
for (int i = 1; i <= n; i ++ ) // 枚举当前几个物品
{
int v, w;
cin >> v >> w;
for (int j = m; j >= v; j -- ) // 体积
f[j] = max(f[j], f[j - v] + w);
}
必须倒序枚举体积,我们的f[j - v]用的实际是f[i - 1][j - v], 而正序枚举我们可能会先更新了
f[j - v]使得后面用到f[j - v]时实际用的是f[i][j - v]从而错误, 只要倒序就可以解决此问题
扩展
01 背包的体积,本质上就是一个限制,是可以利用的,比如在限制体积不超过 \(j\) 的情况下取得最大值,很多时候可以靠这个限制,找到得到最接近限制的值。
如这题的 01 背包做法。
枚举顺序
所有正确都来自于转移方程 f[i][j] = max(f[i - 1][j], f[i - 1][j - v] + w);
对于 01 背包而言,如果先枚举 i 再枚举 j,那么 i 必须从小到大枚举(保证 \(f_{i-1}\) 更新完毕),此时 j 正序还是倒序枚举都可以,因为不影响转移正确性。
如果是先枚举 \(j\) 再枚举 \(i\),那么 \(j\) 也必须正序枚举,而因为转移中,f[i - 1][j] 涉及到了当前的 \(j\),为了保证它先更新完,那么 \(i\) 必须也是正序枚举。而且因为先枚举的是 \(j\),\(i\) 在后面会多次枚举到,因此滚动数组和一维优化都无法使用。
完全背包
即有n种物品,每种无限个
一般的转移方程为 f[i][j] = max(f[i - 1][j], f[i][j - v]);
优化代码
O(n*m)
for (int i = 1; i <= n; i ++ )
{
int w, v;
cin >> v >> w;
for (int j = 1; j <= m; j ++ ) // 从0开始或者从1开始都行,看个人习惯
{
f[i][j] = f[i - 1][j];
if (j >= v) f[i][j] = max(f[i][j], f[i][j - v] + w);
}
}
朴素版
O(n*m*k)
for (int i = 1; i <= n; i ++ )
{
int v, w;
cin >> v >> w;
for (int j = 1; j <= m; j ++ )
for (int k = 0; k <= j / v; k ++ )
f[i][j] = max(f[i][j], f[i - 1][j - v * k] + w * k);
}
方程推导
正常的思路:
f[i][j] = max(f[i - 1][j], f[i - 1][j - v] + w, f[i - 1][j - 2v] + 2w,...,f[i][j - kv] + kw)
k∈(0, j/v);
这种复杂度过高,一般接受不了
一般的方程f[i][j] = max(f[i - 1][j], f[i][j - v]);
f[i][j - v]如何得来? (注意 max 中的对应)
f[i][j] = max(f[i - 1][j], f[i - 1][j - v], f[i - 1][j - 2v],...,f[i][j - kv])
########f[i][j - v] = max(f[i - 1][j - v], f[i - 1][j - 2v],...,f[i][j - kv])
k是相同的
可得f[i][j] = max(f[i - 1][j], f[i][j - v]);
加上价值就是f[i][j] = max(f[i - 1][j], f[i][j - v] + w);
这种思路也可以用来优化其他 DP。
关于为什么能想到使用 f[i][j - v] 是因为,只有 [j - v] 才有可能带来后面的相同。
一维优化
for (int i = 1; i <= n; i ++ )
{
int v, w;
cin >> v >> w;
for (int j = v; j <= m; j ++ )
f[j] = max(f[j], f[j - v] + w);
}
这里只能正序枚举,和 01 背包相反,因为我们转移的实际上是f[i][j - v],正序可以提前更新f[j - v]使他实际上成为f[i][j - v]符合要求倒序则为f[i - 1][j - v]不合要求
转移的注意
对于完全背包,一定要转移完全,即在从 k = 0 的时候也要转移进去,不能终断,否则会出现错误。
如P1941 [NOIP2014 提高组] 飞扬的小鸟 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)如果直接做即完全背包的上升和01背包的下降同时转移,那么祝贺你,会得到70分的代码.
多重背包
有 \(n\) 种物品但是每种物品有数量限制。
本质上,多重背包就是 01 背包的特殊情况,即有很多物品相同。(二进制优化)
或者说 01 背包是多重背包的特殊情况,只不过数量为 1 (单调队列优化)
朴素代码
for (int i = 1; i <= n; i ++ )
{
int w, v, s;
cin >> v >> w >> s;
for (int j = 1; j <= m; j ++ )
for (int k = 0; k <= min(s, j / v); k ++ )
f[i][j] = max(f[i][j], f[i - 1][j - k * v] + k * w);
}
二进制优化
把物品的个数 S 拆成一堆数,形成一个单独的物品,最后采用01背包去做
一般采用二进制的方法,把一个数 \(s\) 尽可能拆成一个个二进制下位数,最后剩余一个数。用这些数凑出所有小于等于 \(s\) 的非负数。
代码为
for (int i = 1; i <= n; i ++ )
{
int v, w, s, k = 1;
cin >> v >> w >> s;
while (k <= s)
{
V[ ++ cnt] = v * k;
W[cnt] = w * k;
s -= k; // 这样拆是logn个比较高效
k <<= 1;
}
if (s > 0) // 我们减到最后可能会有剩下的,也加上,这是为了凑出s这个数
{
V[ ++ cnt] = v * s;
W[cnt] = w * s;
}
}
for (int i = 1; i <= cnt; i ++ )
{
int v = V[i], w = W[i];
for (int j = m; j >= v; j -- )
f[j] = max(f[j], f[j - v] + w);
}
一般够用 \(O(nm\log s)\),实际上效率很好。
证明
为什么能凑出所有小于等于 \(s\) 的非负数?首先要知道,一堆连续的二进制位数,通过相加可以得到小于等于它们之和 \(sum\) 的所有非负数。
对于一个数 \(s\) 我们把它尽可能拆成一个个二进制下位数,即 \(0001,0010,0100,1000\) 这几种(也就是 \(2^0,2^1,2^2,2^3\)),可能会最后剩下一个非二进制下每一位的数 \(a\)。假设当前拆出来的最大二进制位数为 \(2^{k-1}\),因为剩余的数 \(a\) 无法再形成一个二进制位数 \(2^k\),所以 \(a\) 肯定小于 \(2^k\),而我们之前拆出来的二进制数之和 \(sum\) 为 \(2^k - 1\),所以 \(a\) 一定小于等于之前那些二进制数之和 \(sum\),也就是说对于这些二进制位数一点可以凑出 \(a\)。
对于小于 \(a\) 的数 \(b\),它肯定小于 \(sum\) ,那么二进制位数肯定可以凑出 \(b\),而对于大于 \(a\) 的数 \(c\),因为 $$a + sum = s, a\le c\le s$$所以
所以说 \(c\) 超出 \(a\) 的部分 \(e\) 肯定小于等于 \(sum\),可以被凑出,那么这个 \(e\) 加上 \(a\) 就是 \(c\),所以 \(c\) 也一定可以被凑出。
因此,通过这种方式,一点可以凑出所有小于等于 \(s\) 的非负数。
单调队列优化
口诀:比你小还比你强,你就出列了~~~
时间复杂度为 \(O(nm)\)
因为一般 \(m\) 都比较大所以常用 [[DP的优化#滚动数组]]
实际上会单调队列优化,加上会推式子就可以了。
代码
for (int i = 1; i <= n; i ++ )
{
int w, s, v;
cin >> v >> w >> s;
memcpy(g, f, sizeof f);
for (int j = 0; j < v; j ++ )
{
int hh = 0, tt = -1;
for (int k = j; k <= m; k += v)
{
if (hh <= tt && q[hh] < k - (s + 1) * v + v) hh ++ ;
// 弹出队尾过程的对比,是按照转移方程的大小进行的对比,为什么可以,请自己想想
while (hh <= tt && g[q[tt]] + (k - q[tt]) / v * w <= g[k]) tt -- ;
q[ ++ tt] = k;
f[k] = g[q[hh]] + (k - q[hh]) / v * w;
}
}
}
或者滚动数组
for (int i = 1; i <= n; i ++ )
{
int v, w, s;
cin >> v >> w >> s;
for (int j = 0; j < v; j ++ ) // 余数
{
int tt = -1, hh = 0;
for (int k = j; k <= m; k += v) // 体积
{
if (hh <= tt && q[hh] < k - (s + 1) * v + v) hh ++ ;
while (hh <= tt && f[i - 1 & 1][q[tt]] + (k - q[tt]) / v * w <= f[i - 1 & 1][k]) tt -- ;
q[ ++ tt] = k;
f[i & 1][k] = f[i - 1 & 1][q[hh]] + (k - q[hh]) / v * w;
}
}
}
思路
首先你要知道在 s 个物品限制下转移方程为(一般情况s >= m / v)
这里推荐看我以前的笔记
f[i][j] = max(f[i - 1][j], f[i - 1][j - v] + w, f[i - 1][j - 2v] + 2w, ... , f[i - 1][j - sv] + sw);
f[i][j - v] = max(f[i - 1][j - v], f[i - 1][j - 2v] + 1w, ... , f[i - 1][j - sv] + (s - 1)w, f[i - 1][j - (s + 1)v] + sw);
f[i][j - 2v] = ...
f[i][j - 3v] = ...
...
r = j % v;
f[i][r + 2v] = max(f[i - 1][r + 2v], f[i - 1][r + v] + w, f[i - 1][r] + 2w)
f[i][r + v] = max(f[i - 1][r + v], f[i - 1][r] + w)
f[i][r] = f[i - 1][r]
r, r + v, r + 2v, r + 3v, r + 4v, r + 5v, r + 6v, ... , j - 2v, j - v, j
经典的图啊
根据完全背包优化的思路我们可以得到上面的图(也可以看我过去笔记的图片)
我们能发现一个事情,因为有s的限制,在一般情况下,我们没法直接用f[i][j - v]来转移
but我们可以从它的余数出发, f[i][r], 这个是值固定的,往上f[i][r + v]看图
越往上我们会发现,在他们的max内的第一个都在增加v, 而且当s < j / v时它就开始滑动(像f[i][j], f[i][j - v], max的数量一样,但是f[i][j]向前进了1个v, 且w的值,除了新进来的,都加了1w,)
这就想到了单调队列,可以靠它维护这个区间最大值,优化的点,保持队列队头最大,利用这个最大,优化计算量;并且可以计算滑动窗口内的最大值
本文的代码就依靠此图而来
看看代码,多想想
因为过于复杂还是看y的课比较好。。
以前的笔记
如果你还有记忆的话应该会懂得
一般比二进制优化快
二维费用背包
很简单就是有两个费用,多开一维,记录费用即可非常简单
状态一般为 f[i][j][k]在前 i 个数内,在两个费用都不超过 \(j\) 和 \(k\) 的情况下的属性
可以和上面三种背包结合,相当于前缀
一般代码(二维费用01背包)
for (int i = 1; i <= n; i ++ )
{
int w, v, t;
cin >> v >> t >> w;
for (int j = m; j >= v; j -- )
for (int k = p; k >= t; k -- )
{
f[j][k] = max(f[j][k], f[j - v][k - t] + w);
}
}
不少于问题
之前没整理到这个
简单说就是对于费用不少于的问题,这也可以和上面三种背包连用,相当于前缀
对于这个直接DP分析即可,关于不少于,直接设f[i][j]为在前i个里面,费用不少于j的情况
那么第i个可选可不选,不选就是f[i - 1][j],选的话是f[i - 1][j - v] 这时候可能会有j < v的情况,这时候也要转移,因为它符合,费用不少于j(大于了当然可以),写成这个就行f[i - 1][max(0, j - v)]。
这时候回头想想,好像之前不超过的,当 j < v 的时候就不转移了(可看看上面代码),这是因为之前状态是费用最大为 \(j\)(不超过),这就是本质的差距。
恰好问题
这是一类小问题,状态设计要改变为,恰好,转移没什么变化,但初始化有变动,一定要先设置为不可能情况,因为对于一种状态要恰好。
像有许多石头,价值和重量无关,求恰好重量为j时的最大价值,石头有(前面是价值,后面是重量){1, 1},{2, 1},{2, 2}
当f[2][3]转移的时候,f[1][2]这种情况是不可能的,所以f[2][3] = max(f[1][2] + 2)
整个转移是转移不了的,但是价值不大于j时是可以的。这就是它的本质
求方案数
对于方案数而言,不可能情况就是 \(0\),\(0\) 说明没有方案,即不可能。
这类其实有点技巧,最稳妥的方法是,把题目改为恰好,这里用一个例题来说明。
求最优方案数
AcWing 11. 背包问题求方案数 - AcWing
题中要求不超过 j 方案最大价值的方案数,
有两种大方向
- 利用不超过直接求解,因此f初始化都为0,对于每种情况
f[i][j]最少有1种方案,然后转移时如果可以更新就直接更替cnt,相等就加上cnt,最后直接输出cnt[n][m]即可 - 利用恰好,把不超过\(j\),改为恰好为 \(j\),转移和上面一样,然后在
f[n][j]中记录,最大值,并把所有的最大值相等的方案加上。
如上代码在上面链接中
因此对于一个问题,除了特殊记录以外,就可以直接转移或利用恰好凑出方案
而转移时如果可以更新就直接更替cnt,相等就加上cnt,这是所有方案问题的最基本的思想,不限于动态规划。
关于方案的保存
问题在保存选的方案,像这个 AcWing 1013. 机器分配 - AcWing 比较典型,除了求解答案,还要输出每个工厂选择的机器数,可以dfs,可以像我这样原路推回去(实际上更多使用这种),没有固定的方式,这里只说明有此类问题。
枚举的状态
正常背包的转移为f[i][j] = max(f[i][j], f[i - 1][j - v] + w)
我们依赖于第i - 1个物品进行转移,实际上还可以依赖体积,来转移。
思路为
{不选}
{选择体积最大为k的子集,并加上这个点} k <= j
对于正常的背包,这是没有用的,但对于树形等物件间有特殊关系的,有奇效.
AcWing 10. 有依赖的背包问题 - AcWing 这题就需要对于每个子树分配体积,来计算
否则复杂度将爆炸
有依赖的问题
分为很多种,例如可能依赖是树形的,可能是线性的,等等。
中心思想就是转移好子集,不能言传。
AcWing 10. 有依赖的背包问题 - AcWing
487. 金明的预算方案 - AcWing题库
上面是较好的一道,也是较难的一道。
下面的较为简单适合入入门。
求具体方案
这种会问你最后选择的方案是是什么,而不是方案数,可能有限制,如选择字典序最小的最优选择方案是什么。这种有两种思路。
- 直接保存方案,利用string,或者vector,在转移过程中保存方案,通过比较方案来选择最小字典序,这种一般较慢,但很稳定。
- 先求出最优方案的数值,再利用数值,从大枚举,或从小枚举,推出选择方案。这种较快,但边界和枚举设计需要考虑清楚。
AcWing 12. 背包问题求具体方案 - AcWing
具体代码如上
关于状态转移的奇技淫巧
- 有时候我们不能直接转移状态,而要合成状态,像
f[i + 1][j + v] = ...这时候可能会有“卡壳”情况,即我们无法直接判断情况,这时候不妨从0出发把for (int i = 1; i <= n; i ++ )变为for (int i = 0; i < n; i ++ )这样“退位”,在某些时候有奇效如这题P1156 垃圾陷阱 而这就是刷表法,[[刷表法和填表法]]
变化的价值/体积(泛化物品的背包)
对于一类题,它的价值和体积会随着你的选择顺序不同而改变,这时候需要考虑优先级,
常用方法
先设 x,y 为两个相邻物品,然后列出(如价值)的不等式,像如此价值的情况
先x后y w = (p + c[x]) * b[x] + (p + c[x] + c[y]) * b[y] (1)
先y后x w = (p + c[y]) * b[y] + (p + c[y] + c[x]) * b[x] (2)
我们如果要让x在前的话,那么(1) > (2)
可以解得 c[x] * b[y] > b[x] * c[y],然后我们利用这个性质排序,再进行背包处理
做了那么多题,对于背包,正确的物品排序确实很重要。
给出对应的例题试试吧 P1417 烹调方案 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
在最值情况下的最值
这类题题意如求在最大化1的价值的情况下,2的最小花费为多少,即在最大价值1下的最小花费2,
这种算是比较好做,只要在转移出最大价值 1 的同时,记录最小价值2,更新时替换,相等时对比求最小。这是一类非常重要的思想,在求次短路等值的过程中经常遇到。跟随更新。
注意,前提是最大价值 1,所以要对它DP。
例题 P1509 找啊找啊找GF - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
体积极大的背包
有一类题,背包体积极大,可以达到 \(10^9\)。这类题一般物品数量比较小。正常在 40 左右,有特殊性质的可能会达到 100(如价值较小)。如果价值还很大,这就不是 DP 能解决的了。正常要靠枚举, 合理使用双向 dfs 来优化时间复杂度。对于有性质的,特殊枚举,或者使用贪心。
特殊性质:P3985 不开心的金明 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
特殊性质又如价值都很小,这时候不如dp枚举价值:超大背包问题 - Young-children - 博客园 (cnblogs.com)
体积为 \(0\) 的物品与一维优化的特殊情况
对于这题 P1802 5 倍经验日 - 洛谷 出现了,体积为 \(0\) 的物品和有价值的 “不选",正常情况这两个一起出现是没有问题的,但如果进行了一维优化就会错误。
如下代码会造成错误,其原因在于,当战胜敌人不需要嗑药时,\(v = 0\) 使得 \(j = j - v\),从而使得\(f[j] = f[j - v]\),而我们的不选即 f[j] = f[j] + w1 会更新的 \(f[j]\),从而使得 \(f[j - v]\) 得到更新,从原来的 \(f[i - 1][j - v]\) 变为 \(f[i][ j - v]\),从而导致转移错误,最后导致结果错误。
因此,对于一维优化而言,尽量少用,并趋向更稳定的滚动数组.。这就是意想不到的错误。
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010, M = 1010;
int n, m;
int f[N];
int main()
{
cin >> n >> m;
int sum = 0;
for (int i = 1; i <= n; i ++ )
{
int w1, w2, v;
cin >> w1 >> w2 >> v;
for (int j = m; j >= 0; j -- )
{
f[j] = f[j] + w1;
cout << f[j] << endl;
if (j >= v) f[j] = max(f[j], f[j - v] + w2);
}
}
cout << (1ll * f[m]) * 5 << endl;
return 0;
}

浙公网安备 33010602011771号