[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就基本结束了;

DP优化

DP优化总结

数位DP

数位DP

posted @ 2024-02-15 15:13  Peppa_Even_Pig  阅读(84)  评论(4编辑  收藏  举报