W
e
l
c
o
m
e
: )

[学习笔记] 单调队列优化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\)。于是有:

\[\begin{split} dp[b+y*c_i]=max\{dp[b+(y-k)*c_i]+k*w_i\} \end{split} \]

\(x=y-k\),则有:

\[\begin{split} dp[b+y*c_i]&=max\{dp[b+x*c_i]+(y-x)*w_i\}\\ &=max\{dp[b+x*c_i]-x*w_i\}+y*w_i\\ &y-min\{m_i, y\}\leqslant x\leqslant y \end{split} \]

这个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,那么就可以解决这一问题。

image

#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\) 支股票的最大收益。则有:

\[\begin{split} dp[i][j]=max\{dp[i-w-1][j-a+b]-a*AP_i+b*BP_i \} \end{split} \]

但似乎这狮子又臭又长,没法处理,那么考虑分开来转移:先处理卖出股票的转移,再处理买入股票的转移。先看卖出股票:

\[\begin{split} dp[i][j]=max&\{dp[i-w-1][j-a]-a*AP_i\}\\ &0\leqslant a\leqslant min\{j,AS\} \end{split} \]

\(k=j-a\) 则有:

\[\begin{split} dp[i][j]=max&\{dp[i-w-1][k]+k*AP_i\}-j*AP_i\\ &j-min\{j,AS\}\leqslant k\leqslant j \end{split} \]

处理成功!同理,卖出股票的转移方程也是一样:

\[\begin{split} dp[i][j]=max&\{dp[i-w-1][k]+k*BP_i\}-j*BP_i\\ &j\leqslant k\leqslant min\{MaxP-j,BS\}+j \end{split} \]

考虑到最开始时手里没有股票,所以需要先全部买入,再进行后面的转移。当然我们也可以选择什么也不做直接由昨天转移到今天。

#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转移方程式如下:

\[\left\{\begin{matrix} dir=1&dp[i][j]=max\{dp[k][j]+k\}-i & i\leqslant k\leqslant min\{n,dmax+i\} \\ dir=2&dp[i][j]=max\{dp[k][j]-k\}+i & i-min\{i,dmax\}\leqslant k\leqslant i \\ dir=3&dp[i][j]=max\{dp[i][k]+k\}-j & j\leqslant k\leqslant min\{n,dmax+j\} \\ dir=4&dp[i][j]=max\{dp[k][j]-k\}+j & j-min\{j,dmax\}\leqslant k\leqslant j \end{matrix}\right. \]

用单调队列优化后即可得到 \(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;
}
posted @ 2024-05-31 18:08  XiaoLe_MC  阅读(5)  评论(0编辑  收藏  举报