单调队列优化DP

单调队列优化DP

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

先以最大子序和这道题理解单调队列优化DP的思想。

[AcWing135.最大子序和](135. 最大子序和 - AcWing题库)

  1. 求连续子序列的和,必然采用前缀和的思想。
  2. 所有连续子序列的最大值,肯定是至少以某一结点i为头/尾扫一遍整个序列。
  3. 但是对于某一个序列尾结点i,我们之前枚举尾结点的时候已经扫过i前面的数了,再扫一遍岂不是很丑陋的O(n^2)。
  4. 单调队列就是用来优化这种问题的方法。

对于从左到右扫描的 k < j < i ,如果S[j] <= S[k],对于右节点 i ,完全没必要再考虑k了。一是因为 同样的 S[i] 减去更小的 左端点前缀和 S[j] 得到的 序列和 肯定更大;二是因为 j 更靠右边,存活的时间更长。

因此,在我们从左到右扫描的过程中,采用一个队列来保存 扫描过的 结点 i ,要求S[i] 单调递增。

而在寻找最大连续子区间的时候,只需要找到队列首位(且满足序列长度小于等于M)的那个,就一定是左端点的最优解。由于每个元素只入队/出队一次,所以时间复杂度是O(N)。

单调队列的优化思想是:在O(n)的时间复杂度下,减少另一端点的枚举范围,使时间复杂度从O(n^2)降低到O(n),妙就妙在维护队列的单调性不需要太多多余操作,只需将队列元素和当前元素对比并判断是否出队即可。

#include<bits/stdc++.h>
using namespace std;

int n,m;
const int N = 3e5+10;

int a[N];
int sum[N];
int q[N];

int main()
{
    scanf("%d %d",&n,&m);
    
    for(int i=1;i<=n;i++)
    {
        scanf("%d",&a[i]);
        sum[i] = sum[i-1] + a[i];
    }
    
    int hh = 0,tt = 0;
    int res = -0x3f3f3f3f;
    for(int i=1;i<=n;i++)
    {
        while(hh<=tt && (i-q[hh])>m) hh++;  //注意前缀和的性质!,sum[i] - sum[q[hh]]表示的是区间,[q[hh]+1,i],所以这里的闭区间算长度不加1!
        res = max(res, sum[i] - sum[q[hh]]);
        while(hh<=tt && sum[q[tt]] >= sum[i]) tt--;
        q[++tt] = i;
    }
    
    cout<<res<<endl;
    
    return 0;
}

[AcWing1087.修剪草坪](1087. 修剪草坪 - AcWing题库)

Tag:线性DP + 单调队列优化

状态f[i] 代表前 i 个中选取不超过连续 m 个的最大值。属性:MAX

状态转换:对于最后一个元素 i,有选和不选两种方式。

  1. 不选择第 i 个元素,则情况转化为 f[i-1] 。(无需考虑连续的问题)

  2. 选择第 i 个元素,则情况转化为, 对于选择的最后连续的一段, 选几个最好?

    设选择了 j 个,其中(1 <= j <= m),因为至少要选 i 号元素,至多不能超过m个。

    转移方程为:

    ​ f [ i ] = max{ s[ i ] - s[ i - j ] + f [ i - j - 1 ] } ( 1 <= j <= k)

    ​ = max{ f [ i - j - 1 ] - s [ i - j ] } + s [ i ]

    ​ 如果将max函数内部的运算看作 g( i - j ) 的话,相当于建立一个单调队列,维护 g(i - j )的递减序列, 由于 j 的限制是 (1 <= j <= k),则维护 i 之前 最多 k 个即可。

#include<bits/stdc++.h>
using namespace std;

const int N = 1e5+10;
typedef long long LL;
LL a[N];
LL s[N];
LL f[N];
int q[N];
int n,m;

LL g(int i)
{
    return f[i-1] - s[i];
}

int main()
{
    scanf("%d %d",&n,&m);
    
    for(int i=1;i<=n;i++)
    {
        scanf("%lld",&a[i]);
        s[i] = s[i-1] + a[i];
    }
    
    int hh=0, tt=0;
    LL res = 0;
    for(int i=1;i<=n;i++)
    {
        if(hh<=tt && q[hh] < i-m) hh++;
        f[i] = max(f[i-1],s[i] + g(q[hh]));     //注意利用闫氏DP分析法时,分成了选i和不选i两种,而单调队列是对选i进行了优化,还有和不选i
        //即f[i-1]进行比较
        while(hh<=tt && g(q[tt]) <= g(i)) tt--; //维护递减子序列
        q[++tt] = i;
    }
    
    cout<<f[n]<<endl;
    
    return 0;
}

[AcWing1088.旅行问题](1088. 旅行问题 - AcWing题库)

Tag:单调队列DP + 环形问题

单调队列一般用来处理区间最小值问题

本题几个特殊的点:

  1. 把 p[ i ] 和 d[ i ] 综合处理,即每个点保存 p[i] - d[i] ,sum[i] 也保存这个点的 p[ i ] - d[ i ] 的前缀和。

  2. 破环为链。环形问题的基本操作。

  3. 顺时针和逆时针的顺序问题。考察的是每个点的可行性,肯定是顺时针走一圈逆时针走一圈,两种里面有任意一种能走都会设置为1。

    和前面两个题目的不同点是:

    • 这个题是确定起点,去找终点;而不是像之前的两题,已经有了一个终点,问从哪一个起点走来最好。
    • 所以自第一遍顺时针的时候需要逆向处理,滑动窗口中保存的是,距离不超过n的,逆序的递增序列。所以,滑动窗口的队首才是 [i, i+n] 之间前缀和的最小值。如果最小值可行,那整个序列都可行。
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<iostream>
using namespace std;

typedef long long LL;

const int N=2e6+10;

int n;
int o[N],dist[N];
LL s[N];
int q[N];
bool ans[N];

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++)
    {
        scanf("%d%d",&o[i],&dist[i]);
        s[i]=s[i+n]=o[i]-dist[i];
    }
    
    //破环成链  处理出前缀和
    for(int i=1;i<=n*2 ;i++) s[i]+=s[i-1];
    
    //逆向处理
    int hh=0,tt=0;
    q[0]=n*2+1;
    for(int i=n*2;i>=0;i--)
    {
        if(q[hh]>i+n) hh++;
        if(i<n) //逆向的时候在i<n的时候转一圈
        {//事实上,这里应该判断s[q[hh]]-s[i]>=0 更好理解
            if(s[i]<=s[q[hh]]) ans[i+1]=true;
        }//维持单调性
        while(hh<=tt && s[q[tt]]>=s[i]) tt--;
        q[++tt]=i;
    }
    
    dist[0]=dist[n];
    for(int i=1;i<=n;i++) s[i]=s[i+n]=o[i]-dist[i-1];
    for(int i=1;i<=n*2;i++) s[i]+=s[i-1];
    
    //正向处理
    hh=0,tt=0;
    q[0]=0;
    for(int i=1;i<=n*2;i++)
    {
        if(q[hh]<i-n) hh++;
        if(i>n) 
        {
            if(s[i]>=s[q[hh]]) ans[i-n]=true;
        }
        while(hh<=tt && s[q[tt]]<=s[i]) tt--;
        q[++tt]=i;
    }
    
    for(int i=1;i<=n;i++)
    {
        if(ans[i]) puts("TAK");
        else puts("NIE");
    }   
    return 0;
}

要铭记前缀和的性质if(s[i] <= s[q[hh]]) ans[i+1] = true;,其中s[ i ]只能确定 ans[i+1]是否成立,就是因为区间 [i+1 ~ i+n] 是由 s[i + n] 和 **s[ i ] **确定的。

[AcWing1089.烽火传递](1089. 烽火传递 - AcWing题库)

和前面几个题目不同,前面几个题目利用的前缀和处理区间和问题,本题直接利用单调队列的性质,裸单调队列DP。

确定好状态划分之后,手撕还是很容易的。

#include<bits/stdc++.h>
using namespace std;


int n,m;
const int N = 2e5+10;
int a[N];
int q[N];
int f[N];
//f[i] 表示 以 i 结尾的选择方案中的最小值。 

int main()
{
	scanf("%d %d",&n,&m);
	
	for(int i=1;i<=n;i++)
	{
		scanf("%d",&a[i]);
	}
	
	int hh = 0, tt = 0;
	f[0] = 0;
	for(int i = 1;i<=n;i++)
	{
		while(hh<=tt && q[hh]<i-m) hh++;
        //每一次优化当前的结果,直接加队首的最小值就一定是最优解。
		f[i] = f[q[hh]] + a[i];
		while(hh<=tt && f[q[tt]]>=f[i]) tt--;
        //维护的是 递增序列, 队首为最小值。
		q[++tt] = i;
	}
	
	int res = 0x3f3f3f3f;
	for(int i=n-m+1;i<=n;i++)
	{
		res = min(res, f[i]);
	}
	
	printf("%d\n",res);
	
	return 0;
}

[AcWing.1090绿色通道](AcWing 1090. 绿色通道 - AcWing)

单调队列一定要确定滑动窗口的大小,所以,可以采用二分的方法来确定,滑动窗口(即空题段)的大小。

注意本题的要求是空题段。在处理边界问题时要尤其注意。当 q[hh] < i - limit - 1 才不符合题意,这个自己画个图就很容易理解了。

#include<bits/stdc++.h>
using namespace std;

const int N = 5e4+10;

int n,t;
int w[N];
int f[N];
int q[N];

bool check(int limit)
{
	memset(f,0,sizeof f);
	
	int hh = 0, tt = 0;
	for(int i = 1;i<=n;i++)
	{
	    //注意limit的定义是 空题段
		while(hh<=tt && q[hh] < i - limit - 1) hh++;
		f[i] = f[q[hh]] + w[i];
		while(hh<=tt && f[q[tt]] > f[i]) tt--;
		//维护递增序列,队首是最小值
		q[++tt] = i; 
	}
	
	for(int i = n - limit;i<=n;i++)
	{
		if(f[i]<=t)
		return true;
	}
	return false;
}

int main()
{
	scanf("%d %d",&n,&t);
	for(int i=1;i<=n;i++) scanf("%d",&w[i]);
	
	int l = 0, r = n;
	while(l<r)
	{
		int mid = (l+r)>>1;
		if(check(mid)) r = mid;
		else l = mid +1;
	}
	cout<<l<<endl;
	
	return 0;
}

[AcWing1091.理想的正方形](1091. 理想的正方形 - AcWing题库)

单调队列优化DP

区间最大/小值求法:

  • 在行方向上设置n长的滑动窗口,然后每一次都在最右边保存它和它左边n-1个元素的最大值。
  • 单调队列优化DP
  • 然后再对每一列,求出其中的最大值,即为当前 n*n 的方阵的最大值。
  • 最小值同里。

利用单调队列优化,可以在处理最大值时,行方向 O(a*b),列方向O(a * b),求最小值统里,故总得时间复杂度就是O(a * b)。(在整个数据矩阵是 a * b 的情况下)。

#include<bits/stdc++.h>
using namespace std;

typedef long long LL;

const int N = 1010, INF = 1e9;
int a[N];
int w[N][N];
int n,m,k;
int row_min[N][N],row_max[N][N];
int q[N];
//get_min()或get_max()函数,都是对数组a进行单调队列求最小(大)值,并将值放入数组b中保存。
void get_min(int a[],int b[],int tot)
{
	int hh = 0, tt = -1;
	for(int i=1;i<=tot;i++)
	{
		while(hh<=tt && q[hh]<=i-k) hh++;
		while(hh<=tt && a[q[tt]]>=a[i]) tt--;
		q[++tt] = i;
		b[i] = a[q[hh]];
	}
}

void get_max(int a[],int b[],int tot)
{
	int hh=0,tt=-1;
	for(int i=1;i<=tot;i++)
	{
		while(hh<=tt && q[hh]<=i-k) hh++;
		while(hh<=tt && a[q[tt]]<=a[i]) tt--;
		q[++tt] = i;
		b[i] = a[q[hh]];
	}
}

int main()
{
    scanf("%d %d %d",&n,&m,&k);
    
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=m;j++)
        scanf("%d",&w[i][j]);
    }
    
    for(int i=1;i<=n;i++)
    {
        get_min(w[i],row_min[i],m);
        get_max(w[i],row_max[i],m);
    }
	
    int res=INF;
    int a[N],b[N],c[N];
    for(int i=k;i<=m;i++)
    {
        for(int j=1;j<=n;j++) a[j]=row_min[j][i];
        get_min(a,b,n);
        
        for(int j=1;j<=n;j++) a[j]=row_max[j][i];
        get_max(a,c,n);
        
        for(int j=k;j<=n;j++) res=min(res,c[j]-b[j]);
    }
    
    printf("%d\n",res);
    
    return 0;
}

总得来说,单调队列可以用来求范围内的最值维题。在遍历数据的过程中,它抱持了队列中的单调性,无论是队列是根据 前缀和 亦或是 dp值 保存的,队首都能返回 符合约束条件的 最大 / 最小值。我认为,单调队列一般是把 需要重复遍历的 O(n^2) 时间复杂度降低到 O(n) (每个元入队 / 出队一次)的时间复杂度。

上一篇:[SDOI2009]HH的项链解题报告


下一篇:「 洛谷 」P2151 [SDOI2009]HH去散步