定义

插入 \(\text{dp}\) 适用于计数、求最优解且具有选择、排列元素过程等题目。

插入 \(\text{dp}\) 大致分为两类:

  • 乱搞型:状态定义天马行空,但始终围绕着将新元素插入到旧元素已有集合中
  • 套路型:\(dp_{i, j}\) 表示前 \(i\) 个数,现在构成 \(j\) 个连续段的方案数\(/\)最优解,此外根据实际情况添加状态,转移则是用新元素新建连续段\(/\)合并两个连续段\(/\)扩张连续段左侧或右侧

乱搞型

模板

说是模板,其实这种类型谈不上什么模板,每一题的状态定义几乎都不一样,都有奇奇怪怪的某一维,所以此题也可以视为经典题。

CF466D

定义 \(dp_{i, j}\) 表示考虑完前 \(i\) 个数且前 \(i\) 个数都已推平,尚有 \(j\) 个区间未闭合的方案数。

对于转移,我们有需要满足推平下一个数的条件,以及同一位置不能同时开启\(/\)关闭多于 \(1\) 个区间,根据这个思路去推状态转移式即可。

/*
address:https://codeforces.com/problemset/problem/466/D
AC 2024/12/24 20:45
*/
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int mod = 1e9 + 7;
const int N = 2005;
inline void trans(int& x, int y) { x = (x + y) % mod; }
int dp[N][N];
int n, h;
int a[N];
int main() {
scanf("%d%d", &n, &h);
for (int i = 1;i <= n;i++) scanf("%d", &a[i]), a[i] = h - a[i];
dp[0][0] = 1;
for (int i = 0;i < n;i++)
for (int j = 0;j <= i;j++) {
int x = a[i + 1];
if (j + 1 == x) {
trans(dp[i + 1][j + 1], dp[i][j]); //开启一个新区见
trans(dp[i + 1][j], LL(dp[i][j]) * j % mod); //关闭又开启一个新区间
trans(dp[i + 1][j], dp[i][j]); //开启一个区间后马上关闭该区间
}
if (j == x) {
if (j > 0) trans(dp[i + 1][j - 1], LL(dp[i][j]) * j % mod); //关闭一个区间
trans(dp[i + 1][j], dp[i][j]); //什么事都不干
}
}
printf("%d", dp[n][0]); //最后所有区间都关闭了
return 0;
}

实例

AT_dp_t

考虑插入一个数时要考虑插入的数与最后一个数的相对大小关系,并不在意绝对数值,同时方案数计算依赖于又多少数满足条件,所以定义 \(dp_{i, j}\) 表示考虑完前 \(i\) 个数,剩下的数有 \(j\) 个数比位置 \(i\) 填的数小的方案数。这时考虑转移,对于当前字符,选择更大\(/\)更小的,让后转移过去,发现转移要 \(O(N)\) ,但使用差分或前缀和优化可以砍掉,最后是 \(O(N^2)\) 。

/*
address:https://atcoder.jp/contests/dp/tasks/dp_t
AC 2024/12/24 21:11
*/
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 3005;
const int mod = 1e9 + 7;
char s[N];
int n;
LL dp[N][N], tmp[N];
inline void trans(LL& x, LL y) { x = (x + y + mod) % mod; }
int main() {
scanf("%d%s", &n, s + 1);
for (int i = 0;i < n;i++) dp[1][i] = 1;
for (int i = 1;i <= n;i++) {
fill(tmp, tmp + n + 1, 0);
for (int j = 0;j <= n;j++)
if (s[i] == '>') trans(tmp[0], dp[i][j]), trans(tmp[j], -dp[i][j]);
else trans(tmp[j], dp[i][j]), trans(tmp[n - i], -dp[i][j]);
for (int j = 1;j <= n;j++) trans(tmp[j], tmp[j - 1]);
for (int j = 0;j <= n;j++) dp[i + 1][j] = tmp[j];
}
LL ans = 0;
for (int i = 0;i < n;i++) trans(ans, dp[n][i]);
printf("%lld", ans);
return 0;
}

拓展练习

CF626F 可以排序,定义 \(dp_{i, j, k}\) 表示前 \(i\) 个,有 \(j\) 个小组仍未确定,总不平衡度为 \(k\) 的方案数,卡卡能过,也有通过优化砍掉一维时间的做法,都可以。

Code

套路型

先来思考一个问题,怎么用插入dp求 \(n!\) ,其实就是排列 \(n\) 个元素的方案数,怎么求呢?定义 \(dp_{i, j}\) 表示前 \(i\) 个元素构成 \(j\) 个连续段的方案数,根据定义,我们有如下转移:

\[dp_{i, j} \leftarrow dp_{i - 1, j - 1} \times j + dp_{i - 1, j + 1} \times j + dp_{i - 1, j} \times 2j
\]

最终答案即为 \(dp_{n, 1}\) 。

听起来很奇怪,但这是这一类插入 \(\text{dp}\) 的通用套路,屡试不爽。

实例

Seatfriends

先不管相隔的距离,以套路插入 \(\text{dp}\) 计算若不考虑连续段间需至少隔一个空位时的答案,然后枚举连续段数量并用组合数统计插入空位的方案数,同时特判 \(m = 1\) ,不然会出事,具体可参考我的博客

Phoenix and Computers

按套路,定义 \(dp_{i, j}\) 表示已开了 \(i\) 台电脑,构成了 \(j\) 个连续段。

考虑转移,对于新建一个连续段,我们可以在 \(j + 1\) 个空里插,所以转移式是

\[dp_{i, j} \times (j + 1) \to dp_{i + 1, j + 1}
\]

对于扩展连续段,我们可以直接在左右两侧添加,也可以隔一个位置加,同时把隔的那个位置自动打开,故有以下转移

\[dp_{i, j} \times 2j \to dp_{i + 1, j}, dp_{i + 2, j}
\]

对于合并连续段,由于两个连续段间若距离为 \(1\) 的话中间那台电脑就会自动开启,就已经是一个连续段了,与定义不符,故两个连续段间空位数量为 \(2\) 或 \(3\) ,所以有以下转移

\[dp_{i, j} \times 2(j - 1) \to dp_{i + 2, j - 1}
\]

固定区间间距离为 \(3\) ,开中间那台。

\[dp_{i, j} \times (j - 1) \to dp_{i + 3, j - 1}
\]

这时就有朋友要问了:“作者作者,你为什么在扩展区间和新建区间不考虑区间间的距离?现在又凭什么能固定距离为 \(2\) 和 \(3\) 呢?”

观察一下我们的定义,是没有考虑绝对距离的,只有考虑相对间距,对于两个区间间的距离,我们也就可以随意调整,同时转移式的正确与齐全也带来了一种“自适应性”,能保证不合法的状态一定不会转移到最终状态,例如当 \(n = 3\) ,\(2, 2\) 是没法转移到 \(3, 1\) 的状态的,所以不用考虑提到的问题。

参考代码:

/*
address:https://codeforces.com/problemset/problem/1515/E
AC 2024/12/28 10:41
*/
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 405;
int mod;
int n;
LL dp[N][N];
inline void trans(LL& x, LL y) { x = (x + y) % mod; }
int main() {
scanf("%d%d", &n, &mod);
dp[0][0] = 1;
for (int i = 0;i < n;i++)
for (int j = 0;j <= i;j++) {
trans(dp[i + 1][j + 1], dp[i][j] * (j + 1));
trans(dp[i + 1][j], dp[i][j] * j << 1);
trans(dp[i + 2][j], dp[i][j] * j << 1);
if (j > 1) {
trans(dp[i + 2][j - 1], dp[i][j] * (j - 1 << 1));
trans(dp[i + 3][j - 1], dp[i][j] * (j - 1));
}
}
printf("%lld", dp[n][1]);
return 0;
}

Ant Man

先观察一下题目,发现对于题目中的计算公式可以费用提前计算但前提是在当前插入数的对应方向会继续插入数,可题目已经定义了起点和终点,所以除了起点和终点外,其他座椅的左右两侧都一定会继续插入椅子,故放心大胆使用费用提前计算。

定义 \(dp_{i, j}\) 表示将前 \(i\) 把椅子的访问顺序排完,有 \(j\) 个连续段的最小距离。

考虑转移:

段新增:若插入的数不为 \(s\) 或 \(e\) ,则会添加 \(-2x_i + b_i + d_i\) 否则只考虑右\(/\)左侧添加元素贡献,统共是:

\[dp_{i + 1, j + 1} \leftarrow dp_{i, j} - x_{i + 1} + d_{i + 1} (i + 1 = s)
\]
\[dp_{i + 1, j + 1} \leftarrow dp_{i, j} - x_{i + 1} + b_{i + 1} (i + 1 = e)
\]
\[dp_{i + 1, j + 1} \leftarrow dp_{i, j} - 2x_{i + 1} + d_{i + 1} + b_{i + 1} (i + 1 \ne s \wedge i + 1 \ne e)
\]

同时在添加非 \(s\) 和 \(e\) 的数时,当 \(s\) 和 \(e\) 已插入完毕且当前只有 \(1\) 个连续段,再插入连续段是不合法的,所以还要特判 \(i + 1 <= s \vee i + 1 <= e \vee j + 1 > 2\) 才能进行第 \(3\) 个转移。

段扩张,以同样思路进行分析,考虑规避不合法情况,得到以下转移式:

\[dp_{i + 1, j} \leftarrow dp_{i, j} + x_{i + 1} + c_{i + 1} (i + 1 = s)
\]
\[dp_{i + 1, j} \leftarrow dp_{i, j} + x_{i + 1} + a_{i + 1} (i + 1 = e)
\]
\[dp_{i + 1, j} \leftarrow dp_{i, j} + b_{i + 1} + c_{i + 1} (i + 1 \ne s \wedge i + 1 \ne e)
\]
\[dp_{i + 1, j} \leftarrow dp_{i, j} + a_{i + 1} + d_{i + 1} (i + 1 \ne s \wedge i + 1 \ne e)
\]

段合并:

\[dp_{i + 1, j - 1} \leftarrow dp_{i, j} + 2x_{i + 1} + a{i + 1} + c_{i + 1} (i + 1 \ne s \wedge i + 1 \ne e)
\]

代码:

/*
address:https://codeforces.com/problemset/problem/704/B
AC 2025/1/3 20:59
*/
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 5005;
int n, s, e;
int x[N], a[N], b[N], c[N], d[N];
LL dp[N][N];
inline void trans(LL& x, LL y) { x = x < y ? x : y; }
int main() {
scanf("%d%d%d", &n, &s, &e);
for (int i = 1;i <= n;i++) scanf("%d", &x[i]);
for (int i = 1;i <= n;i++) scanf("%d", &a[i]);
for (int i = 1;i <= n;i++) scanf("%d", &b[i]);
for (int i = 1;i <= n;i++) scanf("%d", &c[i]);
for (int i = 1;i <= n;i++) scanf("%d", &d[i]);
for (int i = 0;i <= n;i++)
for (int j = 0;j <= n;j++) dp[i][j] = 1e18;
dp[0][0] = 0;
for (int i = 0;i < n;i++)
for (int j = 0;j <= i;j++)
if (i + 1 == s) {
trans(dp[i + 1][j + 1], dp[i][j] - x[i + 1] + d[i + 1]);
if (j > 0) trans(dp[i + 1][j], dp[i][j] + x[i + 1] + c[i + 1]);
}
else if (i + 1 == e) {
trans(dp[i + 1][j + 1], dp[i][j] - x[i + 1] + b[i + 1]);
if (j > 0) trans(dp[i + 1][j], dp[i][j] + x[i + 1] + a[i + 1]);
}
else {
if (i + 1 <= s || i + 1 <= e || j + 1 > 2) trans(dp[i + 1][j + 1], dp[i][j] - (x[i + 1] << 1) + d[i + 1] + b[i + 1]);
if (j > 0) {
if (j > 1 || i + 1 < s) trans(dp[i + 1][j], dp[i][j] + b[i + 1] + c[i + 1]);
if (j > 1 || i + 1 < e) trans(dp[i + 1][j], dp[i][j] + a[i + 1] + d[i + 1]);
}
if (j > 1) trans(dp[i + 1][j - 1], dp[i][j] + (x[i + 1] << 1) + a[i + 1] + c[i + 1]);
}
printf("%lld", dp[n][1]);
return 0;
}

Boss

最后给大家隆重介绍插入 \(dp\) 的Boss:UTS Open '21 P7 - April Fools

大致说一下,先排序,定义 \(dp_{i, j, k, 0/1/2}\) 表示前 \(i\) 个数,有 \(j\) 个连续段以 \(\text{MSB}(A_i) - 1\) 结尾,有 \(k\) 个连续段以 \(\text{MSB}(A_i)\) 结尾,结尾区间的结尾为 \(\text{MSB}(A_i)\) / $\text{MSB}(A_i) - 1 $ / \(\le \text{MSB}(A_i) - 2\) 的方案数。分类讨论手推式子转移,省流:\(34\) 个转移,具体看我的博客

/*
address:http://vjudge.net/problem/DMOJ-utso21p7
AC 2025/1/10 22:06
*/
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 505;
const int mod = 1e9 + 7;
int n;
int a[N], f[N];
int dp[2][N][N][3];
inline void trans(int& x, const int y, const int z) { x = (x + 1ll * y * z % mod) % mod; }
int main() {
scanf("%d", &n);
for (int i = 1;i <= n;i++) scanf("%d", &a[i]);
sort(a + 1, a + n + 1);
for (int i = 1;i <= n;i++)
for (int j = 31;j >= 0;j--)
if (a[i] >> j & 1) {
f[i] = j;
break;
}
dp[1][0][1][0] = 1;
for (int i = 1;i < n;i++) {
for (int j = 0;j <= i + 1;j++)
for (int k = 0;k + j <= i + 1;k++)
for (int l = 0;l < 3;l++) dp[i + 1 & 1][j][k][l] = 0;
for (int j = 0;j <= i;j++)
for (int k = 0;k + j <= i;k++) {
const int c0 = dp[i & 1][j][k][0], c1 = dp[i & 1][j][k][1], c2 = dp[i & 1][j][k][2];
const int l = (i & 1) ^ 1;
if (f[i + 1] - f[i] == 0) {
// new
trans(dp[l][j][k + 1][0], c0, j + k + 1);
trans(dp[l][j][k + 1][1], c1, j + k);
trans(dp[l][j][k + 1][0], c1, 1);
trans(dp[l][j][k + 1][2], c2, j + k + 1);
// extend
trans(dp[l][j][k][0], c0, j + k);
trans(dp[l][j][k][1], c1, j + k);
trans(dp[l][j][k][2], c2, j + k + 1);
if (j > 0) {
trans(dp[l][j - 1][k + 1][0], c0, j);
trans(dp[l][j - 1][k + 1][1], c1, j - 1);
trans(dp[l][j - 1][k + 1][0], c1, 1);
trans(dp[l][j - 1][k + 1][2], c2, j);
}
// merge
if (j > 0) {
trans(dp[l][j - 1][k][0], c0, j);
trans(dp[l][j - 1][k][1], c1, j - 1);
trans(dp[l][j - 1][k][2], c2, j);
}
}
if (f[i + 1] - f[i] == 1) {
// new
if (j == 0) trans(dp[l][k][1][0], c0, 1);
if (j == 0) trans(dp[l][k][1][1], c0, k);
if (j == 1) trans(dp[l][k][1][2], c1, k + 1);
if (j == 0) trans(dp[l][k][1][2], c2, k + 1);
// extend
if (j == 0) trans(dp[l][k][0][1], c0, k);
if (j == 0 && k > 0) trans(dp[l][k - 1][1][1], c0, k - 1);
if (j == 0 && k > 0) trans(dp[l][k - 1][1][0], c0, 1);
if (j == 1) trans(dp[l][k][0][2], c1, k + 1);
if (j == 1 && k > 0) trans(dp[l][k - 1][1][2], c1, k);
if (j == 0) trans(dp[l][k][0][2], c2, k + 1);
if (j == 0 && k > 0) trans(dp[l][k - 1][1][2], c2, k);
// merge
if (k > 0) {
if (j == 0) trans(dp[l][k - 1][0][1], c0, k - 1);
if (j == 1) trans(dp[l][k - 1][0][2], c1, k);
if (j == 0) trans(dp[l][k - 1][0][2], c2, k);
}
}
}
const int j = i & 1, k = j ^ 1;
if (f[i + 1] - f[i] >= 2) {
// new
trans(dp[k][0][1][2], dp[j][1][0][1], 1);
trans(dp[k][0][1][2], dp[j][0][1][0], 1);
trans(dp[k][0][1][2], dp[j][0][0][2], 1);
// extend
trans(dp[k][0][0][2], dp[j][1][0][1], 1);
trans(dp[k][0][0][2], dp[j][0][1][0], 1);
trans(dp[k][0][0][2], dp[j][0][0][2], 1);
}
}
printf("%d\n", ((dp[n & 1][1][0][1] + dp[n & 1][0][1][0]) % mod + dp[n & 1][0][0][2]) % mod);
return 0;
}

总结

插入 \(dp\) 在学之前去做的话做出的概率无限接近于 \(0\) ,因为无论是乱搞型还是套路型的定义都非常地巧妙,同时这一个 \(\text{dp}\) 地可变性很多,也比较耗脑子,需要多刷题归纳总结各种套路。所以说 \(\text{dp}\) 是人类智慧的结晶是有道理的。

宝剑锋从磨砺出,梅花香自苦寒来

插入dp学习笔记的更多相关文章

  1. 数位DP学习笔记

    数位DP学习笔记 什么是数位DP? 数位DP比较经典的题目是在数字Li和Ri之间求有多少个满足X性质的数,显然对于所有的题目都可以这样得到一些暴力的分数 我们称之为朴素算法: for(int i=l_ ...

  2. DP学习笔记

    DP学习笔记 可是记下来有什么用呢?我又不会 笨蛋你以后就会了 完全背包问题 先理解初始的DP方程: void solve() { for(int i=0;i<;i++) for(int j=0 ...

  3. 树形DP 学习笔记

    树形DP学习笔记 ps: 本文内容与蓝书一致 树的重心 概念: 一颗树中的一个节点其最大子树的节点树最小 解法:对与每个节点求他儿子的\(size\) ,上方子树的节点个数为\(n-size_u\) ...

  4. 斜率优化DP学习笔记

    先摆上学习的文章: orzzz:斜率优化dp学习 Accept:斜率优化DP 感谢dalao们的讲解,还是十分清晰的 斜率优化$DP$的本质是,通过转移的一些性质,避免枚举地得到最优转移 经典题:HD ...

  5. 线段树优化DP学习笔记 & JZOJ 孤独一生题解

    在 \(DP\) 的世界里 有一种题需要单调队列优化 \(DP\) 一般在此时,\(f_i\) 和它的决策集合 \(f_j\) 在转移时 \(i\) 不和 \(j\) 粘在一起(即所有的 \(j\) ...

  6. 动态 DP 学习笔记

    不得不承认,去年提高组 D2T3 对动态 DP 起到了良好的普及效果. 动态 DP 主要用于解决一类问题.这类问题一般原本都是较为简单的树上 DP 问题,但是被套上了丧心病狂的修改点权的操作.举个例子 ...

  7. [总结] 动态DP学习笔记

    学习了一下动态DP 问题的来源: 给定一棵 \(n\) 个节点的树,点有点权,有 \(m\) 次修改单点点权的操作,回答每次操作之后的最大带权独立集大小. 首先一个显然的 \(O(nm)\) 的做法就 ...

  8. 插头DP学习笔记——从入门到……????

    我们今天来学习插头DP??? BZOJ 2595:[Wc2008]游览计划 Input 第一行有两个整数,N和 M,描述方块的数目. 接下来 N行, 每行有 M 个非负整数, 如果该整数为 0, 则该 ...

  9. 树形$dp$学习笔记

    今天学习了树形\(dp\),一开始浏览各大\(blog\),发现都\(TM\)是题,连个入门的\(blog\)都没有,体验极差.所以我立志要写一篇可以让初学树形\(dp\)的童鞋快速入门. 树形\(d ...

  10. 斜率优化dp学习笔记 洛谷P3915[HNOI2008]玩具装箱toy

    本文为原创??? 作者写这篇文章的时候刚刚初一毕业…… 如有错误请各位大佬指正 从例题入手 洛谷P3915[HNOI2008]玩具装箱toy Step0:读题 Q:暴力? 如果您学习过dp 不难推出d ...

随机推荐

  1. 『玩转Streamlit』--交互类组件

    交互类组件在Web应用程序中至关重要,它们允许用户与应用进行实时互动,能够显著提升用户体验. 用户不再只是被动地接收信息,而是可以主动地输入数据.做出选择或触发事件,从而更加深入地参与到应用中来. 此 ...

  2. 用 300 行代码手写提炼 Spring 核心原理 [1]

    系列文章 用 300 行代码手写提炼 Spring 核心原理 [1] 用 300 行代码手写提炼 Spring 核心原理 [2] 用 300 行代码手写提炼 Spring 核心原理 [3] 手写一个 ...

  3. git pull发现有ahead of xx commits问题如何解决

    git pull 的时候发现有提示你ahead of xx commits 这个时候怎么办呢? 直接一句话定位到远程最新分支 git reset --hard origin/分支名称

  4. 我们有40%代码是 AI 写的

  5. Spring的IOC容器创建过程深入剖析

    前言 本次对于Spring的IOC容器的创建过程是基于其源码进行研究分析的,主要涉及BeanFactory的创建过程,Bean的解析与注册过程,Bean实例化的过程以及诸如ClassPathXmlAp ...

  6. MySQL之根据经纬度计算距离

    可以在MySQL层面使用自定义计算函数来使用 CREATE DEFINER=`xxx`@`%` FUNCTION `get_distance`( lat1 float,lon1 float,lat2 ...

  7. win10下,更改程序磁贴图标

    win8.1后,Windows支持程序图标的定制显示. 一般我们制作win程序时,会给程序设定一个标准的icon,不过这个图标不能满足win10的图标显示需求了,现在我们就用qq为例,定制一下程序图标 ...

  8. OS之《CPU调度》

    CPU调度层次 高级调度:是作业调度.将外村的作业加载到内存里,分配对应的资源,然后加入就绪队列 低级调度:将就绪队列中的进程调度到CPU执行 中级调度:为了提高内存的利用率和系统的吞吐量,将暂时不能 ...

  9. MAC清理

    今日分享 Mac清理 有很多三方软件可以清理,以前用过腾讯的柠檬lite,每次就清个几百兆,系统数据感觉还是得自己手动清理才行 今天电脑又在提醒储存空间不足了,一看占用发现系统数据占了100多个G,学 ...

  10. Java线程 interrupt 方法使用异常

    背景 需要在异步任务中中断任务的执行,故选择通过调用 interrupt 方法对线程设置中断信号. 在比较耗时的业务代码增加判断 Thread.currentThread().isInterrupte ...