单调队列优化dp

背景——引入单调队列

       滑动窗口

分析   

    对于这样一个问题,我们采用单调队列来解决。那么什么是单调队列。

如其名就是具有单调性的队列。维护这样一个队列的好处是,当我们想求最大值的时候,我们只需要保证队列是单调递增的,于是每次取出队头就是我们的最大值。比起直接遍历,极大提高了效率。接下来我们以滑动窗口这个例子来一起了解一下具体怎么实现。

         首先我们定义一个数组q[i]用来存储元素的下标。之后第一步就是把第一个元素的下标存储进去,然后依次存储下一个元素的下标。这里我们先以滑动窗口的最小值为例子往下讲解。                        第一个元素下标进队列,此时队列为\left \{ 0 \right \},因为储存的是下标。对应的值为\left \{ 1\right \}                                  接下来第二个元素,为3,比队列中的上一个元素大,能维持从尾到头单调递减,入队              于是队列为\left \{ 0,1 \right \},对应的值为\left \{ 1,3 \right \}

        然后第三个元素,为-1,比上一个元素小,由于要维持单调递减,于是上一个元素就得出队         于是就变成了\left \{ 0 \right \},其对应的值也就变成了\left \{ 1 \right \},但是现在-1还不能着急入队,因为此时进去还是        不能满足单调性,于是我们需要把1(元素的值)出队,于是变成了\left \{ \right \},满足条件,入队,其列          应该变为\left \{ 2 \right \},对应的值为\left \{ -1\right \},此时由于滑动窗口已经有三个元素了,于是我们需要输出队        头元素-1

        接下来第四个元素,为-3,根据上述流程,-1>-3,为了维持单调性,-1出队,然后再           入队,于是队列为\left \{ 3 \right \},队中元素为\left \{ -3 \right \},同时我们需要输出-3

        后续依次类推即可。

细节处理        

           需要注意的是,我们需要将这个单调队列的大小维护在k这个大小,因为这个滑动窗口是不断移动的。如下图,

        当我们滑动窗口的区间为数字[3,5]之间,根据上述单调性入队出队规则,队列中的元素应该为\left \{ 1,2,3 \right \},对应的值应该为\left \{ 3,4,5 \right \},接下来我们考虑滑动窗口后移动

        根据上述规则,此时6应该入队,此时队列应该变为\left \{ 1,2,3,4 \right \},对应的值为\left \{ 3,4,5,6 \right \},输出队头元素对应的值3,是不是不太对了,3已经划走了,怎么还会输出3呢?

        他都已经划走了,那就不关他的事了,将他从队头丢出去,所以我们这个队列最多维护k=3个元素。此时队列变为\left \{ 2,3,4 \right \},对应的值为\left \{ 4,5,6 \right \},输出队首4,正确!

代码

#include<iostream>
#include<algorithm>
using namespace std;
const int N = 1e6 + 10, M = 100010;
int a[N], q[M];
int n, k;
int main()
{
    scanf("%d%d", &n, &k);
    for (int i = 0; i < n; i++)
        scanf("%d",&a[i]);
    int hh = 0, tt = 0;
    for (int i = 0; i < n; i++)
    {
        while (hh <= tt && i - k + 1 > q[hh])
            hh++;
        while (hh <= tt && a[i] < a[q[tt]])
            tt--;
        q[++tt] = i;
        if (i >= k - 1)
            cout << a[q[hh]] << " ";
    }
    puts(" ");
    //最大值即再维护依次一次单调性即可
    hh = 0, tt = 0;
    for (int i = 0; i < n; i++)
    {
        while (hh <= tt && i - k + 1 > q[hh])
            hh++;
        while (hh <= tt && a[i] > a[q[tt]])
            tt--;
        q[++tt] = i;
        if (i >= k - 1)
            cout << a[q[hh]] << " ";
    }
}

单调队列优化dp

     对于一个优化问题,我们首先可以先想一下他的暴力做法,然后在看一下是否可以优化。

     对于单调队列优化的 dp 问题,一般满足在一个可动区间求最大值或者最小值。如果暴力枚举那个区间,一般会超时,于是我们可以选择将最大值放入队头的最大值或者最小值,通过这样维护我们可以减去暴力枚举的过程,优化了时间复杂度。接下来我们看来两个例题来熟悉一下。

   琪露诺

分析

定义状态 f[i] 表示从 i 这个点开始往后跳获得的最大冰冻指数

f[i]=max(f[j])+a[i]   此时i+l<=j<=i+r ;

f[i+1]=max(f[j])+a[i+1] ,此时a+l+1<=j<=i+r+1

我们先想到暴力的做法,就是枚举 j 的取值范围,依次记录下最大值,但是对于这样一个滑动区间的最大值,

由此可以利用单调队列的优化,对于这样的问题我们可以把我们最大的元素放在队头

 代码
//定义状态f[i]表示从i这个点开始往后跳获得的最大冰冻指数
//f[i]=max(f[j])+a[i];此时i+l<=j<=i+r;
//f[i+1]=max(f[j])+a[i],此时a+l+1<=j<=i+r+1
//由此可以利用单调队列的优化,我们储存最大的元素放在队头
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=2e5+10;
int a[N],q[N],f[N];
int hh,tt,n,r,l,ans=-1e9;

int main()
{
	cin>>n>>l>>r;
	for(int i=0;i<=n;i++)
		cin>>a[i];
	memset(f,0xcf,sizeof f);
	f[0]=0;
	hh=0,tt=0;
	for(int i=0;i<=n-l;i++)
	{
		while(q[hh]<=i-(r-l+1)&&hh<=tt)//维护滑动窗口
			hh++;
		while(hh<=tt&&f[q[tt]]<f[i])//队列末尾的元素小于待插入的元素,那么他就没有存在的意义了
			tt--;//让其出队
		q[++tt]=i;//将下标入队
		
		f[i+l]=f[q[hh]]+a[i+l];
	}
	for(int i=n-r+1;i<=n;i++)
		ans=max(ans,f[i]);
	cout<<ans;
	return 0;
}

切蛋糕

       分析

        同样的我们先来思考一下暴力做法,实际上我们要求的是[l,r]这个区间和的最大值,于是我们可以利用前缀和的知识,我们先求出sum[r],再求出sum[l-1]这样只需要两个式相减,即sum[r]-sum[l-1],我们便得到了区间的和,然后我们依次枚举l,r,记录下其中的最大值即可。这样的时间复杂度是O(nm)的。于是我们接下来对他进行优化。

        我们选择将其中一个数字固定,当 r确定的时候,sum[r]就也是一个常数。于是接下来就是要维护[r-l,r-1]中最小的 sum[i] 这样得到的答案一定是在r一定时最优的,就省去了枚举端点的时间,时间复杂度也就降到了O(n)

代码 
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=5e5+10;
int a[N],q[N],sum[N];//q[]储存下标
int hh,tt,ans=-1e9;

int main()
{
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
		cin>>a[i],sum[i]=sum[i-1]+a[i];//预处理出前缀和
	hh=0,tt=0;
	for(int i=1;i<=n;i++)
	{
		while(hh<=tt&&i-q[hh]>m)//即最大区间只能是m,超过了就要往后滑动了
			hh++;
		while(hh<=tt&&sum[q[tt]]>sum[i])//这里要维护一个单调递减的队列,下式说明了是减去
			tt--;					
		ans=max(ans,sum[i]-sum[q[hh]]);
		q[++tt]=i;//下标入队
	}
	cout<<ans;
	return 0;
}

例题



Mowing the Lawn G

分析 

        由于不能连续选择超过m的牛,于是我们可以定义状态f[i][0/1]表示第i头牛选还是不选
        f[i][0]=max(f[i-1],f[i-1][1])      转移前一个选还是不选的最大值
        f[i][1]=max(f[j][0]-sum[j]+sum[i])     从ji区间,sum[i]-sum[j-1]表示ji区间选了的,但由于我们的j是不选的,于是就变成了sum[j] 此时j的取值范围是[i-k,i)
       f[i][1]=max(f[j][0]-sum[j])+sum[i];于是我们只需要用单调递增的队列维护max中的值

代码 

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
typedef long long ll;
const int N=1e5+10;
int a[N],q[N];
ll sum[N],f[N][2];

int main()
{
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
		cin>>a[i],sum[i]=sum[i-1]+a[i];
	int hh=0,tt=0;
	
	for(int i=1;i<=n;i++)
	{
		f[i][0]=max(f[i-1][0],f[i-1][1]);
		while(hh<=tt&&i-q[hh]>m)
			hh++;
		f[i][1]=f[q[hh]][0]-sum[q[hh]]+sum[i];
		while(hh<=tt&&f[i][0]-sum[i]>f[q[tt]][0]-sum[q[tt]])
			tt--;
		
		
		q[++tt]=i;
	}
	cout<<max(f[n][1],f[n][0]);
	return 0;
}

PTA-Little Bird

分析 

        题目大体意思,如果飞到的树的高度小于当前树的高度,不管中间有多高,都不会增加劳累值,否则每次加 1

        假设现在在 i 位置上,想要飞到 j 位置上,定义f[i] 为劳累度

        于是f[i]=(aj>ai)?f[j]:f[j]+1 ,ji-mi-1

        劳累值越小越有利,所以我们需要维护一个f[i] 单调递减的队列

        除此之外,当f[i]和队尾相同时,也就是不增加劳累度的时候,如下图,

        如上图,可以跳到 3−7 这个区间,跳到 3 和 4 的时候,都是消耗劳累度的,但是我们选择哪个更好呢,当然是越大的越好,因为高度越高,下一次越有利,如上图,当我们跳到 4 ,下一次跳到 3 的时候就不增加劳累度。但是当我们第一次跳到 3 的时候(紫色部分),再跳到后面的 3 增加劳累度,于是当 f[i]==f[q[tt]] 时候我们还需维护 d 的单调增加找出最大值。 a[q[tt]]<=a[i]

代码

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N=1e6+10;
int a[N],q[N],f[N];

int main()
{
	int n;cin>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	int t;cin>>t;
	while(t--)
	{
		int m;cin>>m;
		int hh=1,tt=1;q[tt]=1;//由于是从1开始的,所以得先把1给初始化进去
		for(int i=2;i<=n;i++)
		{
			while(hh<=tt&&i-q[hh]>m)
				hh++;
			f[i]=a[q[hh]]>a[i]?f[q[hh]]:f[q[hh]]+1;
			while(hh<=tt&&(f[i]<f[q[tt]]||(f[i]==f[q[tt]]&&a[q[tt]]<=a[i])))//维护一个单调递减的的f和一个单调增的a,因为高度越高,下一次越有利
				tt--;
			q[++tt]=i;
		} 
		cout<<f[n]<<endl;
	}
}

WIL

分析

 代码

//选择不超过d大小区间将其全部置为0,于是我们首先想到贪心,我们只要选择这样的一个大小为d且区间和最大的区间即可。于是我们可以通过枚举算出sum[x]-sum[x-d]的最大值,我们需要用队列维护这个最大值
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
typedef long long ll;
const int N=2e6+10;
int n,d,l,hh=0,tt=0,q[N];
ll p,sum[N],a[N];
int main()
{
	cin>>n>>p>>d;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
		sum[i]=sum[i-1]+a[i];
	}
	int ans=d;q[tt]=d;l=1;//初始化最长区间为d

	for(int i=d+1;i<=n;i++)
	{
		 // while (hh <= tt && i - d > q[hh]) {
            // hh++;
        // }
		while(hh<=tt&&sum[i]-sum[i-d]>sum[q[tt]]-sum[q[tt]-d])
			tt--;//维护单调递增的队列
		q[++tt]=i;
		//然后维护这个不超过p的最大区间,这里i就代表了r
		while(hh<=tt&&sum[i]-sum[l-1]-sum[q[hh]]+sum[q[hh]-d]>p)//因为队头是最大值
		{
			l++;//不符合了区间太大了,于是缩小
			while(hh<=tt&&l-q[hh]+1>d)
				++hh;//维护单调队列的长度			
		}
		ans=max(ans,i-l+1);
	}
	cout<<ans;
	return 0;
}

单调队列优化DP是一种常用的优化方法,可以将时间复杂度从 $O(n^2)$ 降低到 $O(n)$ 或者 $O(n \log n)$。以下是一道利用单调队列优化DP的典型题目: 题目描述: 给定一个长度为 $n$ 的序列 $a_i$,定义 $f(i)$ 为 $a_i$ 到 $a_n$ 中的最小值,即 $f(i) = \min\limits_{j=i}^n a_j$。现在定义 $g(i)$ 为满足 $f(j) \ge a_i$ 的最小下标 $j$,即 $g(i) = \min\{j \mid j > i, f(j) \ge a_i\}$。如果不存在这样的下标 $j$,则 $g(i) = n+1$。 现在请你计算出 $1 \le i \le n$ 的所有 $g(i)$ 的值。 输入格式: 第一行包含一个整数 $n$。 第二行包含 $n$ 个整数 $a_1,a_2,\cdots,a_n$。 输出格式: 输出 $n$ 行,第 $i$ 行输出 $g(i)$ 的值。 输入样例: 5 3 1 2 4 5 输出样例: 2 5 5 5 6 解题思路: 设 $dp(i)$ 表示 $g(i)$,那么 $dp(i)$ 与 $dp(i+1)$ 的转移关系可以表示为: $$dp(i)=\begin{cases}i+1, &\text{if}\ f(i+1)\ge a_i \\dp(i+1), &\text{else}\end{cases}$$ 这个转移方程可以使用暴力 DP 解决,时间复杂度为 $O(n^2)$。但是,我们可以使用单调队列优化 DP,将时间复杂度降为 $O(n)$。 我们定义一个单调队列 $q$,存储下标。队列 $q$ 中的元素满足: - 队列中的元素是单调递减的,即 $q_1 < q_2 < \cdots < q_k$; - 对于任意的 $i\in [1,k]$,有 $f(q_i) \ge f(q_{i+1})$。 队列 $q$ 的作用是维护一个长度为 $k$ 的区间 $[i+1,q_k]$,满足这个区间中的所有 $j$ 都满足 $f(j) < f(i+1)$。 根据定义,当我们要求 $dp(i)$ 时,只需要查找队列 $q$ 中第一个满足 $f(q_j) \ge a_i$ 的位置 $q_j$,那么 $g(i) = q_j$,如果队列 $q$ 中不存在这样的位置,则 $g(i) = n+1$。 那么如何维护单调队列 $q$ 呢?我们可以在每次 DP 的过程中,将 $i$ 加入队尾。然后判断队首元素 $q_1$ 是否满足 $f(q_1) \ge a_i$,如果满足则弹出队首元素,直到队首元素不满足条件为止。 由于每个元素最多被加入队列一次,并且最多被弹出一次,因此时间复杂度为 $O(n)$。具体实现细节可以参考下面的代码实现:
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值