【总结】单调队列优化DP

待填的坑越来越多惹 QwQ

双端队列

前置芝士

队列(queue

正文

众所周知,STL有个东西叫 deque,但是为了装B,我们可以手写。

那么,双端队列(deque)是个什么东西?和queue有什么不同?

queue是队尾(back)进,队头(front)出。

deque呢,是队尾进,队头、队尾都可以出。

所以可以比较轻易地得到代码。

template<class T>
class deque{
	private:
		T q[maxn];
		int l,r;
	public:
		deque(void){
			r=0;
			l=1;
			//l的初值必须比r大1,否则初始时的size就会为1
		}
		void clear(void){
			r=0;
			l=1;
			//清空
        }
		T front(void){
			return q[l];
            //返回队头元素
		}
		T back(void){
			return q[r];
            //返回队尾元素
		}
		void push(T x){
			q[++r]=x;
            //向队尾插入元素
		}
		int size(void){
			return r-l+1;
		}
		bool empty(void){
			return !(r-l+1);
		}
		void pop_front(void){
			if(l<=r)++l;
			return;
			//弹出队头
		}
		void pop_back(void){
			if(l<=r)--r;
			return;
			//弹出队尾
		}
};
//提示:在时限较小时还是使用STL中的吧(亲身经历

单调队列

前置芝士

往上翻。

正文

字面意思,单调队列=元素具有单调性的队列。

LIS 时,我们学过 一种贪心+二分的做法

我们使一个数组内的元素保持从小到大,如果要插入的元素比数组末尾元素更大,直接插入;否则在数组内找到相应的位置替换原有的,贡献较小的值。

这个操作和单调队列有一点点像。但这个数组维护的是LIS的长度,单调队列维护的是区间最值。

具体怎么操作呢?

例题一:求m区间内的最小值

Solution

虽然这道题可以用滚动的ST表或动态开点的线段树过,但我们还是假装它是一道单调队列的模板题。

我们思考,对于1~n内的每一个 i,如何才能去除多余元素,维护 a[i-m-1]~a[i-1] 的最小值?

我们使用一个双端队列q,储存的元素均是 a 的下标。

我们假设此时q内的下标在i之前,且对应的元素已经从小到大有序。(即 a[q[l]]<a[q[l+1]]<a[q[l+2]]<...<a[q[r]]

最后取得的最大值就是 a[q[l]],也就是 a[q.first()]

首先我们要保证 q.first()i-m-1~i-1范围内,也就是 q.first()>=i-m-1

如果 q.first()<i-m-1,说明这个值已经在范围之外了,大可直接 pop 掉。

因为可能下一个最小值同样不在这个范围内,我们重复判断,直到下一个下标在范围内或队列为空为止。

while(i-q.front()+1>m&&q.size())
	q.pop_front();

接下来要做的就是将 i 插入且保持 q 的单调性。

a[i]a[q.back()]小,q.back()一定是个废品,可以pop掉。

重复“扔掉废品”的动作,直到下一个元素不是废品或队列为空为止。

Code

#include<cstdio>
const int maxn=2e6+5;
int n,m;
int a[maxn];
template<class T>
class deque{
	private:
		int q[maxn];
		int l,r;
	public:
		deque(void){
			r=0;
			l=1;
		}
		void clear(void){
			r=0;
			l=1;
        }
		T front(void){
			return q[l];
		}
		T back(void){
			return q[r];
		}
		void push(T x){
			q[++r]=x;
		}
		int size(void){
			return r-l+1;
		}
		bool empty(void){
			return !(r-l+1);
		}
		void pop_front(void){
			if(l<=r)++l;
			return;
		}
		void pop_back(void){
			if(l<=r)--r;
			return;
		}
};
deque<int>q;
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;++i){
		scanf("%d",&a[i]);
		printf("%d\n",a[q.front()]);
		if(i-q.front()+1>m)
			q.pop_front();
        //因为每次都会删除范围外的,导致范围外的数最多只有连续的1个,故将while替换为if
		while(a[i]<=a[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
        /*
        最后插入元素,
        为了防止下一个数无front可查(即除了当前数其他所有数都因为在下一个数范围外而删除)
        必须插入当前数i
        */
	}
	return 0;
}

练习一:滑动窗口

双倍经验 三倍经验

Solution

和上一道题很像,只不过要同时维护最大值与最小值,以及一些语句的顺序问题。

Code

#include<cstdio>
const int maxn=1e6+5;
template<class T>
class deque{/*略*/};
int n,m;
deque<int>q1,q2;
int a[maxn],ans[maxn][2];
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;++i){
		scanf("%d",&a[i]);
		while(a[i]<a[q1.back()]&&q1.size())
			q1.pop_back();
		while(a[i]>a[q2.back()]&&q2.size())
			q2.pop_back();
		q1.push(i);
		q2.push(i);
		if(i-q1.front()>=m)
			q1.pop_front();
		if(i-q2.front()>=m)
			q2.pop_front();
		ans[i][0]=a[q1.front()];
		ans[i][1]=a[q2.front()];
        //注意这里的顺序!
        //可以自行尝试在草稿纸上模拟一下单调队列的过程
	}
	for(int i=m;i<=n;++i)
		printf("%d ",ans[i][0]);
	puts("");
	for(int i=m;i<=n;++i)
		printf("%d ",ans[i][1]);
	return 0;
}

例题二:最大连续和

双倍经验

Solution

水题。

注意到 连续 两字,考虑使用前缀和优化。

sum 数组表示 A 数组的前缀和,对于每一个 \(i(1\leqslant i\leqslant n)\),找到 \(\min\{sum_j\}(i-m\leqslant j<i)\),那么 \(j\sim i\) 就是以 \(i\) 结尾的和最大的连续子序列。

是不是有点 DP 味儿了?

Tips:因为 \(1\sim i\) 也是一段连续的子序列,所以 \(sum_i-sum_0\) 也可能是答案,单调队列中应先 push(0)

Code

#include<cstdio>
const int maxn=2e5+5;
const int inf=0x3f3f3f3f;
template<class T>
class deque{/*略*/};
deque<int>q;
int n,m,ans=-inf;
int a[maxn],sum[maxn];
int max(int x,int y){return x>y?x:y;}
int main(){
	scanf("%d%d",&n,&m);
	q.push(0);
	for(int i=1;i<=n;++i){
		scanf("%d",&a[i]);
		sum[i]=sum[i-1]+a[i];
		if(i-q.front()>m)
			q.pop_front();
		ans=max(ans,sum[i]-sum[q.front()]);
		while(sum[i]<=sum[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
	} 
	printf("%d",ans);
	return 0;
}

例题三:旅行问题

Solution

看来出题人不想要他(她?)的 horse 了。

重点在于 顺时针(或逆时针) ,这代表着我们必须正着跑一遍,再反着跑一遍。

此题需要极强的耐力,调起来让人真心问候出题人的母亲,甚至调到最后你都不知道自己在写什么代码逻辑却很清晰明了,主要是细节问题。

到了这时,我们的算法越来越靠近 DP 了。所以我们想到 DP 处理环形问题的方法:将环变形成一条链。

对于顺时针的走法:

在每一站,可以获得的“真实油量”就是 \(p_i-d_i\)

从第 \(i\) 站到第 \(j\) 站的最后油量(不管中途油够不够用)就是 \(\sum\limits_{k=i+1}^{j}p_k-d_k\)

考虑对 \(p_i-d_i\) 的前缀和优化。

我们从后往前遍历每一站 \(i\),用单调队列储存在 \(i\sim i+n\) 范围内前缀和最小的站,若这个站的前缀和 \(-sum_i\) 的值为非负数,即以 \(i\) 为起点,到这个站时油够用。由于这个站是以 \(i\) 站为起点,全程剩余油量最小的一个站,既然这个站油够用,整个行程的有就够用。

逆时针同理。

Code

<法一>(乱写水过,作者本人都看不懂,可以跳过)

#include<cstdio>
#define int long long
const int maxn=2e6+5;
template<class T>
class deque{/*略*/};
int n,nn;
deque<int>q,q2;
bool vis[maxn];
int p[maxn],d[maxn];
int a[maxn],a2[maxn],sum[maxn],sum2[maxn];
signed main(){
	scanf("%lld",&n);
	for(int i=1;i<=n;++i){
		scanf("%lld%lld",&p[i],&d[i]);
		p[i+n]=p[i],d[i+n]=d[i];
		a[i]=p[i]-d[i];
		a[i+n]=p[i+n]-d[i+n];
		sum[i]=sum[i-1]+a[i];
		while(sum[i]<sum[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
	}
	d[0]=d[n];
	for(int i=1;i<=n;++i){
		a2[i]=p[i]-d[i-1];
		a2[i+n]=p[i+n]-d[i+n-1];
		sum2[i]=sum2[i-1]+a2[i];
	}
	nn=n<<1;
	for(int i=n+1;i<=nn;++i)
		sum[i]=sum[i-1]+a[i];
	for(int i=nn;i>n;--i){
		sum2[i]=sum2[i+1]+a2[i];
		while(sum2[i]<sum2[q2.back()]&&q2.size())
			q2.pop_back();
		q2.push(i);
	}
	for(int i=n;i;--i)
		sum2[i]=sum2[i+1]+a2[i];
	for(int i=1;i<=n;++i){
		if(q.front()<i)
			q.pop_front();
		if(sum[q.front()]-sum[i-1]>=0)
			vis[i]=1;
		while(sum[i+n]<sum[q.back()]&&q.size())
			q.pop_back();
		q.push(i+n);
	}
	for(int i=nn;i>n;--i){
		if(q2.front()>i)
			q2.pop_front();
		if(sum2[q2.front()]-sum2[i+1]>=0)
			vis[i-n]|=1;
		while(sum2[i-n+1]<sum2[q2.back()]&&q2.size())
			q2.pop_back();
		q2.push(i-n);
	}
	for(int i=1;i<=n;++i)
		puts(vis[i]?"TAK":"NIE");
	return 0;
}

<法二>(正解)

#include<cstdio>
#define int long long
const int maxn=2e6+5;
template<class T>
class deque{/*略*/};
int n,nn;
deque<int>q;
bool vis[maxn];
int p[maxn],d[maxn],a[maxn];
signed main(){
	scanf("%lld",&n);
	for(int i=1;i<=n;++i){
		scanf("%lld%lld",&p[i],&d[i]);
		p[n+i]=p[i],d[n+i]=d[i];
	}
	nn=n<<1;
	//顺时针
	for(int i=1;i<=nn;++i)
		a[i]=a[i-1]+p[i]-d[i];
	for(int i=nn;i;--i){
		if(q.front()>=i+n)
			q.pop_front();
		while(a[i]<=a[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
		if(a[i-1]<=a[q.front()]&&i<=n)
			vis[i]=1;
	}
	//逆时针
	d[0]=d[n];
	q.clear();
	for(int i=1;i<=nn;++i)
		a[i]=a[i-1]+p[i]-d[i-1];
	for(int i=1;i<=nn;++i){
		if(q.front()<=i-n)
			q.pop_front();
		if(i>n&&a[i]>=a[q.front()])
			vis[i-n]=1;
		while(a[q.back()]<=a[i]&&q.size())
			q.pop_back();
		q.push(i);
	}
	for(int i=1;i<=n;++i)
		puts(vis[i]?"TAK":"NIE");
	return 0;
}

例题四:修剪草坪

双倍经验 三倍经验 (没有放错链接,就是重题《旧事重提》)

Solution

进入正题

看上去是一道 DP,我们先写出状态转移方程。

\(f_{i,0}\) 为不选第 \(i\) 头牛时的最大效率值,\(f_{i,1}\) 为选择第 \(i\) 头牛时的最大效率值,\(sum_i=\sum\limits_{j=1}^{i}E_i\),则有:

\[f_{i,0}=\max(f_{i-1,0},f_{i-1,1}) \\ \begin{aligned}f_{i,1}&=\max\{f_{j,0}+sum_i-sum_j\}(i-k\leqslant j<i) \\ &=\max\{f_{j,0}-sum_j\}+sum_i(i-k\leqslant j<i) \end{aligned} \]

\(f_{i,0}\) 很好求,关键在于 \(f_{i,1}\)\(\max\) ,如果不进行优化,算法将会有 \(\Theta(n^2)\) 的时间复杂度,明显爆掉。

既然是求极值,我们为何不用单调队列优化呢?

使用单调队列维护 \(\max\{f_{j,0}-sum_j\}\)

Code

#include<cstdio>
#define int long long
const int maxn=1e5+5;
template<class T>
class deque{/*略*/};
int n,k;
deque<int>q;
int f[maxn][2];
int a[maxn],sum[maxn];
int max(int x,int y){return x>y?x:y;}
signed main(){
	scanf("%lld%lld",&n,&k);
	q.push(0);
	for(int i=1;i<=n;++i){
		scanf("%lld",&a[i]);
		sum[i]=sum[i-1]+a[i];
		f[i][0]=max(f[i-1][0],f[i-1][1]);
		if(i-q.front()>k)
			q.pop_front();
		f[i][1]=f[q.front()][0]-sum[q.front()]+sum[i];
		while(f[i][0]-sum[i]>f[q.back()][0]-sum[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
	}
	printf("%lld",max(f[n][0],f[n][1]));
	return 0;
}

练习四:绿色通道

\(f_i\) 表示抄第 \(i\) 题时最小用时。

二分最小空题段长度,水题。

#include<cstdio>
const int maxn=5e4+5;
template<class T>
class deque{/*略*/};
deque<int>q;
int a[maxn],f[maxn];
int n,t,ans,l,r,mid;
int max(int x,int y){return x>y?x:y;}
bool check(int x){
	/*
	状态
		f[i]表示抄第i道题所花费的最小时间 
	DP柿子
		f[i]=min{f[j]}+a[i] (i-x<=j<i)
	*/
	q.clear();
	q.push(0);
	for(int i=1;i<=n;++i){
		if(q.front()<i-x-1)
			q.pop_front();
		f[i]=f[q.front()]+a[i];
		while(f[i]<f[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
	}
	return f[q.front()]<=t;
}
int main(){
	scanf("%d%d",&n,&t);
	for(int i=1;i<=n;++i)
		scanf("%d",&a[i]);
	l=1,r=n;
	while(l<r){
		mid=l+r>>1;
		if(check(mid)){
			ans=mid;
			r=mid;
		}
		else l=mid+1;
	}
	printf("%d",ans);
	return 0;
}

总结

总的方法就是:

  1. 推出 DP 柿子
  2. 将与 \(i\) 有关的项提到 \(\max/\min\) 的外面
  3. 单调队列维护 \(\max/\min\) 内的值

也就是说,满足以上条件的DP,可以使用单调队列优化。

那么,如果有类似 \(f_i=\min\{f_j+a_i\times a_j\}\) 这样无法直接将与 \(i\) 有关的项提出来的话,怎么办呢?

我们将会在斜率优化DP中讲到。


拓展难题

Dragon Ball

提示

绝对值处理起来很麻烦,为什么不尝试将每个周期的地图排序后,先处理当前点左边的点,再处理右边的呢?

Code
#include<cstdio>
#include<algorithm>
using std::sort;
const int inf=0x3f3f3f3f;
template<class T>
class deque{/*略*/};
struct ball{
	int val,pos;
	bool operator<(const ball q)const{
		return pos<q.pos;
	}
}a[55][1005];
deque<int>q;
int f[55][1005];
int T,n,m,x,ans,i,j,k;//不卡常过不了
int min(int x,int y){return x<y?x:y;}
int abs(int x){return x>=0?x:-x;}
int main(){
	scanf("%d",&T);
	while(T--){
		ans=inf;
		scanf("%d%d%d",&m,&n,&x);
		for(i=1;i<=m;++i){
			for(j=1;j<=n;++j)
				scanf("%d",&a[i][j].pos);
		}
		for(i=1;i<=m;++i){
			for(j=1;j<=n;++j)
				scanf("%d",&a[i][j].val);
			sort(a[i]+1,a[i]+n+1);
		}
		for(i=1;i<=n;++i)
			f[1][i]=abs(a[1][i].pos-x)+a[1][i].val;
		for(i=2;i<=m;++i){//枚举周期
			q.clear();
			for(j=k=1;j<=n;++j){//枚举当前节点为j
				f[i][j]=inf;
				while(k<=n&&a[i-1][k].pos<=a[i][j].pos){
					int tmp=f[i-1][k]-a[i-1][k].pos;
					while(q.size()&&tmp<f[i-1][q.back()]-a[i-1][q.back()].pos)
						q.pop_back();
					q.push(k++);
				}
				if(q.size())
					f[i][j]=f[i-1][q.front()]+a[i][j].pos-a[i-1][q.front()].pos+a[i][j].val;
			}
			q.clear();
			for(j=k=n;j;--j){
				while(k&&a[i-1][k].pos>=a[i][j].pos){
					int tmp=f[i-1][k]+a[i-1][k].pos;
					while(q.size()&&tmp<f[i-1][q.back()]+a[i-1][q.back()].pos)
						q.pop_back();
					q.push(k--);
				}
				if(q.size())
					f[i][j]=min(f[i][j],f[i-1][q.front()]-a[i][j].pos+a[i-1][q.front()].pos+a[i][j].val);
			}
		}
		for(i=1;i<=n;++i)
			ans=min(ans,f[m][i]);
		printf("%d\n",ans);
	}
	return 0;
}

Wilcze doły

提示

用单调队列维护 \(\max\{w_{i-d}\sim w_i\}\) 。需要思考明白以 \(i\) 结尾的序列的左端点不可能比以 \(i-1\) 结尾的序列的左端点更靠左这件事。

Code
#include<cstdio>
#define int long long
const int maxn=2e6+5;
template<class T>
class deque{/*略*/};
deque<int>q;
int n,p,d,lst,ans;
int w[maxn],s[maxn];
void read(int&x){
	x=0;
	bool f=0;
	char ch=getchar();
	while(ch<'0'||ch>'9'){
		if(ch=='-')f=1;
		ch=getchar();
	}
	while(ch>='0'&&ch<='9'){
		x=x*10+(ch^48);
		ch=getchar();
	}
	if(f)x=-x;
	return;
}
int max(int x,int y){return x>y?x:y;}
signed main(){
	read(n);read(p);read(d);
	for(int i=1;i<=n;++i){
		read(w[i]);
		w[i]+=w[i-1];
	}
	for(int i=d;i<=n;++i)
		s[i]=w[i]-w[i-d];
	ans=d;
	lst=1;//区间起点,具有单调性
	//使用单调队列维护 [i-d,i] 的和的最大值
	q.push(d);
	for(int i=d+1;i<=n;++i){
		while(s[i]>s[q.back()]&&q.size())
			q.pop_back();
		q.push(i);
		while(q.front()-d+1<lst&&q.size())
			q.pop_front();
		//sum[lst,i]-sum[j-d,j]如果大于p
		//也就是说这一段不符合条件,弹出。 
		while(w[i]-w[lst-1]-s[q.front()]>p&&q.size()){
			lst++;
			while(q.front()-d+1<lst&&q.size())
				q.pop_front();
		}
		ans=max(ans,i-lst+1);
	}
	printf("%lld",ans);
	return 0;
}

end.

posted @ 2021-02-06 23:59  XSC062  阅读(250)  评论(0编辑  收藏  举报