[DP] [PEP总结] C++ DP总结
玄学DP
大前提
1、最优子结构;2、无后效性(不被前面影响);3、写DP时不要考虑是哪种DP(顶多大体分三类:区间DP,树状DP,其它能够依题意设计出状态的DP),状态设计是最重要的!
秘诀
迭代法(当真的不知道怎么改时,可以把整个DP外面套上一个常数级循环(一般为10),如果DP写的有后效性, 经过多次假最大值的迭代后,最后f数组可能就不再变化,此时的值很大可能是最优解(其实就是循环写反了,造成第一遍更新时其它还是初始值,多迭代几次就会找到最优值);
要点:1、初始化;2、状态转移方程;(重点);
记忆化搜索
其实,记忆化搜索就是递归版的DP,其用数组记录符合下标条件的(最优)价值;
但符合记忆化搜索需要满足搜索树在下方的某(几)个节点交汇,否则记忆化就变成了DFS,还会增加空间复杂度;
例题:杨辉三角:
1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1
...
点击查看代码
记忆化:if (!memory[n][m]) memory[n][m] = f(n - 1, m) + f(n, m - 1);
return memory[n][m];
递归边界条件:if (n == m) return 1;
if (n == 0) return 0;
if (m == 0) return n;
背包DP
<1> 01背包:
01背包本质是由上层状态转移而来(满足条件的前提),由于其只能选一次,状态的转移沿对角线方向;初始化为题目所要求;(一般为f[0][0] = 0; memset(f, 0, sizeof(f)););
当某一层状态转移满时,其后的状态可能会出现不可预料的错误(一般是由于初始化导致的)(初始化非常重要!!!)(其实最优解就在最后一行的某个地方);当初始化为0xcf时, 可能会出现f[][]数组中有0xcf+符合条件的最优价值的情况;cout << f[n][m]; 也无法出现最优解;(其实f数组中可能根本没有最优解,(初始化已经出现错误,体现了初始化的重要性),max也没用);
- 朴素做法;
点击查看代码
#include <iostream>
#include <cstring>
using namespace std;
int f[2005][2005]; //f[i][j]表示前i件物品,容量为j时最大价值;
int m, n;
int w[10005], c[10005];
int main() {
cin >> m >> n;
memset(f, 0, sizeof(f));
for (int i = 1; i <= n; i++) {
cin >> w[i] >> c[i];
}
f[0][0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++) {
f[i][j] = f[i - 1][j];
}
for (int j = w[i]; j <= m; j++) {
f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + c[i]);
}
}
cout << f[n][m];
return 0;
}
- 滚动数组
点击查看代码
#include <iostream>
#include <cstring>
using namespace std;
int c[10005], w[10005]; //w[i]为第i件物品的重量,c[i]为第i件物品的价值;
int f[10005]; //f[i]为 容量为i时的最大价值;
int m, n; //m为背包容量,n为总物品数量;
int main() {
cin >> m >> n;
memset(f, 0, sizeof(f)); //初始化,等待赋值;
for (int i = 1; i <= n; i++) {
cin >> w[i] >> c[i];
}
f[0] = 0; //初始化,当容量为0时最大价值为0;
for (int i = 1; i <= n; i++) {
for (int j = m; j >= w[i]; j--) {
f[j] = max(f[j], f[j - w[i]] + c[i]);
}
}
cout << f[m];
return 0;
}
为啥内层循环要倒序呢?因为每种物品只能选一次, 其实就是第i层的状态只能由第i-1层的状态转移一次得来,若顺序,则当第i层第j个状态被更新时,它就到了第i-1层,后面的第i层的j + w[i]个状态又可能被此状态更新,这样一个物品就拿了两次;
如此循环往复,则物品都能哪无限次了;
01背包求的是最大值,那如果求第k大的呢?
加一维,存储第k大;
点击查看代码
#include <iostream>
#include <cstring>
using namespace std;
int t;
int n, m, K;
int w[10005], c[10005];
int f[1005][1005]; //容积,第k大;
int a[10005], b[10005];
int main() {
cin >> t;
while(t--) {
cin >> n >> m >> K;
memset(w, 0, sizeof(w));
memset(f, 0, sizeof(f));
memset(c, 0, sizeof(c));
memset(a, 0, sizeof(a));
memset(b, 0, sizeof(b));
for (int i = 1; i <= n; i++) {
cin >> c[i];
}
for (int i = 1; i <= n; i++) {
cin >> w[i];
}
for (int i = 1; i <= K; i++) {
f[0][i] = 0;
}
for (int i = 1; i <= n; i++) {
for (int j = m; j >= w[i]; j--) {
for (int k = 1; k <= K; k++) {
a[k] = f[j][k];
b[k] = f[j - w[i]][k] + c[i]; //先把这2k个数储存起来,等下面找出k个大数合并;
}
//下面的步骤,类比归并排序中合并的操作;
int k = 1;
int x = 1;
int y = 1;
a[K + 1] = -1;
b[K + 1] = -1; //两个-1不能忘,因为前面可能有0,如果不赋值为一个极小数,可能下标会越界;
while(k <= K && (x <= K || y <= K)) {
if (a[x] > b[y]) {
f[j][k] = a[x];
x++;
} else if (a[x] <= b[y]) { //不要忘了else!
f[j][k] = b[y];
y++;
}
if (f[j][k] != f[j][k - 1]) { //去重;
k++;
}
}
}
}
cout << f[m][K] << endl;
}
return 0;
}
so,引出下一个背包——
<2> 完全背包(能拿无限件);
顺序就完事了;
点击查看代码
#include <iostream>
#include <cstring>
using namespace std;
int c[1000005], w[1000005]; //w[i]为第i件物品的重量,c[i]为第i件物品的价值;
int f[1000005]; //f[i]为 容量为i时的最大价值;
int m, n; //m为背包容量,n为总物品数量;
int main() {
cin >> m >> n;
memset(f, 0, sizeof(f)); //初始化,等待赋值;
for (int i = 1; i <= n; i++) {
cin >> w[i] >> c[i];
}
f[0] = 0; //初始化,当容量为0时最大价值为0;
for (int i = 1; i <= n; i++) {
for (int j = w[i]; j <= m; j++) {
f[j] = max(f[j], f[j - w[i]] + c[i]);
}
}
cout << f[m];
return 0;
}
<3> 多重背包(一个物品只能取有限次);
其实多重背包可以看作是01背包和完全背包的结合版,只需将其转化成01背包,枚举次数求解即可;
- 朴素:
点击查看代码
#include <iostream>
#include <cstring>
using namespace std;
int n, m;
int w[100005], c[100005], num[100005]; //重量,价值,个数;
int f[100005];
int main() {
cin >> n >> m;
memset(f, 0, sizeof(f));
for (int i = 1; i <= n; i++) {
cin >> w[i] >> c[i] >> num[i];
}
for (int i = 1; i <= n; i++) {
for (int j = m; j >= w[i]; j--) {
for (int k = 1; k <= num[i] && k * w[i] <= j; k++) { //枚举个数;
f[j] = max(f[j], f[j - k * w[i]] + k * c[i]);
}
}
}
cout << f[m];
return 0;
}
但是,近乎O(n^3)的做法使其很容易超时,所以要用二进制优化;
二进制优化,就是将一个物品分解成多个二进制数表示的形式,然后再用01背包求解;
如:32 = 2 + 4 + 8 + 16 + 2;
那这五个物品的价值总和就相当于32,且这五个物品只能选一次;
具体看代码:
点击查看代码
#include <iostream>
using namespace std;
int n, m, num;
int w[1000005], c[1000005]; //要开大一点,因为要分解;
int f[1000005];
int main() {
cin >> n >> m; //物品数量和背包容积;
int a, b, num; //每件物品的重量,价值,个数;
int cnt = 0;
for (int i = 1; i <= n; i++) {
cin >> a >> b >> num;
for (int j = 1; j <= num; j <<= 1) { // j *= 2;
w[++cnt] = a * j;
c[cnt] = b * j;
num -= j; //这样就可以保证每件物品只能选一次(因为如果选多次就超过了原来的num;
}
if (num) { //如果num不能完全分解,那就把剩下的直接存入;
w[++cnt] = a * num;
c[cnt] = b * num;
}
}
for (int i = 1; i <= cnt; i++) {
for (int j = m; j >= w[i]; j--) {
f[j] = max(f[j], f[j - w[i]] + c[i]);
}
}
cout << f[m];
return 0;
}
<4> 混合背包;
就是把三种背包混合起来,不同背包用不同方法即可;
但要注意开个结构体存储背包种类;
点击查看代码
#include <iostream>
using namespace std;
struct sss {
int w, c, nu; //重量,价值,背包类型;
}e[1000005];
int n, m;
int f[1000005];
int main() {
cin >> m >> n;
int a, b, num; //同上;
int cnt = 0;
for (int i = 1; i <= n; i++) {
cin >> a >> b >> num;
if (num == 1) { //只能要一个;
e[++cnt].w = a;
e[cnt].c = b;
e[cnt].nu = 1; //01背包;
} else if (num == 0) { //能要无限个;
e[++cnt].w = a;
e[cnt].c = b;
e[cnt].nu = 2; //完全背包;
} else {
for (int j = 1; j <= num; j <<= 1) { //多重背包的二进制优化;
e[++cnt].w = j * a;
e[cnt].c = j * b;
e[cnt].nu = 1; //01背包;
num -= j;
}
if (num) {
e[++cnt].w = num * a;
e[cnt].c = num * b;
e[cnt].nu = 1;
}
}
}
//预处理后直接按背包种类跑DP就行;
for (int i = 1; i <= cnt; i++) {
if (e[i].nu == 1) {
for (int j = m; j >= e[i].w; j--) {
f[j] = max(f[j], f[j - e[i].w] + e[i].c);
}
} else {
for (int j = e[i].w; j <= m; j++) {
f[j] = max(f[j], f[j - e[i].w] + e[i].c);
}
}
}
cout << f[m];
return 0;
}
<5> 分组背包;
一个旅行者有一个最多能用V公斤的背包,现在有n件物品,它们的重量分别是W1,W2,...,Wn,它们的价值分别为C1,C2,...,Cn。
这些物品被划分为若干组,每组中的物品互相冲突,最多选一件。
求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
其实就是分着跑01背包,用结构体存储,按组号升序排列,然后每组跑倒着的01就行;
点击查看代码
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
struct sss{
int w, c, nu; //nu为组号;
}e[1000005];
int n, m, t;
int f[1000005];
bool cmp(sss x, sss y) {
return x.nu < y.nu; //按组号排序,减小复杂度;
}
int main() {
cin >> m >> n >> t;
memset(f, 0, sizeof(f));
memset(e, 0, sizeof(e));
for (int i = 1; i <= n; i++) {
cin >> e[i].w >> e[i].c >> e[i].nu;
}
sort(e + 1, e + 1 + n, cmp);
for (int i = 1; i <= t; i++) { //枚举组号;
for (int j = m; j >= 0; j--) { //先枚举每一个容积,再枚举数量,因为每组至多选一个,每个容积只能被一个物品更新;这里的j从m到0,每个状态都要更新,为下一组的状态继承做铺垫;
for (int k = 1; k <= n; k++) {
if (j < e[k].w) continue;
if (e[k].nu == i) { //如果在第i组;
f[j] = max(f[j], f[j - e[k].w] + e[k].c);
}
}
}
}
cout << f[m];
return 0;
}
思考:如果是最少选一件呢?
需要开二维来记录前i类商品以及花费j;
点击查看代码
#include <iostream>
#include <cstring>
using namespace std;
int n, m, K; //数量,容积,组数;
struct sss{
int w, c, nu; //重量,价值,组号(分组背包存储方法);
}e[100005];
int f[55][10005]; //前i类,花费(容积)j;
int main() {
while(cin >> n && cin >> m && cin >> K) {
memset(f, -1, sizeof(f)); //-1代表非法,因为0也是一个解;
memset(f[0], 0, sizeof(f[0])); //后面的状态需要由f[0][...]转移而来;
memset(e, 0, sizeof(e));
for (int i = 1; i <= n; i++) {
cin >> e[i].nu >> e[i].w >> e[i].c;
}
for (int i = 1; i <= K; i++) {
for (int j = 1; j <= n; j++) {
for (int k = m; k >= e[j].w; k--) {
if (e[j].nu == i) f[i][k] = max(f[i][k], max(f[i - 1][k - e[j].w] + e[j].c, f[i][k - e[j].w] + e[j].c)); //第二个代表选第j件,但第j件是本组中第一个被选时的情况;第三个代表选第j件,但第j件不是本组中第一个被选时的情况;
}
}
}
if (f[K][m] == -1) {
cout << "Impossible" << endl;
} else {
cout << f[K][m] << endl;
}
}
return 0;
}
<6> 输出方案;
开一个g数组(维数依据题意而定)记录下标状态转移需要这个数组的值;
一般采用递归输出或者ans数组倒序输出;
具体见线性DP;
<7> 有依赖的DP;
详见树状DP;
线性DP
线性DP可以说是应用最广泛的DP了,在后面写坐标DP时,感觉坐标DP就是线性DP的一类,有的区间DP也可以和线性DP结合着做;
线性DP的状态也是一层层转移而来,每层互不影响,如f[i][sth.]可以由f[i - 1][sth.] + value转移而来;
<1> 求最长上升序列并输出路径
点击查看代码
#include <iostream>
#include <cstring>
#include <cstdio>
using namespace std;
int a[100005];
int f[100005]; //f[i]代表a[1 ~ i]这段区间内的最长上升序列(前提是必选a[i]);
int g[100005];
int n;
void output(int x) { //递归输出序列;
if (x) {
cout << a[x] << ' '; //倒序输出,因为下面i是倒着找的,所以这里要正序输出;
output(g[x]);
}
}
int main() {
memset(a, 0, sizeof(a));
memset(g, 0, sizeof(g));
n = 1;
while(scanf("%d", &a[n]) != EOF) n++;
n--;
for (int i = 1; i <= n; i++) {
f[i] = 1; //当长度(最后一位下标)为1时,最长上升序列长度为1;
}
int ma = -1;
for (int i = n - 1; i >= 1; i--) { //倒序循环,一步步往前更新;
for (int j = i + 1; j <= n; j++) {
if (a[i] < a[j]) { //要求其他序列,改成其它不等符号即可;
if (f[i] < f[j] + 1) {
f[i] = f[j] + 1;
g[i] = j;
}
}
ma = max(ma, f[i]);
}
}
cout << ma << endl; //序列最长长度;
ma = -1;
int o = 0;
for (int i = 1; i <= n; i++) {
if (ma < f[i]) {
ma = f[i];
o = i; //找出最大f的下标;
}
}
output(o); //递归输出;
return 0;
}
<2> 求序列长度的二分查找优化;
优化思路,在内层循环中,一次次枚举去找序列,其实如果单纯求序列长度而不输出路径的话,可以用二分查找去优化;
点击查看代码
#include <iostream>
using namespace std;
int n;
int a[100005];
int d1[100005]; //d1[i] == j代表长度为i的最长不下降序列最后一位是j;
int d2[100005]; //d2[i] == j代表长度为i的最长不上升序列最后一位是j;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
int l1 = 1;
int l2 = 1; //最开始长度初始化为1,因为有第一个元素;
d1[l1] = a[1];
d2[l2] = a[1];
for (int i = 2; i <= n; i++) { //第一个元素已经确定,从第二个开始;
if (a[i] >= d1[l1]) { //如果这样,满足最长不下降序列,直接接到d1数组中;
d1[++l1] = a[i];
} else { //如果不满足,从d1数组中寻找满足位置(第一个大于它的)并更新值;
int j = upper_bound(d1 + 1, d1 + 1 + l1, a[i]) - d1; //upper_bound返回指针,所以最后要减d1(头指针);
d1[j] = a[i];
}
if (a[i] <= d2[l2]) {
d2[++l2] = a[i];
} else {
int j = upper_bound(d2 + 1, d2 + 1 + l2, a[i], greater<int>()) - d2; //按照上面的逻辑,应该在d2数组中找第一个小于他的,lower_bound找的是第一个大于等于的,所以不行,而upper_bound+greater<int>()找的是第一个小于它的;
}
}
return 0;
}
<3> 求序列长度的BIT优化
用值域BIT,若要找最长不下降或上升,就维护一个前缀最大值,反之维护一个后缀最大值;
例题:Luogu P1020 [NOIP1999 提高组] 导弹拦截
求最长不上升和最长上升;
这里用到了 $ Dilworth $ 定理,感性一点就是正的个数等于反的最长长度;
点击查看代码
#include <iostream>
#include <cstdio>
using namespace std;
int n;
int a[500005];
int ans;
namespace BIT{
inline int lowbit(int x) {
return x & (-x);
}
int tr[100005];
void add(int pos, int d) {
for (int i = pos; i <= 100000; i += lowbit(i)) tr[i] = max(tr[i], d);
}
int ask(int pos) {
int ans = 0;
for (int i = pos; i; i -= lowbit(i)) ans = max(ans, tr[i]);
return ans;
}
}
using namespace BIT;
int main() {
freopen("in.in", "r", stdin);
freopen("out.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int x = 0;
while(cin >> x) {
a[++n] = x;
}
for (int i = 1; i <= n; i++) {
x = ask(100000 - a[i]) + 1;
add(100000 - a[i], x); //维护后缀最大值;
ans = max(ans, x);
}
cout << ans << '\n';
ans = 0;
for (int i = 1; i <= 100000; i++) tr[i] = 0;
for (int i = 1; i <= n; i++) {
x = ask(a[i] - 1) + 1;
add(a[i], x);
ans = max(ans, x);
}
cout << ans;
return 0;
}
总结:线性DP没啥板子,更重要的是思路(DP都是这样),当设计状态转移方程时,不妨多开几个维度,使阶段划分更加明确,状态的设计也就简单了;
维度的设计,主要取决于题干当中关于时间,步数,个数等具有明确阶段的词语,一般用作第一维,剩下的依题干要求即可;
区间DP(合并类DP)
设f[i][j]表示区间i到j上的最优解,可以运用分治的思想,将此区间分成f[i][k]和f[k + 1][j] + 合并这两个区间的价值,依次枚举k并比较大小,同时更新f[i][j],最后输出所需区间的最大值;
可以发现,区间DP是属于线性DP的,其在线性基础上运行,但和线性DP不同,它的特征是分治,这也是写状态转移方程的重要依据;
但有些算法将区间DP抽象成二维来用(如Floyd),看情况吧;
当遇到环形问题时,通常破环成链,将长度*2,并将数组向后复制一次(如石子合并<2>和<3>);
区间DP板子:
点击查看代码
for (int len = 2; len <= 总长度; len++) { //枚举区间长度;
for (int i = 1; i + len - 1 <= 总长度; i++) { //枚举区间左端点;
int j = i + len - 1; //区间右端点;
for (int k = i; k < j; k++) { //枚举区间中的断点;
f[i][j] = max(f[i][j], f[i][k] + f[k + 1][j] + value); //这里的+号可以依题意变为其它的,value为合并这两个区间所能产生的价值,因题而异;
}
}
}
例题:整数划分;
坐标DP
坐标DP,即在二维层面进行的DP,特征是在进行DP时不仅仅考虑线性层面,它的上下左右都要考虑到,并且进行状态转移;
和其它DP一样,重点还是在维度的设计,搞清每一维存储什么,多开几维,是做出题的关键;
这种题有好做不难的感觉,但细节处理需要尤为注意,细节决定成败;
树形DP
树形DP,即在树上进行的DP,因为树本身的递归性,所以进行DP时一般借助DFS的形式进行;
对于DP顺序,我们一般采用“叶子到根”的顺序,先递归到叶子节点,在回溯时再一层层的更新我们f数组的值,并最终将全局最优解储存在根节点上;
树状DP的状态转移方程也很有特征,一般第一维存储根节点,剩下的再存储其它信息;
拿到一道树状DP的题,第一步首先是建树,一般采用从根节点出发递归建树的形式,如果题目中很鲜明的给出了“二叉树”这个字眼,那么我们就建一个二叉树(用结构体存储左右儿子以及本节点的值),如果没说,那就建多叉树(一般是用链式前向星或邻接表),多叉树转二叉树几乎用不到(因为我认为这两种做法的难度差不多);
找根节点时,我们可以存储入度,没有入度的点是根节点;
对于建出森林的情况,我们可以建一个虚点来连接各个根,即可将其转换成一棵树;
建树时,我们的边一般是由根指向儿子,如果非要建无向边,那就bool个vis数组标记一下已访问的点,或者在DFS时多开一个参数记录访问点的父亲以防重复访问;
还有之前提到的有依赖的背包DP--树上背包以及建虚点的操作,可以通过一道例题来理解:选课。
对于普通树状DP以及建树方法,直接看题库中的题即可,不再过多解释;
还有常用的 $ up \ and \ down $,以及换根DP等等;
到此,基础DP就基本结束了;