dp 专题复习
前言:我现在才知道我的初一到底有多摆。决定从头开始学 dp。
题单(总)
https://www.luogu.com.cn/training/1435
https://www.luogu.com.cn/training/201862
广告
这位博主讲得确实很好,至少让我这个半死的 dp 废物看到了生存的希望,大家有兴趣可以看一看。
前记
dp 所需性质
-
最优子结构
-
无后效性
-
子问题重叠
dp 基本步骤:
-
定义
序列以及其下标的含义 -
写出状态转移方程
-
初始化
-
寻找合适遍历顺序
-
输出
序列(作调试用)
dp 基础
P1216 [USACO1.5] [IOI1994]数字三角形 Number Triangles
十分典型的 dp 入门题。
发现暴力 dfs 会解决很多不必要的问题例如枚举深度为
但是倘若我们考虑贪心那么正确性就不一定会得到保障。
此时我们想到,数字三角形中每一个元素下只有两个子元素。
设
因为题目最终要求一个最长路径,而最长路径的子问题合并是很容易的,我们不妨设
我们惊奇地发现:
不难看出其状态转移方程:
注意到当
我们又发现,在
到此为止,我们已经完成了 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 入门题第二弹。
我们发现题目中所求的最终答案是 序列最长上升子序列,而我们发现如果以第
所以我们设
说一句题外话就是这个
而这里的最小子问题是最长上升子序列长度为一个元素时,序列就只有当前元素本身。因此我们的初始化是
#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 入门题第三弹。
根据前一题的经验,我们设
但是我们发现这样不能很好地维护
所以我们就想,如果要同时维护
设
那么为什么是 前 而不是 第 呢?
我们手玩几个数据,不难发现最长上升子序列是不具有 前缀性质 的(也就是
我们先对
- 当有贡献时:
此时因为前缀性质,
- 当没有贡献时
此时考虑继承,
我们判定一个
#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:路径记录。
设
设
这个序列的更新可以和
输出路径只需要找到
#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。
由于只能相邻两两元素合并,所以
我们又发现因为加法的结合率,一个区间
于是我们设
此时边界显然是一个元素合并它自身——代价为
然后我们去枚举点
我们再考虑一个细节:因为我们的区间是由小到大去合并的,所以当枚举到合并形成区间时区间长度比它大的区间答案一定是没有被统计到的。因此我们枚举一个区间左端点
再补充一个笔者犯下的错误:由于此题的区间为闭区间,所以区间
#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,最后枚举复制后的序列中长度为
#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;
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库