【笔记】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]\) 能从哪些状态转移过来,就可以前缀和优化。

观察方程 :

\[dp[i][j] = \sum _{k = 0} ^ {i - 1} dp[i - 1][j - k] \]

我们设 :

\[f[i][j] = \sum _{k = 0} ^ j dp[i][k] \]

则 :

\[dp[i][j] = f[i - 1][j] - f[i - 1][j - i] \]

这样就可以通过记录前缀和的方式来将转移优化成为 \(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\) 有关的项,以下是常见的一维和二维的情况。

\[dp[i]=\max\{dp[j]+f(j)\}+g[i]$$ $$dp[level][i]=\max\{dp[level-1][j]+f(j)\}+g(i) \]

这样的题我们就可以做单调队列优化。

【股票交易】

\(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\) 天有三种操作。

不买不卖 :

\[f[i][j] = \max\{f[i][j],f[i - 1][j]\} \]

买入 :

\[f[i][j] = \max\{f[i][j],f[i - w - 1][k] - AP[i] \times (j - k) \ \ | \ \ k \geq j - AS[i]\} \]

卖出 :

\[f[i][j] = \max\{f[i][j],f[i - w - 1][k] + BP[i] \times (k - j) \ \ | \ \ k \leq j + BS[i]\} \]

这样就可以写出状态转移方程,你就可以拿到 50 分了。

考虑优化: 对于买入的情况,我们可以对其进行变形。

\[f[i][j] = \max\{f[i][j],f[i - w - 1][k] - AP[i] \times (j - k) \} \\= \max\{f[i][j],f[i - w - 1][k] + AP[i] \times k - AP[i] \times j \} \]

那么我们就可以用单调队列维护 \(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\) 头能够获得的最大效率。

得到转移方程 :

\[f[i] = \max_{j = \max(0,i - k - 1)} ^ {i - 1} \{f[j] + sum[i - 1] - sum[j]\} \]

我们发现 sum[i - 1] 是不变的,我们可以将其提出来,得到 :

\[f[i] = \max_{j = \max(0,i - k - 1)} ^ {i - 1} \{f[j] - sum[j]\}+ 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\) 个房间的金币和。

\[f[i][j] = \max\{\max\{f[i - 1][j + k] + sum[i][j +k] - sum[i][j - 1]\},\max\{f[i - 1][j - k] + sum[i][j] - sum[i][j - k - 1]\}\} \]

把不变的式子提出来。

\[f[i][j] = \max\{\max\{f[i - 1][j + k] + sum[i][j + k] \}- sum[i][j - 1],\max\{f[i - 1][j - k] - sum[i][j - k - 1] + sum[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)\) 时最大权值。那么方程就是 :

\[dp[i][j][k][l] = map[i][j] + map[k][l] + \max(dp[i - 1][j][k - 1][l],dp[i -1][j][k][l - 1],dp[i][j - 1][k -1][l],dp[i][j -1][k][l -1]) \]

还有一点注意的是,如果 \(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)\);

\[dp[i][j][k] = map[i][j] + map[k][i+j-k] + max(dp[i - 1][j][k - 1],dp[i -1][j][k],dp[i][j - 1][k -1],dp[i][j -1][k]) \]

\(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;
}
posted @ 2021-03-07 21:16  Ti_Despairy  阅读(227)  评论(0)    收藏  举报