dp 专题复习

前言:我现在才知道我的初一到底有多摆。决定从头开始学 dp。

题单(总)

https://www.luogu.com.cn/training/1435

https://www.luogu.com.cn/training/201862

广告

这位博主讲得确实很好,至少让我这个半死的 dp 废物看到了生存的希望,大家有兴趣可以看一看。

前记

dp 所需性质

  • 最优子结构

  • 无后效性

  • 子问题重叠

dp 基本步骤:

  • 定义 \(dp\) 序列以及其下标的含义

  • 写出状态转移方程

  • 初始化

  • 寻找合适遍历顺序

  • 输出 \(dp\) 序列(作调试用)

dp 基础

P1216 [USACO1.5] [IOI1994]数字三角形 Number Triangles

十分典型的 dp 入门题。

发现暴力 dfs 会解决很多不必要的问题例如枚举深度为 \(dep\) 的元素次数达到了惊人的 \(2^{dep - 1}\) 次!

但是倘若我们考虑贪心那么正确性就不一定会得到保障。

此时我们想到,数字三角形中每一个元素下只有两个子元素。

\(a_{i,j}\) 为点 \((i,j)\) 所表示的数值。

因为题目最终要求一个最长路径,而最长路径的子问题合并是很容易的,我们不妨设 \(dp_{i,j}\) 为从点 \((1,1)\) 走向点 \((i,j)\) 的最长路径。

我们惊奇地发现:\(dp_{1,1} = \max\{ dp_{2,1},dp_{2,2}\} + a_{1,1}\),而 \(dp_{2,1} = \max\{ dp_{3,1},dp_{3,2} \} + a_{2,1}\)\(dp_{2,2} = \max\{ dp_{3,2},dp_{3,3} \} + a_{2,2}\)...

不难看出其状态转移方程:

\[dp_{i,j} = \max\{ dp_{i + 1,j},dp_{i + 1,j + 1} \} + a_{i,j} \]

注意到当 \(i = n\) 时,\(dp\) 序列并不在域内存在,也就是说我们可以把 \(dp\) 的贡献记为 \(0\)。所以此时的 \(dp_{i,j} = a_{i,j}\)。我们可以根据这个特点进行初始化。

我们又发现,在 \(i = n\) 时,\(dp_{i,j}\) 只依托题目至今所给的信息去表示,也就是说这其实是个 最小的子问题。因为一般我们是由小到大去合并子问题,所以我们的遍历顺序也由此确定。

到此为止,我们已经完成了 dp 的基本步骤。而这个题目启示了我们 dp 遍历的顺序 是要根据子问题的规模由小到大进行的

#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e3 + 5;
int n, a[N][N];
signed main() {
	ios_base :: sync_with_stdio(NULL);
	cin.tie(nullptr);
	cout.tie(nullptr);
	cin >> n;
	for(int i = 1 ; i <= n ; ++ i)
		for(int j = 1 ; j <= i ; ++ j)
			cin >> a[i][j];
	for(int i = n ; i >= 1 ; -- i)
		for(int j = 1 ; j <= i ; ++ j)
			a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]);
	cout << a[1][1];
	return 0;
} 

B3637 最长上升子序列

十分典型的 dp 入门题第二弹。

我们发现题目中所求的最终答案是 序列最长上升子序列,而我们发现如果以第 \(i\) 个元素为结尾的最长上升子序列可以由前面上升子序列的结尾元素小于第 \(i\) 个元素的最长上升子序列贺第 \(i\) 个元素拼接在一起。这就是本题的子问题。

所以我们设 \(dp_i\) 为以第 \(i\) 个元素为结尾的最长上升子序列长度,于是得到状态转移方程:

\[dp_i = \max_{a_i > a_j}\{ dp_j + 1 \} \]

说一句题外话就是这个 \(\max\) 可以用其单调的性质二分或用一些支持区间 \(\max\) 的数据结构去维护。复杂度降至 \(O(n \log n)\)。具体的会在 dp 优化里面讲。

而这里的最小子问题是最长上升子序列长度为一个元素时,序列就只有当前元素本身。因此我们的初始化是 \(dp_i \to 1\)

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 5e3 + 5;
int n, a[N], dp[N];
signed main() {
	ios_base :: sync_with_stdio(NULL);
	cin.tie(nullptr);
	cout.tie(nullptr);
	cin >> n;
	for(int i = 1 ; i <= n ; ++ i)
		cin >> a[i];
	for(int i = 1 ; i <= n ; ++ i)
		for(int j = 1 ; j <= i - 1 ; ++ j)
			if(a[i] > a[j]) dp[i] = max(dp[i], dp[j] + 1);
	int ans = 0;
	for(int i = 1 ; i <= n ; ++ i)
		ans = max(ans, dp[i]);
	cout << ans;
	return 0;
}

P1439 【模板】最长公共子序列

十分典型的 dp 入门题第三弹。

根据前一题的经验,我们设 \(dp_i\)\(P_1\)\(i\) 个字符为结尾的最长公共子序列长度。

但是我们发现这样不能很好地维护 \(P_2\)

所以我们就想,如果要同时维护 \(P_1,P_2\),是不是可以设置两个维度去表示 dp 序列呢?

\(dp_{i,j}\) 为以 \(P_1\) \(i\) 个字符结尾,以 \(P_2\) \(j\) 个字符结尾的最长公共子序列长度。

那么为什么是 而不是 呢?

我们手玩几个数据,不难发现最长上升子序列是不具有 前缀性质 的(也就是 \(dp_{i-1}\) 不一定是 \(dp_i\) 的子问题),但是最长公共子序列是有的。

我们先对 \((i,j)\) 是否有贡献进行讨论:

  • 当有贡献时:

此时因为前缀性质,\(dp_{i,j}=dp_{i-1,j-1}+1\)

  • 当没有贡献时

此时考虑继承,\(dp_{i,j}=\max\{ dp_{i-1,j},dp_{i,j-1} \}\)

我们判定一个 \((i,j)\) 有贡献当且仅当 \(P_{1_i}=P_{2_j}\) 且此贡献为 \(1\)。所以我们得到状态转移方程:

\[dp_{i,j}=\begin{cases}max\{ dp_{i-1,j},dp_{i,j-1},dp_{i-1,j-1}+1 \}&P_{1_i}=P_{2_j}\\max\{ dp_{i-1,j},dp_{i,j-1}\}&P_{1_i}\neq P_{2_j}\end{cases} \]

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 1e3 + 5;
int n, a[N], b[N], dp[N][N];
signed main() {
	ios_base :: sync_with_stdio(NULL);
	cin.tie(nullptr);
	cout.tie(nullptr);
	cin >> n;
	for(int i = 1 ; i <= n ; ++ i)
		cin >> a[i];
	for(int i = 1 ; i <= n ; ++ i)
		cin >> b[i];
	for(int i = 1 ; i <= n ; ++ i)
		for(int j = 1 ; j <= n ; ++ j) {
			if(a[i] == b[j]) dp[i][j] = max({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1] + 1});
			else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
		}
	cout << dp[n][n];
	return 0;
}

同样这个也是可以优化的。

dp 基础题单

刷。如果有什么 trick 之类的会放在下面。由于是练习所以不精讲。

P2196 [NOIP1996 提高组] 挖地雷

trick:路径记录。

\(dp_i\) 为以节点 \(i\) 为结尾的最多地雷个数。则可以得到状态转移方程:

\[dp_i = \max_{1\le j \le n}\{ dp_j \} +a_i \]

\(pre_i\) 为最优路径上节点 \(i\) 的前驱。

这个序列的更新可以和 \(dp\) 序列的转移一起。具体地,当 \(dp_j >dp_i\) 时,\(pre_i \to j\)

输出路径只需要找到 \(pre_i = 0\) 的节点 \(i\),再每次 \(i \to pre_i\) 并输出即可。

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 25;
int n, p, ans, cnt, a[N], dp[N], pre[N], Ans[N];
bool vis[N][N]; 
signed main() {
	ios_base :: sync_with_stdio(NULL);
	cin.tie(nullptr);
	cout.tie(nullptr);
	cin >> n;
	for(int i = 1 ; i <= n ; ++ i)
		cin >> a[i];
	for(int i = 1 ; i <= n - 1 ; ++ i)
		for(int j = i + 1 ; j <= n ; ++ j)
				cin >> vis[i][j];
	for(int i = 1 ; i <= n ; ++ i) {
		for(int j = 1 ; j <= n ; ++ j)
			if(vis[j][i] && dp[j] > dp[i]) dp[i] = dp[j], pre[i] = j;
		dp[i] += a[i];
		if(dp[i] > ans) ans = dp[i], p = i;
	}
	while(pre[p]) Ans[++ cnt] = p, p = pre[p];
	Ans[++ cnt] = p;
	for(int i = cnt ; i >= 1 ; -- i)
		cout << Ans[i] << ' ';
	cout << '\n' << ans;
	return 0;
}

P1470 [USACO2.3] 最长前缀 Longest Prefix

区间 dp

顾名思义,一般就是一段一段的区间状态。

性质

区间 dp 有以下特点:

  • 合并:即将两个或多个部分进行整合,当然也可以反过来;

  • 特征:能将问题分解为能两两合并的形式;

  • 求解:对整个问题设最优值,枚举合并点,将问题分解为左右两个部分,最后合并两个部分的最优值得到原问题的最优值。

P1775 石子合并(弱化版)

十分典型的区间 dp。

由于只能相邻两两元素合并,所以 \([i,j]\) 全部合并的最小贡献就是从第 \(i\) 个元素合并到第 \(j\) 个元素的答案。

我们又发现因为加法的结合率,一个区间 \([i,j]\) 可以写为区间 \([i,k],[k+1,j](k\in [i,j-1])\) 的贡献之和。这个贡献不难发现就是 \(\sum_{x=i}^{j} a_x\)

于是我们设 \(dp_{i,j}\) 表示区间 \([i,j]\) 的最小代价,则状态转移方程:

\[dp_{i,j} = \min_{k\in [i,j-1]} \{dp_{i,k} + dp_{k+1,j} \} +\sum_{x=i}^{j} a_x \]

此时边界显然是一个元素合并它自身——代价为 \(0\),即:

\[dp_{i,i}\to 0 \]

然后我们去枚举点 \(k\) 去进行转移,\(\sum\) 柿子用前缀和维护。

我们再考虑一个细节:因为我们的区间是由小到大去合并的,所以当枚举到合并形成区间时区间长度比它大的区间答案一定是没有被统计到的。因此我们枚举一个区间左端点 \(i\) 和区间长度 \(l\),来保证转移的时候 dp 序列为空的情况不会发生。

再补充一个笔者犯下的错误:由于此题的区间为闭区间,所以区间 \([i,j]\) 是由区间 \([i,k]\)\(\color{red} [k+1,j]\) 转移而来的(一个晚上)。

#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 305;
int n, x, sum[N], dp[N][N];
signed main() {
	ios_base :: sync_with_stdio(NULL);
	cin.tie(nullptr);
	cout.tie(nullptr);
	memset(dp, 0x3f, sizeof dp);
	cin >> n;
	for(int i = 1 ; i <= n ; ++ i)
		cin >> x, dp[i][i] = 0, sum[i] = sum[i - 1] + x;
	for(int l = 2 ; l <= n ; ++ l)
		for(int i = 1, j ; i + l - 1 <= n ; ++ i) {
			j = i + l - 1;
			for(int k = i ; k < j ; ++ k)
				dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1]);
		}
	cout << dp[1][n]; 
	return 0;
}

P1880 [NOI1995] 石子合并

trick。

题目和上一题是基本一样的,唯一不同的上一题是一个线形序列,而本题是环形序列。

显然此时直接按照环的做法会极其难写……

考虑到一个环形序列的本质其实就是线形序列首尾相接。那么这样就可以说明环形序列的一个子区间是由本序列的线形形式的一段前缀和后缀拼接。

于是就有一个 断环成链 的 trick。

具体操作就是将一个环形序列的线形形式复制两遍,然后正常 dp,最后枚举复制后的序列中长度为 \(n\) 的子区间答案即可。

#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 105;
int n, ans1 = 9e18, ans2 = -9e18, a[N], sum[N << 1], dp1[N << 1][N << 1], dp2[N << 1][N << 1];
signed main() {
	ios_base :: sync_with_stdio(NULL);
	cin.tie(nullptr);
	cout.tie(nullptr);
	memset(dp1, 0x3f, sizeof dp1);
	memset(dp2, ~ 0x3f, sizeof dp2);
	cin >> n;
	for(int i = 1 ; i <= n ; ++ i)
		cin >> a[i], dp1[i][i] = 0, dp2[i][i] = 0, sum[i] = sum[i - 1] + a[i];
	for(int i = n + 1 ; i <= n << 1 ; ++ i)
		dp1[i][i] = 0, dp2[i][i] = 0, sum[i] = sum[i - 1] + a[i - n];
	for(int l = 2 ; l <= n ; ++ l)
		for(int i = 1, j ; i + l - 1 <= n << 1 ; ++ i) {
			j = i + l - 1;
			for(int k = i ; k < j ; ++ k) {
				dp1[i][j] = min(dp1[i][j], dp1[i][k] + dp1[k + 1][j] + sum[j] - sum[i - 1]);
				dp2[i][j] = max(dp2[i][j], dp2[i][k] + dp2[k + 1][j] + sum[j] - sum[i - 1]);
			}
		}
	for(int i = 1 ; i <= n ; ++ i)
		ans1 = min(ans1, dp1[i][i + n - 1]), ans2 = max(ans2, dp2[i][i + n - 1]);
	cout << ans1 << '\n' << ans2;
	return 0;
}

区间 dp 题单

posted @ 2024-08-23 11:51  end_switch  阅读(6)  评论(0编辑  收藏  举报