单调队列+决策单调性dp学习笔记

What Is Monotonic Queue

单调队列是一种特殊的队列数据结构,用于维护一定的单调性,通常是单调递增或单调递减。

单调队列的主要特点是,队列中的元素满足特定的单调性要求,使得队列的头部元素(或者尾部元素,取决于具体问题)始终是当前队列中的最大(或最小)值。这种特性使得单调队列可以高效地处理一些需要在不断变化的窗口或序列中找到最大(或最小)值的问题。

Monotonic Queue Can Do What

单调队列在解决一些与窗口和单调性有关的问题时非常有用。以下是一些可以使用单调队列解决的常见问题:

  1. 滑动窗口最大/最小值: 给定一个数组和一个固定大小的窗口,需要在窗口在数组上滑动的过程中,快速找到每个窗口的最大或最小值。

  2. 连续子数组的平均值大于阈值的个数: 给定一个数组和一个阈值,找到所有长度为固定值的连续子数组,使得子数组的平均值大于给定的阈值。

  3. 下一个更大元素: 给定一个数组,对于每个元素,找到在其右边第一个比它大的元素。

这些问题在实际应用中非常常见,而单调队列可以帮助有效解决这些问题,因为它们可以在\(O(n)\)的时间复杂度内进行操作,而不是传统的\(O(n^{2})\)解法。通过维护单调性,单调队列可以在滑动窗口或者数组遍历的过程中高效地找到满足特定条件的元素。

How To Solve These Question

1.滑动窗口最大/最小值

例题:- P1886 滑动窗口 /【模板】单调队列

这题之前使用线段树做的,但是\(nlogn>n\),1.55s>1.33s。用单调队列整整快了0.22s也不是很多

当使用单调队列来解决滑动窗口最大/最小值问题时,需要维护一个滑动窗口,以及一个单调递减队列。单调队列用于存储当前窗口内的元素的下标(因为要保持单调队列元素在窗口内),使得队列的头部始终是窗口内的最值元素。以下是解决滑动窗口最大值问题的方法:

假设有一个数组 \(arr\) 和一个窗口大小 k,我们需要找到每个窗口的最大值。

创建一个单调递减队列 deque,用于存储元素在窗口中的下标。

遍历数组,对于每个元素 \(i\) 进行以下操作:

在每个新元素要加入窗口时,从队列尾部开始,将所有比新元素小的元素下标出队,以保持队列的单调递减性质。
将当前元素下标入队到队列尾部。
对于每个窗口的起始位置,计算窗口内的最大值并记录。

#include<bits/stdc++.h>
using namespace std;
long long n,k,a[1000005];
deque<int>q;//双端队列
int main(){
	cin>>n>>k;
	for(long long i=1;i<=n;i++){
		cin>>a[i];
	}
	for(int i=1;i<=n;i++){
		while(!q.empty()&&q.front()<i-k+1){//保证元素在窗口内
            q.pop_front();
        }
        while(!q.empty()&&a[i]<=a[q.back()]){//保证队列内单调递增(队首为区间最小值)
            q.pop_back();
        }
        q.push_back(i);
		if(i>=k) cout<<a[q.front()]<<" ";
	}
	cout<<endl;
	while(!q.empty()){//同上
		q.pop_front();
	}
	for(int i=1;i<=n;i++){
		while(!q.empty()&&q.front()<i-k+1){
            q.pop_front();
        }
        while(!q.empty()&&a[i]>=a[q.back()]) {
            q.pop_back();
        }
        q.push_back(i);
		if(i>=k) cout<<a[q.front()]<<" ";
	}
	
	return 0;
}

2.连续子数组的平均值大于阈值的个数

Leetcode 1343

本人没有leetcode账号qwq,只能看题面了

因为滑动窗口长度为\(k\),所以用双端队列即可

#include<bits/stdc++.h>
using namespace std;
int n,a[1000005],k,t,sum=0,ans=0;
deque<int>q;
int main(){
	cin>>n>>k>>t;
	t=k*t;//平均值>=t -> 子数组数的和>=k*t
	for(int i=1;i<=n;i++){
		cin>>a[i];
	}
	for(int i=1;i<=n;i++){
		while(!q.empty()&&q.front()<i-k+1){//超出范围就pop
			sum-=a[q.front()];
			q.pop_front();
		}
		q.push_back(i);
		sum+=a[i];
		if(sum>=t) ans++;//看看可不可以
	}
	cout<<ans<<endl;

	return 0;
}

3.下一个更大元素

例题: P1901 发射站

XJOI上 $ n^{\smash{2}} $ 过百万

假设我们有一个数组 arr,我们要为每个元素找到在其右边第一个比它大的元素。我们可以使用一个单调递减队列来解决这个问题。队列中的元素按照从大到小的顺序排列,当我们遍历数组时,如果当前元素大于队列末尾的元素,那么我们就可以得到队列末尾元素的下一个更大元素。

1.创建一个空的deque

遍历数组,对于每个元素执行以下操作:

如果队列不为空,并且当前元素大于等于队列末尾的元素,那么说明队列末尾的元素找到了下一个更大元素。我们将队列末尾的元素弹出,并将其下标与当前元素建立映射,表示下一个更大元素的位置。
将当前元素的下标入队到队列中,以便稍后可以根据下标获取元素。
遍历完成后,队列中剩余的元素表示没有找到下一个更大元素的位置,可以标记为-1或数组长度。

#include<bits/stdc++.h>
using namespace std;
long long n,h[1000005],v[1000005],ans[1000005];
void getmax() {
    stack<long long>stk;//单调栈
    for (long long i=1;i<=n;i++){//对于每一个i进行操作
        while(!stk.empty()){
			if(h[i]<=h[stk.top()]) break;//不符合要求
            long long j=stk.top();
            if(!stk.empty()){
                ans[i]+=v[j];//h[j]<h[i] -> j向i发射
            }
			stk.pop();
        }
        if(!stk.empty()) ans[stk.top()]+=v[i];//没有别的高就是i向stk.top()发射
        stk.push(i);
    }
}
int main(){
    cin>>n;
    for(long long i=1;i<=n;i++){
    	cin>>h[i]>>v[i];
	}
	getmax();
    long long maxx=-1;
    for(long long i=1;i<=n;i++){
    	maxx=max(maxx,ans[i]);
	}
	cout<<maxx<<endl;

    return 0;
}

4.决策单调性dp

单调队列

例题:最大子串和

很简单想到一个\(O(n \log n)\)类似线段树的做法(学ds学的)
然而这个显然可以先求一个前缀和,然后对于每一位在前面找到一个最小的前缀和,然后取\(max\)即可,这是\(O(n)\)
但如果子串长度不能超过呢?在\([r-m+1,r]\)\(min\)有不有让你想起什么?滑动窗口,没错,就是单调队列,然后就很简单了

q.push_back(0); //因为是前缀和,可以减去s[0]
for(int i=1;i<=n;i++){
	while((!q.empty())&&(i-q.front()>m)) q.pop_front();//因为是前缀和,所以是(i-q.front()>m)
	ans=max(ans,s[i]-s[q.front()]);
	while((!q.empty())&&(s[q.back()]>=s[i])) q.pop_back(); 
	q.push_back(i);
} 

POJ 1821

以后看到可以用dp做的题目就硬着头皮做下去,一般都是可以靠优化通过的。
一开始的dp方程(要按照这个规范写dp)

for(int i=1;i<=m;i++){
    for(int j=0;j<=n+1;j++) dp[i][j]=dp[i-1][j]; 
    for(int r=a[i].s;r<=min(n,a[i].s+a[i].l-1);r++){
        for(int l=max(1,r-a[i].l+1)-1;l<=a[i].s-1;l++){
          	dp[i][r]=max(dp[i][r],dp[i-1][l]+a[i].p*(r-l+1)); 
	}
    }
}

然后发现最内层循环是一段范围,容易想到线段树

for(int i=1;i<=m;i++){
	build(0,n,1,i);
    for(int j=0;j<=n+1;j++) dp[i][j]=dp[i-1][j]; 
    for(int r=a[i].s;r<=min(n,a[i].s+a[i].l-1);r++){
        dp[i][r]=max(dp[i][r],getmax(0,n,1,max(1,r-a[i].l+1)-1,a[i].s-1)+a[i].p*r);
    }
}

然后就可以了
最后放下单调队列,感觉单调队列可以被一些多一只log的ds给搞掉,这些带log的ds我还是比较熟练的。但单调队列也不是不会,放在这边吧(有点大

非常好的单调队列优化多重背包

总结:
\(F[i]=min/max{c(i)+d(j)+K}\) 是可用单调队列优化的基本条件,c(i)关于i的多项式,d(j)同理,K常数
斜率优化是单调队列优化的推广
我们知道,有些DP方程可以转化成\(DP[i]=f[j]+x[i]\)的形式,其中\(f[j]\)中保存了只与j相关的量。这样的DP方程我们可以用单调队列进行优化,从而使得\(O(n^{2})\)的复杂度降到\(O(n)\)
具体来说就是枚举i,然后使用单调队列找到i的最优决策点j,然后就可以了。
那我们要如何处理我们的dp式子呢。斜率优化和单调队列都是处理min/max的,所以我们要枚举i,将式子全部拆开,整理成\(F[i]=min/max{c(i)+d(j)+K}\)再把里面常数拎出来F[i]={(单调队列)d(j)}+c(i)+K的样子。

斜率优化

如果\(F[i]=min{c(i)+d(j)+e(i,j)+K}\),如何进行优化?
邦邦的ppt还是烂了
从POJ1180任务安排的解法二开始

for(int i=2;i<=n;i++){
    for(int j=1;j<i;j++){
        dp[i]=min(dp[i],dp[j]+sumt[i]*(sumw[i]-sumw[j])+s*(sumw[n]-sumw[j]));
    }
}

按照上面单调队列的方式优化
\(dp[i]=min(dp[j]+sumt[i]*sumw[i]-sumt[i]*sumw[j]+s*sumw[n]-s*sumw[j]);\)//拆括号
\(dp[i]=min(dp[j]-sumw[j]*(sumt[i]+s))+sumt[i]*sumw[i]+s*sumw[n]\)//常数拎出
\(dp[j]=dp[i]+sumw[j]*(sumt[i]+s)-sumt[i]*sumw[i]-s*sumw[n]\)//拆掉min (与j相关的项放左边)
不会了吧,小baby~
看ppt,非常清楚(我原来的理解全部假了,很搞笑)
然后因为斜率是\((sumt[i]+s)\)所以一定是从左往右做单调队列
具体实现这篇题解也很清楚

而很多题解没讲到的一点是为了去除\(/\)号,都选择了移项。而因为\(sumw[i]\)单增,直接移项即可

放下我自己的理解吧,因为我的每一个点都是\((sumw[j],dp[j])\)而dp[j]和sumw[j]都是单增的,而\((s+sumt[j])\)也是单增的,因为我们维护的是一个凹包,所以大概就是这样子了

因为我们要找切线,而斜率\((s+sumt[i])\)是单增的,所以就从队尾删即可。而我们要维护凹包,所以从队头删,比如这个E点。
看加强版
破防了,在P5785 [SDOI2012] 任务安排中,\(t_{i}\)有可能是负的,所以斜率\((s+sumt[i])\)不确定。所以我们就要在凹包上二分。(不二分还是有60分

所以当我们遇到\(0=a(i)+b(j)+c(i,j)+K\)
要处理成\(b(j)=c(i,j)+a(i)\)(k扔到外面了)

比如摆渡车。
容易写出dp方程

for(long long i=0;i<=maxx+m+1;i++){
	dp[i]=i*sum[i]-sumt[sum[i]];
}
for(long long i=1;i<=maxx+m+1;i++){
	for(long long j=0;j<=i-m;j++){
		dp[i]=min(dp[i],dp[j]+(sum[i]-sum[j])*i-(sumt[sum[i]]-sumt[sum[j]]));
	}
}

然后拆开
\(dp[i]=min(dp[i],dp[j]+sum[i]*i-sum[j]*i-sumt[sum[i]]+sumt[sum[j]])\)(拆括号)
\(dp[i]=dp[j]+sum[i]*i-sum[j]*i-sumt[sum[i]]+sumt[sum[j]]\)(拆掉min)
\(dp[i]=dp[j]-sum[j]*i+sumt[sum[j]]\)(丢掉常数)
\(dp[j]=sum[j]*i-sumt[j]+dp[i]\)整理成一般式
然后注意在斜率优化时,我们常常会移项,这时要考虑正负性(最好是分讨一下)

double getk(int i,int j){
	if(sum[i]-sum[j]==0) return (double)(dp[i]+sumt[i]-dp[j]-sumt[j])*1e9;<-------这一行要格外注意。因为两个除以0的也要比较
	return (double)(dp[i]+sumt[i]-dp[j]-sumt[j])*1.0/(double)(sum[i]-sum[j]);
}
for(long long i=0;i<=maxx+m-1;i++){
	if(i>=m){//如果第二层循环有范围的话就要这样写
		while((r>l)&&(getk(q[r],q[r-1])>=getk(i-m,q[r-1]))) r--;
		r++;
		q[r]=i-m;
	}
	while((r>l)&&(getk(q[l+1],q[l])<=i)) l++;
	if(l<=r) dp[i]=min(dp[i],dp[q[l]]+(sum[i]-sum[q[l]])*i-(sumt[i]-sumt[q[l]]));
}
posted @ 2024-02-27 09:34  wuhupai  阅读(14)  评论(0编辑  收藏  举报