单调队列优化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题库)

image-20210708120627012

区间最大/小值求法:

  • 在行方向上设置n长的滑动窗口,然后每一次都在最右边保存它和它左边n-1个元素的最大值。
  • image-20210708120811203
  • 然后再对每一列,求出其中的最大值,即为当前 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) (每个元入队 / 出队一次)的时间复杂度。

298. 围栏 - AcWing题库

Tag :单调队列优化DP

和前面练手的几道题不同,这道题很有意思。

状态表示:f[ i ] [ j ] 表示 前 i 个工匠刷前 j 个木板得到报酬的最大值

状态计算:(根据最后一个状态来看状态转移

  1. 不用第 i 个工匠: f [ i ] [ j ] = f[ i - 1] [ j ]

  2. 使用第 i 个工匠 但是 不刷第 j 面墙: f[ i ] [ j ] = f [ i ] [ j - 1 ]

  3. 使用第 i 个工匠 并且 刷第 j 面墙:

    这里注意, 根据题干, 刷墙一定是连续的,所以刷墙的范围是包含 j 的 连续的 一段。

    假设 刷墙的 范围是 [ k , j ],那么 这里的状态计算可以写成:

    f [ i ] [ j ] = f [ i - 1 ] [ k ] + ( j - k ) * p[ i ] (记为 @)

    那么问题转化成 对于区间 [ k , j ],固定 j(在我们遍历木板的过程中 j 肯定是要遍历一遍的) 的情况下, 寻找 符合条件 的 k (区间起点) ,使表达式 @ 的值最大

    右端点不点,左端点向右走,要求区间方案的极值,这是正是单调队列求极值的适用条件,这样就可以优化掉 暴力 寻找左端点k 这一维的时间复杂度。

    注意 :这里是对区间左端点 k 进行 单调队列优化, 而不是 整个区间。

    那么 k 又有什么约束条件呢?

    1. k < si ,作为区间左端点,为了使区间 包含 si ,必须要满足这个条件。

    2. k >= j - Li ,题目要求连续一段的长度不能超过 Li ,也就是要求左端点 k >= j - Li。

      其他问题见代码注释

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

const int N = 16000 + 10, M = 110;

int n,m;
int q[N];
int f[M][N];

struct Carpenter{
	int l,p,s;
	bool operator < (const Carpenter & t)
	{
		return s < t.s;
	}
}car[M];

int main()
{
	scanf("%d %d",&n,&m);
	for(int i=1;i<=m;i++) scanf("%d %d %d",&car[i].l, &car[i].p,&car[i].s);
	sort(car+1,car+m+1);
	
	for(int i=1;i<=m;i++)
	{
		int hh = 0, tt = -1;
		for(int j = 0;j<=n;j++)
		{
			f[i][j] = f[i-1][j];	//不用第 i 个工匠的情况
			if(j) f[i][j] = max(f[i][j],f[i][j-1]);	//不刷第 j 快木板的情况
			
			int l = car[i].l, p = car[i].p, s = car[i].s;
			if(hh<=tt && q[hh] < j - l) hh++; //枚举的 k 的左端点 
			// j >= s 的时候 说明区间 [ k , j ] 是可行的,更新一遍答案。 
			if(j>=s && hh<=tt)	//s是枚举 k 的右端点,必须包含s 
			{
				int k = q[hh];
				f[i][j] = max(f[i][j],f[i-1][k] + p * (j - k));
			}
			// j < s 的时候, j 也是有用的 ,这时候 j 可能是一个起点,
			// 通过在队列中维护  f[i][j] 的第三种来源 的最大值。 
			if(j<s)
			{
				while(hh<=tt && f[i-1][q[tt]] - q[tt] * p <= f[i-1][j] - j*p) tt--;
                //这里对 第三种表达式进行了化简,因为现在枚举的 j 是用来做起点的了,所以表达式中( j - k ) * p(i) 中的 j 就没用了,(大家在队列里都是被用作起点的),只考虑 k 即可。
				q[++tt] = j;
			}
		}
	}	
	
	printf("%d\n",f[m][n]);
	
	return 0;
}

299. 裁剪序列 - AcWing题库

寄了,这题啃不动了。下次一定 ,先去打场 Div2 醒醒脑。

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

typedef long long LL;

const int N = 1e5+10;

int n;
LL m;
int a[N], q[N];
LL f[N];

multiset<LL> S;

void remove(LL x)
{
	auto it = S.find(x);
	S.erase(it);
}

int main()
{
	scanf("%d %lld",&n,&m);
	for(int i=1;i<=n;i++)
	{
		scanf("%d",&a[i]);	
		if(a[i] > m)	//当有某一个元素a[i]大于m时,不满足题设条件。 
		{
			puts("-1");
			return 0;
		}
	}	
	
	int hh = 0, tt = 0;
	LL sum = 0;
	for(int i=1, j=0;i<=n;i++)
	{
		sum += a[i];
		while(sum>m) sum -= a[++j];
		
		while(hh <= tt && q[hh] <= j)
		{
			if(hh<tt) remove(f[q[hh]] + a[q[hh+1]]);
			hh++;
		}
		int tail = tt;
		while(hh <= tt && a[q[tt]] <= a[i])
		{
			if(tt != tail) remove(f[q[tt]] + a[q[tt+1]]);
			tt--;
		}
		if(hh <= tt && tt != tail) remove(f[q[tt]] + a[q[tt+1]]);
		q[++tt] = i;
		if(hh<tt) S.insert(f[q[tt-1]] + a[q[tt]]);
		
		f[i] = f[j] + a[q[hh]];
		if(S.size()) f[i] = min(f[i], *S.begin());	
	}
	
	printf("%lld\n",f[n]);
	
	return 0;
}
posted @ 2021-07-08 12:40  superPG  阅读(602)  评论(1编辑  收藏  举报