【总结】单调队列优化DP


待填的坑越来越多惹 QwQ

双端队列

前置芝士

队列(queue

正文

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

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

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

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

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

template
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]],也就是 a[q.first()]

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

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

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

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

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

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

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

Code

#include
const int maxn=2e6+5;
int n,m;
int a[maxn];
template
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;
		}
};
dequeq;
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
const int maxn=1e6+5;
template
class deque{/*略*/};
int n,m;
dequeq1,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[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,那么 \(j\sim i\) 就是以 \(i\) 结尾的和最大的连续子序列。

是不是有点 DP 味儿了?

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

Code

#include
const int maxn=2e5+5;
const int inf=0x3f3f3f3f;
template
class deque{/*略*/};
dequeq;
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
#define int long long
const int maxn=2e6+5;
template
class deque{/*略*/};
int n,nn;
dequeq,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]n;--i){
		sum2[i]=sum2[i+1]+a2[i];
		while(sum2[i]=0)
			vis[i]=1;
		while(sum[i+n]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]

<法二>(正解)

#include
#define int long long
const int maxn=2e6+5;
template
class deque{/*略*/};
int n,nn;
dequeq;
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

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

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

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

Code

#include
#define int long long
const int maxn=1e5+5;
template
class deque{/*略*/};
int n,k;
dequeq;
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
const int maxn=5e4+5;
template
class deque{/*略*/};
dequeq;
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>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
#include
using std::sort;
const int inf=0x3f3f3f3f;
template
class deque{/*略*/};
struct ball{
	int val,pos;
	bool operator<(const ball q)const{
		return posq;
int f[55][1005];
int T,n,m,x,ans,i,j,k;//不卡常过不了
int min(int x,int y){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=a[i][j].pos){
					int tmp=f[i-1][k]+a[i-1][k].pos;
					while(q.size()&&tmp

Wilcze do?y

提示

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

Code
#include
#define int long long
const int maxn=2e6+5;
template
class deque{/*略*/};
dequeq;
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+1p&&q.size()){
			lst++;
			while(q.front()-d+1

end.