[学习笔记] 单调队列优化DP - DP
单调队列优化DP
简单好想的DP优化
真正的教育是把学过的知识忘掉后剩下的东西 —— ***
对于一个转移方程类似于 \(dp[i]=max(min)\{dp[j]+b[j]+a[i]\}\ \ x_i<=j<=y_i\) 的DP,如果暴力实现的话复杂度是 \(O(n^2)\),实现方法是双层for循环嵌套。但如果区间 \([x_i,y_i]\) 与区间 \([x_{i+1},y_{i+1}]\) 存在交集,或者说当 \(i\) 变化时,不同的 \(i\) 所对应的 \(j\) 区间存在重叠,那么我们在使用 \(j\) 进行遍历时就会产生重复计算,而单调队列优化DP就是解决这一重复计算的法宝。
如何用单调队列进行优化呢?可以将 \(j\) 所在的区间看作一个滑动窗口,每次循环 \(i\) 的时候将元素进队,并且更新 \(head\) 的值(找到合法区间),这样就可以将每次寻找最大值的时间复杂度均摊为 \(O(1)\),再加上dp的 \(n\) 次转移,时间复杂度为 \(O(1)*O(n)=O(n)\)。完美~
使用这一优化方法的前提是: max(min)里的东西必须只与 \(j\) 相关,不然没办法优化。
单调队列优化多重背包
我们知道多重背包的朴素DP表达式为:\(dp[j]=max\{dp[j-k*c_i]+k*w_i\}\),其中 \(0\leqslant k\leqslant min\{m_i,j/c_i\}\)。但是这个式子和单调队列优化DP的普通形式 \(dp[i]=max\{dp[i]+b[j] \}+a[i]\ \ \ L(i)\leqslant j\leqslant R(i)\) 差太多了,无法直接用单调队列优化。
考虑到单调队列优化的前提是存在重复的计算,显然有 \(j\) 和 \(j+c_i\) 在计算时存在重复计算。那么也就是说,当\(j_1\equiv j_2\ mod\ c_i\) 时是存在重复计算的,那么问题就很清楚了。
令 \(b=j\%c_i\)、\(y=j/c_i\),那么 \(j=b+y*c_i\)。于是有:
令 \(x=y-k\),则有:
这个DP式即可进行单调队列优化。
就一般的题目而言,只要是蓝题及以上的,满足单调队列优化的狮子都不会太明显,需要一步一步去转化。优化多重背包就是个很巧妙的转化的例子。掌握这类转化的技巧对这类DP很有帮助。
例题
「一本通 5.5 练习 1」烽火传递
纯纯的单调队列优化DP。建议打通这道题,对后面的理解很有帮助。
根据题目可知转移方程为:\(dp[i]=min\{dp[j]+a[i]\}\),其中 \(j\in[i-m, i-1]\)。为了看得清楚,把min里面和 \(i\) 有关的东西全都踢出去:\(dp[i]=min\{dp[j]\}+a[i]\)。那么就可以建立一个关于dp
的单调队列,在每次计算dp[i]
前要先把dp[i-1]
入队。最后答案为 \(i\in[n-m+1,n]\) 中的dp最大值。
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
int n, m, a[N], dp[N], p[N], tail, head=1, ans = INT_MAX;
int main(){
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin>>n>>m;
for(int i=1; i<=n; ++i) cin>>a[i];
for(int i=1; i<=n; ++i){
while(tail >= head && dp[p[tail]] >= dp[i-1]) --tail; // 关于dp数组的单调队列
p[++tail] = i-1;
if(p[head] < i-m) ++head;
dp[i] = dp[p[head]] + a[i];
if(i > n-m) ans = min(ans, dp[i]);
} return cout<<ans, 0;
}
「一本通 5.5 例 2」最大连续和
纯纯的单调队列题。根据题目可知转移方程(假了)为:\(dp[i]=max\{sum[i]-sum[j]\}\),其中 \(j\in[i-m,i-1]\)。转化为能看懂的 \(dp[i]=sum[i]-min\{sum[j]\}\)。用单调队列求解即可。复杂度 \(O(n)\)。
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
int n, m, sum[N], p[N], tail, head = 1, ans = INT_MIN;
int main(){
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin>>n>>m;
for(int i=1, a; i<=n; ++i) cin>>a, sum[i] = sum[i-1] + a;
for(int i=1; i<=n; ++i){
while(tail >= head && sum[p[tail]] >= sum[i-1]) --tail;
p[++tail] = i-1;
while(p[head] < i-m) ++head;
ans = max(ans, sum[i]-sum[p[head]]);
} return cout<<ans, 0;
}
[USACO11OPEN] Mowing the Lawn G
很好的单调队列DP入门题。设 dp[i]
表示选择第 \(i\) 项元素的合法序列的最大和。那么可得转移方程 \(dp[i]=max\{dp[j-1]-sum[j]\}+sum[i]\) 。\(sum[]\) 表示前缀和。其中 \(j\in[i-m,i-1]\),这里的 \(j\) 可以理解为两段连续区间的断开处,并且 \(j\) 是可以等于 \(0\) 的。但是如果DP包含了 \(0\),那必然会涉及 \(dp[-1]\) 的计算。不妨在整个序列前加入一个 \(0\),在进行DP,那么就可以解决这一问题。
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e5 + 5;
int n, k, dp[N], sum[N], p[N], tail=1, head=0, ans = INT_MIN;
signed main(){
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin>>n>>k;
for(int i=2, a; i<=n+1; ++i) cin>>a, sum[i] = sum[i-1] + a;
for(int i=1; i<=n+1; ++i){
while(tail > head && dp[p[tail]-1]-sum[p[tail]] <= dp[i-1]-sum[i]) --tail;
p[++tail] = i;
while(p[head] < i-k) ++head;
dp[i] = dp[p[head]-1] - sum[p[head]] + sum[i];
ans = max(ans, dp[i]);
} return cout<<ans, 0;
}
[POI2005] BAN-Bank Notes
下面只给出计算最小硬币数的代码,方案数略去。
#include<bits/stdc++.h>
using namespace std;
const int N = 2e4 + 1;
int n, k, dp[N], c[N], m[N], p[N], num[N], tail, head;
int main(){
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin>>n;
for(int i=1; i<=n; ++i) cin>>c[i];
for(int i=1; i<=n; ++i) cin>>m[i];
cin>>k;
for(int i=1; i<=k; ++i) dp[i] = INT_MAX;
for(int i=1; i<=n; ++i){
if(m[i] > k / c[i]) m[i] = k/c[i];
for(int b=0; b<c[i]; ++b){
tail = 0, head = 1;
for(int y=0; y<=(k-b)/c[i]; ++y){
int tmp = dp[b + y*c[i]] - y;
while(tail >= head && p[tail] >= tmp) --tail;
p[++tail] = tmp, num[tail] = y;
while(head <= tail && num[head] < y-m[i]) ++head;
dp[b+y*c[i]] = min(dp[b+y*c[i]], p[head] + y);
}
}
} return cout<<dp[k], 0;
}
[SCOI2010] 股票交易
这道题的题目非常的繁琐啊,看的人眼花缭乱的。不过如果你注意力十分充沛的话就可以发现,这道题其实和背包DP很像。我们可以列出总的DP转移方程式:令 \(dp[i][j]\) 表示第 \(i\) 天拥有 \(j\) 支股票的最大收益。则有:
但似乎这狮子又臭又长,没法处理,那么考虑分开来转移:先处理卖出股票的转移,再处理买入股票的转移。先看卖出股票:
令 \(k=j-a\) 则有:
处理成功!同理,卖出股票的转移方程也是一样:
考虑到最开始时手里没有股票,所以需要先全部买入,再进行后面的转移。当然我们也可以选择什么也不做直接由昨天转移到今天。
#include<bits/stdc++.h>
using namespace std;
const int N = 2e3 + 1;
int T, MaxP, w, AP, BP, AS, BS, dp[N][N], p[N], num[N], head, tail, ans = INT_MIN;
int main(){
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin>>T>>MaxP>>w;
memset(dp, -0x7f, sizeof dp);
for(int i=1; i<=T; ++i){
cin>>AP>>BP>>AS>>BS;
for(int j=0; j<=min(AS, MaxP); ++j) dp[i][j] = -j * AP; //全部买入
for(int j=0; j<=MaxP; ++j) dp[i][j] = max(dp[i][j], dp[i-1][j]); //什么也不做
if(i <= w) continue;
tail = 0, head = 1;
for(int j=0; j<=MaxP; ++j){ //买入
int tmp = dp[i-w-1][j] + j*AP;
while(tail >= head && p[tail] <= tmp) --tail;
p[++tail] = tmp, num[tail] = j;
while(head <= tail && num[head] < j-min(j, AS)) ++head;
dp[i][j] = max(dp[i][j], p[head]-j*AP);
}
tail = 0, head = 1;
for(int j=MaxP; j>=0; --j){ // 卖出
int tmp = dp[i-w-1][j] + j*BP;
while(tail >= head && p[tail] <= tmp) --tail;
p[++tail] = tmp, num[tail] = j;
while(head <= tail && num[head] > min(MaxP-j, BS)+j) ++head;
dp[i][j] = max(dp[i][j], p[head]-j*BP);
}
} return cout<<dp[T][0], 0;
}
[NOI2005] 瑰丽华尔兹
披着暴力外衣的DP。跟魔法没半毛钱关系。
首先,定义状态 dp[i][j]
表示当前第 \(i\) 行第 \(j\) 列所走的最大距离。然后全部初始化为 -inf
,这样就可以保证转移出来数字一定是能走到的地方。因为要从最开始的地方进行转移,那么把 dp[x][y]
设为 \(0\)。
接着考虑转移,我们可以根据方向把整张图都转移一遍,比如方向为 \(1\) 那就从下往上刷。如果刷到障碍物,就单调队列归零,continue跳过障碍物重新开始刷。这样就可以保证DP值为正的地方就是能走的地方。然后ans取max即可。
四个方向的DP转移方程式如下:
用单调队列优化后即可得到 \(O(nm)\) 的转移复杂度。总复杂度为 \(O(knm)\)。
#include<bits/stdc++.h>
using namespace std;
int n, m, x, y, K, dmax, dir, num[201], tail, head;
char ch;
bitset<201> G[201];
long long ans, dp[201][201], p[201], tmp;
int main(){
ios::sync_with_stdio(0), cin.tie(0) ,cout.tie(0);
cin>>n>>m>>x>>y>>K;
for(int i=1; i<=n; ++i) for(int j=1; j<=m; ++j){
cin>>ch;
if(ch == 'x') G[i][j] = 1;
}
memset(dp, -0x7f, sizeof dp);
dp[x][y] = 0;
for(int i=1, l, r; i<=K; ++i){
cin>>l>>r>>dir; dmax = r-l+1;
if(dir == 3) for(int j=1; j<=n; ++j){
tail = 0, head = 1;
for(int k=m; k>=1; --k){
if(G[j][k]){tail = 0, head = 1; continue; }
tmp = dp[j][k] + k;
while(tail >= head && p[tail] <= tmp) --tail;
p[++tail] = tmp, num[tail] = k;
while(head <= tail && num[head] > min(m, k+dmax)) ++head;
dp[j][k] = max(dp[j][k], p[head]-k);
ans = max(ans, dp[j][k]);
}
}else if(dir == 4) for(int j=1; j<=n; ++j){
tail = 0, head = 1;
for(int k=1; k<=m; ++k){
if(G[j][k]){tail = 0, head = 1; continue; }
tmp = dp[j][k] - k;
while(tail >= head && p[tail] <= tmp) --tail;
p[++tail] = tmp, num[tail] = k;
while(head <= tail && num[head] < k-min(k, dmax)) ++head;
dp[j][k] = max(dp[j][k], p[head]+k);
ans = max(ans, dp[j][k]);
}
}else if(dir == 1) for(int j=1; j<=m; ++j){
tail = 0, head = 1;
for(int k=n; k>=1; --k){
if(G[k][j]){tail = 0, head = 1; continue; }
tmp = dp[k][j] + k;
while(tail >= head && p[tail] <= tmp) --tail;
p[++tail] = tmp, num[tail] = k;
while(head <= tail && num[head] > min(n, dmax+k)) ++head;
dp[k][j] =max(dp[k][j], p[head]-k);
ans = max(ans, dp[k][j]);
}
}else if(dir == 2) for(int j=1; j<=m; ++j){
tail = 0, head = 1;
for(int k=1; k<=n; ++k){
if(G[k][j]){tail = 0, head = 1; continue; }
tmp = dp[k][j] - k;
while(tail >= head && p[tail] <= tmp) --tail;
p[++tail] = tmp, num[tail] = k;
while(head <= tail && num[head] < k-min(k, dmax)) ++head;
dp[k][j] = max(dp[k][j], p[head]+k);
ans = max(ans, dp[k][j]);
}
}
} return cout<<ans, 0;
}
[USACO13NOV] Pogo-Cow S
神题好吧。一般单调队列是固定左右端点移动中间的 \(k\) 值,这个是固定中间的 \(k\) 值不断扩展左右端点。
定义状态 dp[j][i]
表示从第 \(j\) 个点跳到第 \(i\) 个点的最大分数。
#include<bits/stdc++.h>
using namespace std;
int n, dp[1001][1001], ans;
struct target{ int x, p; }tg[1001];
bool cmp(target a, target b){ if(a.x < b.x) return 1; return 0; }
int main(){
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin>>n;
for(int i=1; i<=n; ++i) cin>>tg[i].x>>tg[i].p;
sort(tg+1, tg+n+1, cmp);
for(int j=1; j<=n; ++j){
dp[0][j] = dp[j][j] = tg[j].p;
for(int i=j+1, h=j+1; i<=n; ++i){
dp[j][i] = dp[j][i-1] - tg[i-1].p;
while(h >= 0 && tg[i].x - tg[j].x >= tg[j].x - tg[h-1].x)
dp[j][i] = max(dp[j][i], dp[--h][j]);
dp[j][i] += tg[i].p;
ans = max(ans, dp[j][i]);
}
}
for(int j=n; j>=1; --j){
dp[j][0] = tg[j].p;
for(int i=j-1, h=j-1; i>0; --i){
dp[j][i] = dp[j][i+1] - tg[i+1].p;
while(h <= n && tg[j].x - tg[i].x >= tg[h+1].x - tg[j].x)
dp[j][i] = max(dp[j][i], dp[++h][j]);
dp[j][i] += tg[i].p;
ans = max(ans, dp[j][i]);
}
} return cout<<ans, 0;
}