//https://img2018.cnblogs.com/blog/1646268/201908/1646268-20190806114008215-138720377.jpg

单调队列优化DP详解

单调队列优化DP

单调栈和单调队列都是借助单调性,及时排除不可能的决策,保持候选集合的高度有效性和秩序性。单调队列尤其适合优化决策取值范围的上、下界均单调变化,每个决策在候选集合中插入或删除至多一侧的问题。

利用单调队列,我们可以舍去许多无用的状态,来更快的找出最优解。

一般用单调队列维护的都是根据题目而定,比如求 XX 最小值,那么我们就要维护一个单调递增的队列,因为这样队头的元素是始终最小的;反之求 XX 最大值,我们就要维护一个单调递减的队列,因为这样队头元素始终是最大的。

其中最为经典的就是滑动窗口问题,实际上后面的大多数优化的模板都是转化为一个滑动窗口的模型。

我们写一下伪代码:

输入数据
枚举我们的状态
{
	弹出不在窗口,也就是不合法的答案
	更新f的值
	维护队列单调性,不停弹出队尾元素直到合法
	当前状态入列
}

持续更新题目中,约为三天。

一般の例题

最大子序和

显然最朴素的方法是直接暴力,但是 \(3\times 10^{5}\) 的数据范围是不会让我们 AC 的。

我们考虑利用单调队列来优化一下复杂度。

首先我们看到求连续的一段的和,所以我们先预处理出前缀和。

首先我们知道如果当前遍历到的元素的下标减去队列头的元素是大于 \(m\) 的话,这个是不合法的,所以我们每到了一个点都要先把这些不合法的都给弹出去,然后在进行求解。

我们维护一个单调递增的队列,这样我们的头部元素保证是这个区域内最优的一块,然后我们统计答案直接用当前点的前缀和减去头部第一个元素的下标对应的前缀和就好了。

code:

#include<bits/stdc++.h>
#define INF 0x3f3f3f3f
#define int long long
#define N 1000100
using namespace std;
int n,m,a[N],sum[N],q[N],h,t,ans=-INF;
signed main()
{
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
		sum[i]=sum[i-1]+a[i];
	}
	for(int i=1;i<=n;i++)
	{
		while(h<=t&&i-q[h]>m)h++;
		ans=max(ans,sum[i]-sum[q[h]]);
		while(h<=t&&sum[q[t]]>=sum[i])t--;
		q[++t]=i;
	}
	cout<<ans<<endl;
	return 0;
}

围栏

我们设 \(f[i][j]\) 表示前 \(i\) 个人粉刷前 \(j\) 块木板的最大花费。

我们对于每一个人,枚举每一块木板,我们都有以下三种情况:

  1. 当前人不粉刷,f[i][j]=max(f[i-1][j],f[i-1][j])

  2. 当前人粉刷,但是不粉刷当前木板 f[i][j]=max(f[i][j-1],f[i][j])

  3. 当前人粉刷且粉刷当前木板、

第三种情况又分为两种情况。

  1. 当前的 \(j<s_{i}\),只能做左端点,因为必须包含 \(s_{i}\),所以这个时候,我们维护一个以队列里的点为起点时花费最大的花费单调递增的队列,也就是说,我们队列里面是左端点,但是我们里面单调递增的,是未计算当前粉刷的区间时最大的花费。

  2. 当前点 \(j>=s_{i}\),只能做右端点,这个时候我们判断一下队列里的元素是否和当前点的最大长度不超过 \(l\),然后我们进行计算,设我们从里面取出的左端点为 \(k\),则 f[i][j]=max(f[i][j],f[i-1][k]+(j-k)*p_{i})

然后就得出答案了。

code:

#include<bits/stdc++.h>
#define N 100010
#define M 110
using namespace std;
int n,m,q[N],f[M][N];
struct sb{int l,p,s;}e[N];
inline int cmp(sb a,sb b){return a.s<b.s;}
signed main()
{
	cin>>n>>m;
	for(int i=1;i<=m;i++)cin>>e[i].l>>e[i].p>>e[i].s;
	sort(e+1,e+m+1,cmp);//按必须包含的木板s来从小到大排序 
	for(int i=1;i<=m;i++)//枚举每一个工匠 
	{
		int h=0,t=0;//头尾指针 
		for(int j=0;j<=n;j++)
		{
			f[i][j]=f[i-1][j];//当前工人不用 
			if(j)f[i][j]=max(f[i][j],f[i][j-1]);//当前木板不粉刷 
			int l=e[i].l,p=e[i].p,s=e[i].s;//区间最大长度,单位花费,必须包含的木板 
			if(h<=t&&q[h]<j-l)h++;//如果要是队列里最靠左的元素在当前的区间左边,我们直接弹出 
			if(j>=s&&h<=t)//如果要是当前点大于s且队列不空,那j就只能是区间右端点
			{
				int k=q[h];//取出队头元素
				f[i][j]=max(f[i][j],f[i-1][k]+p*(j-k));//取max
			}
			if(j<s)//如果要是当前点不包含s就只能做左端点 
			{
				while(h<=t&&f[i-1][q[t]]-q[t]*p<=f[i-1][j]-j*p)t--;//如果以当前的尾部元素为起点的话不如以当前点为起点更优就弹出,维护队列内的起点的价值单调递增 
				q[++t]=j;//入列 
			}
		}
	}
	cout<<f[m][n]<<endl;//输出m个工匠粉刷n个木板的最大收益 
	return 0;
}

P1725 琪露诺

我们观察题目,发现如果要是从一点点转移到一个区间是不好转移的,所以我们转化一下问题,变为当前点由那些点转移过来,这样我们就可以由已经处理好的问题来处理当前的问题了。

我们手模一下可以发现,点 \(i\) 只能由 \([i-R,i-L]\) 转移而来,所以我们直接用一个单调队列来维护里面的 \(f[k]\) 单调递减,也就是说,我们每次到了点 \(i\) 都把当前最大能转移到 \(i\) 的点给放入队列,然后维护一个单调递减的队列,这样让我们的头部元素始终是最优的,我们就可以通过头部元素转移到当前点;当然我们也要判断是否在这个区间内,把不合法的先弹出去。

code:

#include<bits/stdc++.h>
#define INF 0x3f3f3f3f
#define int long long
#define N 1000100
using namespace std;
int n,L,R,a[N],f[N],q[N],h,t=-1,ans=-INF;
signed main()
{
	cin>>n>>L>>R;
	for(int i=0;i<=n;i++)cin>>a[i];
	memset(f,-127,sizeof f);
	f[0]=0;
	for(int i=L;i<=n;i++)
	{
		while(h<=t&&f[q[t]]<=f[i-L])t--;
		q[++t]=i-L;
		while(h<=t&&q[h]<i-R)h++;
		f[i]=f[q[h]]+a[i];
//		for(int j=h;j<=t;j++)cout<<f[q[j]]<<" ";cout<<endl;
		if(i>n-R)ans=max(ans,f[i]);
	}
	cout<<ans<<endl;
	return 0;
}

1599:【 例 3】修剪草坪

我们把问题转化一下,转化成 \(k+1\) 个牛里面必须有一个不选的。

我们设 \(f[i]\) 表示到了第 \(i\) 头牛的不选的牛且第 \(i\) 头牛不选的最小效率,我们发现,在 \(i>k+1\) 的时候我们就可以开始统计答案了,因为这里面的牛肯定都是合法的方案了,因为都是最后一块的牛。

里面在判断是否合法的时候我们是用的 q[h]<i-m,因为我们用这个来判断的是是否合法,因为第 \(i\) 头牛不选,所以 \(i-m\)\(i\) 的牛必须有一个不选的,所以再往前是不合法的。

code:

#include<bits/stdc++.h>
#define INF LONG_LONG_MAX
#define int long long
#define N 1000100
using namespace std;
int n,m,a[N],sum,q[N],f[N],h,t,minn=INF;
signed main()
{
	cin>>n>>m;
	m++;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
		sum+=a[i];
	}
	for(int i=1;i<=n;i++)
	{
		while(h<=t&&f[q[t]]>=f[i-1])t--;
		q[++t]=i-1;
		while(h<=t&&q[h]<i-m)h++;
		f[i]=f[q[h]]+a[i];
		if(i>n-m)minn=min(minn,f[i]);
	}
	cout<<(sum-minn)<<endl;
	return 0;
}

1600:【例 4】旅行问题

我们手模一下可以发现:将 \(p\) 减去 \(d\) 之后按照顺时针或者逆时针统计前缀和,如果中间出现了负数则无法到达,反之可以到达。

我们先破环为链,然后把数组复制一倍,然后统计前缀和开始遍历。

我们在里面维护一个单调递增的队列,这样开头元素一定是其中前缀和里最小的元素,这样我们就可以判断是否出现了小于 \(0\) 的数。

然后我们重新赋值逆时针再来一遍就 ok 了,复杂度为 \(O(n)\),实际要大于这个值因为循环有点多,其实大约 \(10n\) 吧。

code:

#include<bits/stdc++.h>
#define int long long
#define N 1000100
#define endl '\n'
using namespace std;
int n,s[N<<1],ans[N],h,t,p[N],d[N],a[N<<1],q[N];
signed main()
{
	cin>>n;
	for(int i=1;i<=n;i++)cin>>p[i]>>d[i];
	d[0]=d[n];
	for(int i=1;i<=n;i++)a[i]=a[i+n]=p[i]-d[i];
	for(int i=1;i<(n<<1);i++)s[i]=s[i-1]+a[i];
	h=1;t=0;
	for(int i=1;i<(n<<1);i++)
	{
		while(h<=t&&s[q[t]]>=s[i])t--;//维护一个单调递增的队列,保证队头元素最小 
		q[++t]=i;
		while(h<=t&&q[h]<=i-n)h++;//去除不在此区间内的点 
		if(i>=n&&s[q[h]]>=s[i-n])ans[i-n+1]=1;//i大于n开始统计答案,如果此区间内的最小值是大于当前点的值,那么所有路程中都是合法的 
	}
	for(int i=1;i<=n;i++)a[i]=a[i+n]=p[n-i+1]-d[n-i];//处理后缀和,就是倒过来再处理一遍 
	for(int i=1;i<(n<<1);i++)s[i]=s[i-1]+a[i];
	h=1;t=0;
	for(int i=1;i<(n<<1);i++)
	{
		while(h<=t&&s[q[t]]>=s[i])t--;
		q[++t]=i;
		while(h<=t&&q[h]<=i-n)h++;
		if(i>=n&&s[q[h]]>=s[i-n])ans[(n<<1)-i]=1;//注意存答案的是反着来的 
	}
	for(int i=1;i<=n;i++)//遍历一遍输出答案 
	{
		if(ans[i])cout<<"TAK"<<endl;
		else cout<<"NIE"<<endl;
	}
	return 0;
}

单调队列优化多重背包

我之前是没有学过二进制的,我看这个的复杂度更为优秀于是直接来学了这个,复杂度为 \(O(nm)\),一般可以比这个要大一些。

我们都知道朴素的多重背包,就是在 01 背包的基础上,多了一层循环来枚举我们的当前物品的个数,复杂度上界是 \(O(nms)\) 的,非常的慢,所以我们有了二进制优化,但二进制优化有时候也不是很优,就有了单调队列优化。

我们在循环的时候,我们会发现,我们中间其实是枚举了很多没有用的状态,为什么呢?因为我们在枚举的时候会发现,其实我们的当前状态能从什么状态转移过来呢?是和当前状态与 \(v\) 同余的状态转移而来,所以我们就考虑维护一个窗口,来用单调队列优化。

image

图片来自:https://www.bilibili.com/video/BV1354y1C7SF/?spm_id_from=333.337.search-card.all.click

从上面的可以明显看出我们当前的 \(f[i]\) 都是由之前的相差 \(v\) 的倍数的 \(f\) 数组转移过来的,他们模 \(v\) 的余数是相同的,所以我们就可以根据这一点来用单调队列优化了。

我们写一下伪代码:


枚举物品种类个数
{
	复制一遍f数组到g数组 
	输入体积v,价值w,个数s
	枚举每一个模v的余数
	{
		枚举当前体积,从余数开始每次加v
		{
			超出个数限制的弹出
			更新当前的f[]数组
			维护队列内的元素递减
			当前状态入列 
		} 
	}
}

其实这个大部分都是一样的。

P1776 宝物筛选

因为能力有限所以只有一道模板题。

code:

#include<bits/stdc++.h>
#define int long long
#define N 40100
using namespace std;
int n,W,ans,tmp,g[N],f[N],q[N],num[N];
signed main()
{
    cin>>n>>W;
    for(int i=1;i<=n;i++)
	{
		memcpy(f,g,sizeof g);
        int v,w,m;
        cin>>w>>v>>m;
        if(v==0){ans+=m*w;continue;} 
        for(int j=0;j<v;j++)
		{
            int h=1,t=0;
            for(int k=j;k<=W;k+=v)
			{
                while(h<=t&&q[h]<k-m*v)h++;
                while(h<=t&&f[k]>=f[q[t]]+(k-q[t])/v*w)t--;
                q[++t]=k;
                g[k]=max(f[k],f[q[h]]+(k-q[h])/v*w);
            }
        }
    }
    cout<<(g[W]+ans)<<endl;
    return 0;
}

滚动数组优化(虽然省了 memcpy 但是好像没快多少):

#include<bits/stdc++.h>
#define int long long
#define N 100010
using namespace std;
int n,m,v,w,s,q[N],f[2][N],ans;
signed main()
{
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>w>>v>>s;
		if(v==0){ans+=s*w;continue;}
		for(int j=0;j<v;j++)
		{
			int h=1,t=0;
			for(int k=j;k<=m;k+=v)
			{
				int c=(i-1)&1;
				while(h<=t&&q[h]<k-s*v)h++;
				while(h<=t&&f[c][k]>=f[c][q[t]]+(k-q[t])/v*w)t--;
				q[++t]=k;
				f[i&1][k]=f[c][q[h]]+(k-q[h])/v*w;
			}
		}
	}
	ans+=f[n&1][m];
	cout<<ans<<endl;
	return 0;
}

结合其他的思想

有的时候题目不满足于让我们用单调队列优化,开始结合其他的算法考(e)察(xin)我们。

P3957 [NOIP2017 普及组] 跳房子

要求达到一定得分但又要求花费最少,一眼二分,写个 bfs 加二分!

T 啦!

我用 DP!

又 T 啦!

nnd 这个破题还 tm 要单调队列优化。

我们在二分的 check 函数里面 DP 的时候,我们就考虑如何用单调队列优化。

设从起点到点 \(i\) 的距离为 \(b[i]\)

我们可以发现,当前点只能从 \([b[i]-d-g,b[i]-d+g]\) 转移过来,所以我们以此为滑动的窗口。

我们在入列一个元素的时候,我们不是仅用 \(i\) 了,而是开了一个新的变量 \(R\),来表示当前入列的元素到了哪里,只要没超过 \(i\) 并且符合当前的最少跳多少个距离的限制,那我们就开始入列他。

因为求的是最大值,所以我们直接维护一个得分单调递减的队列,保证队列里面的元素队头是窗口内最大的。

然后就是跟其他的单调队列优化一样的操作了。

code:

#include<bits/stdc++.h>
#define INF LONG_LONG_MAX
#define int long long
using namespace std;
const int N=1000100;
int n,d,m,f[N],a[N],b[N],q[N],sum;
inline int check(int g)
{
	memset(f,-0x3f,sizeof f);//每次都要清空一下 
	int h=0,t=-1,R=0;f[0]=0;//得0分不需要走格子 
	for(int i=1;i<=n;i++)//枚举每一个格子 
	{
		while(R<i&&b[R]<=b[i]-d+g)//R不能超过i,限制最少跳的距离为1,后面限制最少跳(g-d) 
		{
			while(h<=t&&f[q[t]]<=f[R])t--;//只要不合法就弹出 
			q[++t]=R;//入列 
			R++;//加一进入下一次循环 
		}
		while(h<=t&&(b[q[h]]<b[i]-d-g||b[q[h]]>b[i]-d+g))h++;//把不合法的队头元素弹出 
		if(h<=t)f[i]=f[q[h]]+a[i];//计算当前点最大得分 
		if(f[i]>=m)return 1;
	}
	return 0; 
}
signed main()
{
	cin>>n>>d>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>b[i]>>a[i];
		if(a[i]>0)sum+=a[i];//计算所有正数的和 
	}
	if(sum<m){puts("-1");return 0;}//如果所有正数加起来都不到m那就无解 
	int l=0,r=b[n];//左右边界 
	while(l<r)//贰分 
	{
		int mid=(l+r)>>1;
		if(check(mid))r=mid;
		else l=mid+1;
	}
	cout<<r<<endl;//输出答案 
	return 0;
}

有没搞懂的欢迎私信。

posted @ 2023-06-16 09:07  北烛青澜  阅读(228)  评论(0编辑  收藏  举报