【笔记】DP 的优化
DP 常见的优化方式
NOIP 范围内的 DP 优化方式
加速状态转移:
- 前缀和优化。
- 单调队列优化。
- 树状数组或线段树优化。
精简状态:
- 精简状态往往是通过对题目本身性质的分析,去省掉一些冗余的状态,相对以上三条套路性更少,对分析能力的要求更高。
前缀和优化
【逆序对】
【 \(Description\) 】
求长度为 \(n\),逆序对为 \(k\) 的排列有多少个,答案对 \(10^9+7\) 取模。
对于 \(20\%\) 的数据,\(N , K \leq 20\)。
对于 \(40\%\) 的数据,\(N , K \leq 100\)。
对于 \(60\%\) 的数据,\(N , K \leq 5 \times 10 ^ 3\)。
对于 \(100\%\) 的数据,\(1 \leq N , K \leq 10 ^ 5,1 \leq K \leq \binom{n}{2}\)。
【 \(Solution\) 】
排列题的一个套路,我们从小到大依次把数字插入到排列中,以这
个思路进行 \(dp\)。
这个问题设计动态规划的方法就是最基本,最自然的。
我们设 \(dp[i][j]\) 表示插入了前 \(i\) 个数,产生的逆序对为 \(j\) 的排列的方案数,转移时就考虑 \(i+1\) 的那个数插在哪一个位置就好,因为它比之前的都要大。
- 插在最后面就 \(dp[i+1][j+0] += dp[i][j]\)。
- 如果是最后一个数前面就是 \(dp[i+1][j+1] += dp[i][j]\)。
- 倒数第 2 个数前面就是 \(dp[i+1][j+2] += dp[i][j]\)。
依次类推,这个是从前向后更新。
我们如果考虑 \(dp[i][j]\) 能从哪些状态转移过来,就可以前缀和优化。
观察方程 :
我们设 :
则 :
这样就可以通过记录前缀和的方式来将转移优化成为 \(O(1)\)
这样能过 \(60\%\) 的数据,想要过 \(100\%\) 的数据,在这个 \(dp\) 基础上,做容斥原理,通过之前讲的整数划分的模型 \(dp\) 求出容斥系数即可。
但是我不会 /kk。
【 \(Code\) 】
//你不会真以为我写代码了吧/cy
单调队列优化
基本形式,适用范围
单调队列维护 \(dp\),一般就是把一个 \(O(N)\) 转移的 \(dp\) 通过单调队列优化成一个均摊 \(O(1)\) 转移的式子。
式子形如:\(dp[i]=\max\{f(j)\}+g[i]\)(这里的 \(g[i]\) 是与 \(j\) 无关的量),且 \(j\) 的取值是一段连续区间, 区间端点的两端随着 \(i\) 增大而增大的区间。
(同时如果这个可行的区间左端点固定,我们就可以通过之前讲的记录前缀最小值来处理)
这里的 \(f(j)\) 是仅和 \(j\) 有关的项,以下是常见的一维和二维的情况。
这样的题我们就可以做单调队列优化。
【股票交易】
【\(Description\)】
未来 \(T\) 天内的某只股票,第 \(i\) 天的买入价为每股 \(AP_i\),第 \(i\) 天的卖出价为每股 \(BP_i\) (对于每个 \(i\),都有 \(AP_i \geq BP_i\))。
每天不能无限制地交易,规定第 \(i\) 天至多只能购买 \(AS_i\) 股,至多卖出 \(BS_i\) 股。另外规定在两次交易(买入或者卖出均算交易)之间,至少要间隔 \(W\) 天,也就是说如果在第 \(i\) 天发生了交易,那么从第 \(i+1\) 天到第 \(i+W\) 天,均不能发生交易。还规定在任何时间,一个人的手里的股票数不能超过 \(MaxP\)。
初始 \(w\) 手里有一大笔钱(可以认为钱的数目无限),但是没有任何股票,问 \(T\) 天以后,小 \(w\) 最多能赚多少钱。
对于 \(30\%\) 的数据,\(0 \leq W , T \leq 50\),\(1 \leq MaxP \leq 50\)。
对于 \(50\%\) 的数据,\(0 \leq W , T \leq 2000\),\(1 \leq MaxP \leq 50\)。
对于 \(100\%\) 的数据,\(0 \leq W , T \leq 2000\),\(1 \leq MaxP \leq 2000\)。
对于所有的数据,\(1 \leq BP_i \leq AP_i \leq 1000\),\(1 \leq AS_i,BS_i \leq MaxP\)。
【\(Solution\)】
设 \(f[i][j]\) 表示到第 \(i\) 天手里持有 \(j\) 的股票的最大收益,那么第 \(i\) 天有三种操作。
不买不卖 :
买入 :
卖出 :
这样就可以写出状态转移方程,你就可以拿到 50 分了。
考虑优化: 对于买入的情况,我们可以对其进行变形。
那么我们就可以用单调队列维护 \(f[i - w - 1][k] + AP[i] \times k\) ,(因为对于固定的 \(i\),\(AP[i]\) 是固定的),这样 \(f[i][j]\) 就能做到 \(O(1)\) 求得,而不必枚举 \(k\),卖出同理。

- \(id\) 和 \(q\) 数组构成单调队列。
- \(id\) 存的是队首的下标。
- \(q\) 存的是买队首的收益。
- 记下表是为了窗口移动的时候判断队首是否还合法,而求最大收益也肯定是要记个最大值。
【\(Code\)】(50 分代码)
#include<cstdio>
#include<cmath>
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int Maxk = 2010;
int n,w,maxp;
struct DAY {
int AP;
int BP;
int AS;
int BS;
}s[Maxk];
int f[Maxk][Maxk];
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
int Max(int x,int y) {return x > y ? x : y;}
signed main()
{
n = read(),maxp = read(),w = read();
for(int i = 1;i <= n;i ++) {
s[i].AP = read();s[i].BP = read();
s[i].AS = read();s[i].BS = read();
}
memset(f,0xcf,sizeof f);
for(int i = 1;i <= n;i ++) {
for(int j = 0;j <= s[i].AS;j ++) {
f[i][j] = -s[i].AP * j;
}
}
for(int i = 1;i <= n;i ++) {
for(int j = 0;j <= maxp;j ++) {
f[i][j] = Max(f[i - 1][j],f[i][j]);
for(int k = j;(k - j) <= s[i].AS;k ++) {
if(i > w) f[i][k] = Max(f[i][k],f[i - w - 1][j] - s[i].AP * (k - j));//买入
}
for(int k = j;(j - k) <= s[i].BS;k --) {
if(i > w) f[i][k] = Max(f[i][k],f[i - w - 1][j] + s[i].BP * (j - k));//卖出
}
}
}
int ans = 0;
for(int i = 0;i <= maxp;i ++) {
ans = Max(ans,f[n][i]);
}
printf("%d",ans);
return 0;
}
【\(Code\)】(100 分代码)
#include<cstdio>
#include<cmath>
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int Maxk = 2010;
int n,w,maxp;
struct DAY {
int AP;
int BP;
int AS;
int BS;
}s[Maxk];
int f[Maxk][Maxk];
int q[Maxk];//单调队列
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
int Max(int x,int y) {return x > y ? x : y;}
signed main()
{
n = read(),maxp = read(),w = read();
memset(f,128,sizeof f);
for(int i = 1;i <= n;i ++) {
s[i].AP = read();
s[i].BP = read();
s[i].AS = read();
s[i].BS = read();
for(int j = 0;j <= s[i].AS;j ++) f[i][j] = -s[i].AP * j;
for(int j = 0;j <= maxp;j ++) f[i][j] = Max(f[i][j],f[i - 1][j]);
if(i <= w) continue;
int l = 1,r = 0;
for(int j = 0;j <= maxp;j ++) {
while(l <= r && q[l] < j - s[i].AS) l ++;//过时的元素
while(l <= r && f[i - w - 1][q[r]] + q[r] * s[i].AP <= f[i - w - 1][j] + j * s[i].AP)
r --;//更新单调队列的元素
q[++ r] = j;
if(l <= r) f[i][j] = Max(f[i][j],f[i - w - 1][q[l]] + q[l] * s[i].AP - j * s[i].AP);
}
l = 1,r = 0;
for(int j = maxp;j >= 0;j --) {
while(l <= r && q[l] > j + s[i].BS) l ++;//过时的元素
while(l <= r && f[i - w - 1][q[r]] + q[r] * s[i].BP <= f[i - w - 1][j] + j * s[i].BP)
r --;//更新单调队列的元素
q[++ r] = j;
if(l <= r) f[i][j] = Max(f[i][j],f[i - w - 1][q[l]] + q[l] * s[i].BP - j * s[i].BP);
}
}
int ans = 0;
for(int i = 0;i <= maxp;i ++) {
ans = Max(ans,f[n][i]);
}
printf("%d",ans);
return 0;
}
md,while 后面加 ;,调了一小时。
【\(Mowing \ \ the \ \ Lawn \ \ G\)】
【\(Description\)】
Farm John 有 \(N\) ( \(1 \leq N \leq 100000\) )只排成一排的奶牛,编号为 \(1 \cdots N\)。每只奶牛的效率是不同的,奶牛 \(i\) 的效率为 \(E_i\) (\(0 \leq E_i \leq 10 ^ 9\))。
靠近的奶牛们很熟悉,因此,如果 Farm John 安排超过 \(K\) 只连续的奶牛,那么,这些奶牛就会罢工去开派对)。因此,现在 Farm John 需要你的帮助,计算他可以得到的最大效率,并且该方案中没有连续的超过\(K\) 只奶牛。
【\(Solution\)】
设 \(f[i]\) 表示到前 \(i\) 头奶牛且不选第 \(i\) 头能够获得的最大效率。
得到转移方程 :
我们发现 sum[i - 1] 是不变的,我们可以将其提出来,得到 :
用单调队列维护即可。
【\(Code\)】
#include <bits/stdc++.h>
#define ull unsigned long long
#define LL long long
#define M 100010
#define INF 0x3f3f3f3f
using namespace std;
int n,k;
LL a[M];
LL sum[M];
LL f[M];//f[i] 前 i 个数满足条件并且第 i 个数不被选中
LL q[M],l = 1,r = 1;
inline LL read()
{
LL s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
signed main()
{
n = read(),k = read();
for(int i = 1;i <= n;i ++) a[i] = read(),sum[i] = sum[i - 1] + a[i];//统计前缀和
q[1] = 0;
for(int i = 1;i <= n + 1;i ++) {
while(l <= r && q[l] < i - k - 1) l ++;
f[i] = f[q[l]] + sum[i - 1] - sum[q[l]];
while(l <= r && f[i] - sum[i] >= f[q[r]] - sum[q[r]]) r --;//剔除
q[++ r] = i;
}
printf("%lld",f[n + 1]);
return 0;
}
【\(One \ \ hundred \ \ layer\)】
【 \(Description\) 】
有 \(n\) 层,每层从左到右一共 \(m\) 个房间,第 \(j\) 个房间右边走一步是第 \(j+1\) 个房间,第 \(i\) 层第 \(j\) 个房间是 \((i,j)\) 号房间,每个房间有一些金币。
我们从第一层走到最后一层,每层最多移动 \(T\) 步,只能从一个方向走过来,求最多能够拿到多少金币。假设第 \(i\) 层走完时所在的房间为j,则我们走到下一层是会到达 \((i+1,j)\) 号房间。
一开始在第一层的 \(x\) 房间。
\(n,m \leq 10 ^ 3\)。
【 \(Solurion\) 】
转移很明显。
用 \(f[i][j]\) 表示从 \(i\) 层走完了到达 \(i\) 层 \(j\) 位置时的最大金币数。
\(sum[i][j]\) 表示第 \(i\) 层前 \(j\) 个房间的金币和。
把不变的式子提出来。
单调队列优化和前缀和优化的对比
有几类题目可以用单调队列。
- \(O(N \times M)\) 的多重背包。
- 基环树求直径。
单调队列优化 \(dp\) 跟前缀和优化 \(dp\) 差不多一个思路,转移的合法点集都是一个区间。
只不过,单调队列优化 \(dp\) 是当你在最优化 \(dp\) 值的时候和 一段区间有关,而前缀和优化 \(dp\) 是在计数的时候和一段区间有关。
树状数组或线段树优化(数据结构优化)
【LIS】
【\(Description\)】
求最长上升子序列。
\(n \leq 10 ^ 5\)。
【\(Solution\)】
数据结构无脑暴力优化,数据结构不需要什么灵巧的闪光就是套路。
状态转移:\(dp[i]=\max\{ dp[j] \ \ | \ \ a[j] < a[i] \ \ \And\And \ \ j < i \} + 1\) ;
我们把 \(a[j]\) 看成坐标,\(dp[j]\) 看成权值,这就是每次求坐标小于等于某个值的权值最大值,然后每算完一个单点修改即可。
线段树能做,但是大材小用了,其实树状数组就可以解决。

后记
这三个技巧往往就是 \(dp\) 列出式子来之后,观察一下式子,你发现它满足
对应优化的模型,所以我们就单调队列或者前缀和或者线段树优化了。
对思维的要求并不高。
精简状态
精简状态
这个对题目性质的分析能力要求还是比较高的。
需要挖掘题目的性质,特点等等。
不能算很套路的内容,需要多做题感受。
【还是 LIS】
【\(Solution\)】
状态转移:\(dp[i]=\max\{ dp[j] \ \ | \ \ a[j] < a[i] \ \ \And\And \ \ j < i \} + 1\) ;
我们观察一下这个 \(dp\) 式子的转移,他到底是在做一个什么操作。
我们是找比 \(a[i]\) 小的 \(a[j]\) 里面, \(dp[j]\) 的最大值。
从这个角度不是很好优化,我们考虑另外一个思路,我们找最大的 \(k\),满足
存在 \(dp[j]=k \ \ \And\And \ \ a[j] < a[i]\)。
我们设 \(h[k]\) 表示 \(dp[j]=k\) 的所有 \(j\) 当中的最小的 \(a[j]\),就是说长度为 \(k\) 的最长上升序列,最后一个元素的最小值是多少,因为最后一个元素越小。肯定后面更容易再加上一个元素了。
然后我们发现了个奇妙的性质。
而 \(h[k]\),肯定是单调不下降的,就是说“长度为 \(k\) 的最长上升序列最后一个元素的最小值”一定是小于“长度为 \(k+1\) 的最长上升序列最后一个元素的最小值”,如果不是的话,我们可以用后者所在上升子序列构造出一个更小的前者。
然后这个样子我们对于一个 \(a[i]\) 就可以找到,最大的 \(k\),满足 \(h[k]\) 是小于 \(a[i]\) 的,然后 \(dp[i]=k+1\)。找的过程是可以二分加速的。
然后同时在维护出 \(h\) 数组即可。
【\(Code\)】
#include <bits/stdc++.h>
#define LL long long
using namespace std;
int a[101000];
int f[100010];
signed main() {
int n;
cin >> n;
for(int i = 1;i <= n;i ++) cin >> a[i];
int ans = 1;
f[1] = a[1];
for(int i = 2;i <= n;i ++) {
int l = 1,r = ans,mid;
while(l <= r) {
mid = (l + r) >> 1;
if(a[i] <= f[mid]) r = mid - 1;
else l = mid + 1;
}
f[l] = a[i];
if(l > ans) ++ans;
}
printf("%d",ans);
return 0;
}
【传纸条】
【\(Description\)】
一个 \(m\) 行 \(n\) 列的矩阵,而小渊和小轩传纸条。纸条从小渊坐标 \((1,1)\),传到小轩坐标 \((m,n)\)。从小渊传到小轩的纸条只可以向下或者向右传递,从小轩传给小渊的纸条只可以向上或者向左传递。
班里每个同学都可以帮他们传递,但只会帮他们一次。
全班每个同学有一个好心程度。小渊和小轩希望尽可能找好心程度高的同学来帮忙传纸条,即找到来回两条传递路径,使得这两条路径上同学的好心程度之和最大。
\(n,m \leq 300\)。
【\(Solution\)】
其实可以理解为从小渊到小轩传两次。
最暴力的做法:设 \(dp[i][j][k][l]\) 是第一张纸条到达 \((i,j)\),第二张到达 \((k,l)\) 时最大权值。那么方程就是 :
还有一点注意的是,如果 \(i = k \ \ \And\And \ \ j = l\),也就是第二张走了第一张的路径,那么就冲突了要扔掉。
接下来枚举每个 \(i,j,k,l\),最后输出 \(dp[m][n][m][n]\) 就行了,但这样做时间复杂度是 \(O(n^4)\)。
考虑优化 :
其实我们可以让两个路线并行走,同时走,而既然第一张与第二张是同时走,那么我们知道他们的步数是一样的,步数 \(=\) 横坐标 \(+\) 纵坐标,所以只需枚举 \(i,j,k\),就能计算出 \(l\),只需三重循环,时间就变成了 \(O(n^3)\);
【\(Code\)】
#include<cstdio>
#include<cmath>
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int Maxk = 60;
int n,m;
int mp[Maxk][Maxk];
int f[Maxk][Maxk][Maxk][Maxk];
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
int Max(int x,int y) {return x > y ? x : y;}
signed main()
{
n = read(),m = read();
for(int i = 1;i <= n;i ++)
for(int j = 1;j <= m;j ++)
mp[i][j] = read();
for(int i = 1;i <= n;i ++)
for(int j = 1;j <= m;j ++)
for(int k = 1;k <= n;k ++)
for(int l = j + 1;l <= m;l ++)
f[i][j][k][l] = Max(f[i - 1][j][k - 1][l],Max(f[i - 1][j][k][l - 1],Max(f[i][j - 1][k - 1][l],f[i][j - 1][k][l - 1]))) + mp[i][j] + mp[k][l];
printf("%d",f[n][m - 1][n - 1][m]);
return 0;
}

浙公网安备 33010602011771号