【动态规划 进阶】单调队列优化dp 学习总结

既然是“优化”,我们就一定要在思考出原始的状态转移方程后再实现的吧。

0x00 单调队列

0x01理论概念

单调队列,顾名思义,是一种具有单调性的队列。单调性有单调递增和单调递减两种,相应的单调队列也分为单调递增队列和单调递减队列两种。

单调递增队列:保证队列头元素一定是当前队列的最小值,用于维护区间的最小值。

单调递减队列:保证队列头元素一定是当前队列的最大值,用于维护区间的最大值。

0x02 根本特点

说了半天,实际上想表达的特点只有一个:
单调队列中的元素是单调递增或单调递减的!
这是我们需要知道的第一个特点。


接下来,引入一道例题来分析单调队列的第二个特点:
P1886 滑动窗口 /【模板】单调队列
读过题,我们发现这是一道经典的滑动窗口的题。
我们分析过题后,不难发现这道题的本质就是
在一段固定的区间内求出区间的最大值和最小值!
等等,我们不是刚刚分析过单调队列的第一个特点吗?

单调递增队列:保证队列头元素一定是当前队列的最小值,用于维护区间的最小值。
单调递减队列:保证队列头元素一定是当前队列的最大值,用于维护区间的最大值。

一模一样啊!(注意:不要因为这是模板而去套用模板!)
根据特性1,我们就可以推断出第2个特性:
单调队列可以解决滑动窗口(或类滑动窗口)问题
注意,这里有读者可能会怀疑为什么队列中的元素个数并不等于滑动窗口的宽而却能代替暴力模拟的滑动窗口O(n2)大小的时间复杂度。
这里大家可以去看一下这位大佬的blog,讲的很清楚。


接下来还要说单调队列的特性3:
单调队列可以解决类似于区间dp但是不需要合并的问题
其实这条特性很重要,但是有这条特性的算法有不少(本块思想只是作者自己思考感悟不具备权威性),例如树上dp,贪心等都可以实现这一点。
但是对于这种单调队列优化来说,这已经足够了,可以pass掉不少其他的优化算法了。


总结一下:
单调队列三大性质:
1.在一段固定的区间内求出区间的最大值和最小值
2.单调队列可以解决滑动窗口(或类滑动窗口)问题
3.单调队列可以解决类似于区间dp但是不需要合并的问题

0x03 模板代码

注意一点,本题的模板代码对于新手来说很难写(尤其是head、tail赋初值多少)。
根据个人经验,其实针对不同的题,赋的初值可能会产生差别,请小心!

#include<iostream>
#include<cstdio>
using namespace std ;

const int MAXN = 1e6 ;

int n ,k ;
int a[MAXN] ;
int id[MAXN] ;
int q[MAXN] ;

inline void findd_min(){
	int head = 1 ;
	int tail = 0 ;
	
	for(int i = 1;i <= n;++i){
		while(head <= tail && q[tail] > a[i])
			tail-- ;
		q[++tail] = a[i] ;
		id[tail] = i ;
		
		while(id[head] <= i - k)
			head++ ;
		
		if(i >= k)
			printf("%d " , q[head]) ;
	}
	printf("\n") ;
}

inline void findd_max(){
	int head = 1 ;
	int tail = 0 ;
	
	for(int i = 1;i <= n;++i){
		while(head <= tail && q[tail] <= a[i])
			tail-- ;
		q[++tail] = a[i] ;
		id[tail] = i ;
		
		while(id[head] <= i - k)
			head++ ;
		
		if(i >= k)
			printf("%d " , q[head]) ;
	}
	printf("\n") ;
}

int main(){
	scanf("%d%d" , &n , &k) ;
	for(int i = 1;i <= n;++i)
		scanf("%d" , &a[i]) ;
	
	//滑动窗口的最小值
	findd_min() ;
	
	//滑动窗口的最大值
	findd_max() ;
	
	return 0 ;
}

0x10 单调队列优化dp的特点

介绍了好半天的单调队列,终于来到了单调队列优化dp
但要时刻铭记本文的第一句话:

既然是“优化”,我们就一定要在思考出原始的状态转移方程后再实现的吧。

来让我们按照单调队列的特点推出来单调队列优化dp的特点:
首先是单调队列的第1个特点:

  1. 在一段固定的区间内求出区间的最大值和最小值

那与之匹配的dp特点就是:
1. 在一段固定(或非固定)的区间内求出区间最大值
这里引入了一个dp的性质:被不同的因素所影响的性质。
就是不固定区间长度的因素。


第2个特点:

  1. 单调队列可以解决滑动窗口(或类滑动窗口)问题

2.求解类似于区间最大值(或有类似于区间限制的题)


第3个特点:

  1. 单调队列可以解决类似于区间dp但是不需要合并的问题

3. 求解关于任何有关于区间但无合并操作的题

0x20 例题分析

说明:这里作者只会分析为什么会使用 单调队列优化dp 并给出参考代码
关于细节问题请移步相关题解。

0x21 luogu P1725 琪露诺

本题特征很明显可以列出这样的状态转移方程:
f [ i ] = max ( f [ j ] ) + A[ i ] ( i − R ≤ j ≤ i − L )
然后我们发现枚举从 i - R 到 i - L 区间的 j 十分的浪费时间,所以我们想到了
单调队列的性质2:求解类似于区间最大值(或有类似于区间限制的题)。

诶 有读者就想问了 为什么我选 性质2 不选 性质1 呢
很简单 因为范围是固定的 用不着去找 区间的条件

给上参考代码:

#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std ;

const int MAXN = 2e5 + 10 ;
const int INF = 0x3f3f3f3f ;

int f[MAXN] ;//设 f[i]: 到达位置 i 时最大的价值和
int delta[MAXN] ;
int n ;
int ans = 0 ;
int l ,r ;
int que[MAXN] ,head = 1 ,tail = 1 ;//单调队列, 内部元素为位置 

inline int read(){
	int x = 0 ,y = 1 ;
	char c = getchar() ;
	while(c < '0' || c > '9'){
		if(c == '-')
			y = -1 ;
		c = getchar() ;
	}
	while(c <= '9' && c >= '0'){
		x = x * 10 + c - '0' ;
		c = getchar() ;
	}
	return x * y ;
}

//插入
inline void insert(int node){
	for(;f[node] >= f[que[tail]] && tail >= head;)
		--tail ;//弹出权值和较小的 队尾元素 
	que[++tail] = node ;
}

//查询
inline int query(int node){
	for(;que[head] + r < node;)
		head++ ;//弹出队首 不可到达x位置的 不合法元素 
	return que[head] ;
}

int main(){
	memset(f , 128 , sizeof(f)) ;
	f[0] = 0 ;
	ans = -INF ;
	n = read() ,l = read() ,r = read() ;
	for(int i = 0;i <= n;++i)
		delta[i] = read() ;
		
	for(int i = l;i <= n;++i){
		insert(i - l) ;//将最后一个 能够转移到i的位置 加入单调队列 
		int from = query(i) ;//找到队首 权值和最大的位置 
		f[i] = f[from] + delta[i] ;//进行转移 
		if(i + r > n)//进行转移 
		//"只要她下一步的位置编号  大于  N就算到达对岸"
			ans = max(ans , f[i]) ;
	}
	printf("%d" , ans) ;
		
	return 0 ;
}

0x22 luogu P2627 [USACO11OPEN]Mowing the Lawn G

首先列出状态转移方程:
f [ i ] [ 1 ] = m a x ( f [ j ] [ 0 ] − Σ j = i − k + 1 i a [ j ] ) f [ i ][ 1 ] = max( f [ j ][ 0 ] - \Sigma_{j=i-k+1}^{i} a[j]) f[i][1]=max(f[j][0]Σj=ik+1ia[j])
f [ i ] [ 0 ] = m a x ( f [ i − 1 ] [ 0 ] , f [ i − 1 ] [ 1 ] ) f [ i ][ 0 ] = max(f [ i - 1][ 0 ] , f[ i - 1][ 1 ]) f[i][0]=max(f[i1][0],f[i1][1])

其中 0 表示不选 1 表示选
然后时间复杂度压力来到他这里
很容易发现简化时间复杂度的方法只有对 a [ j ] a [ i ] 进行优化
很显然需要进行一个区间求和的操作
类似于区间求和的操作。有哪些呢?
线段树、树状数组、前缀和
因为不方便 所以我们不选择前两者,而选择了前缀和
然后又发现需要维护 前缀数组 选取

f [ j ] [ 0 ] − S u m [ j ] f [ j ][ 0 ] - Sum[ j ] f[j][0]Sum[j]

最小的一个
所以想到单调队列性质1
因为区间长度未知(他只是说不超过k没说一定是)
还顺便优化掉 0/1 维度的空间

样例代码:

//f[i][1] = max(f[j][0] - sum[j]) + sum[i]
//f[i][0] = max(f[i-1][0] , f[i-1][1])
//单调队列滑动窗口 维护 a ~ a + k - 1 区间内f[j][0] - sum[j]最小
#include<iostream>
#include<cstdio>
using namespace std ;

const int MAXN = 1e5 + 10 ;

int n ,k ;
long long sum[MAXN] ;
int a[MAXN] ;
long long f[MAXN] ;
long long d[MAXN] ;
int q[MAXN << 1] ;
int head = 0 ;
int tail = 1 ;

int main(){
	scanf("%d%d" , &n , &k) ;
	for(int i = 1;i <= n;++i)
		scanf("%d" , &a[i]) , sum[i] = sum[i-1] + a[i] ;
	
	for(int i = 0;i <= n;++i){
		d[i] = f[i-1] - sum[i] ;
		while(head <= tail && d[q[tail]] < d[i])
			tail-- ;
		while(head <= tail && q[head] < i - k)
			head++ ;
		q[++tail] = i ;
		f[i] = d[q[head]] + sum[i] ;
	}
	
	printf("%lld" , f[n]) ;
	return 0 ;
}

0x23 luogu P2216 [HAOI2007]理想的正方形

这道题没有体现任何dp的性质 所以他不是dp
但是为什么我要放在 单调队列优化dp 里来讲呢
是为了方便读者认识第3条性质:

  1. 求解关于任何有关于区间但无合并操作的题

打眼一看 区间内的 最大值 与 最小值 的差
这时候我们想
也许 他符合第3性质
注意这里只是 也许
我们此时还不确定 因为他可能还有可能是其他算法 例如区间dp 线段树 等等
都可以求区间最大值 、 最小值
但是这道题还有一个提示 这道题一定符合第2性质 没有固定区间范围
区间dp去 * 吧哈哈哈
那一定就是用单调队列了
附上代码:

/*
xmax[][]与xmin[][]所存储的分别是1×n的长方形内的最大值,最小值。xmax[i][j]存储第i行第j~j+n-1列的
长方形中的最大值。同理,xmin[i][j]存储第i行第j~j+n-1列的长方形中的最小值。

xmax[][]中每个区间的的最大值用ymax[][]维护,将xmin[][]中的每个区间的最小值用ymin[][]维护。
那么ymax[i][j]存储xmax[][]中第i~i+n-1行第j列的长方形的最大值。同理ymin[i][j]存储xmin[][]中
第i~i+n-1行第j列的长方形的最小值。
*/
#include<iostream>
#include<cstdio>
using namespace std ;

const int MAXN = 1009 ;
const int INF = 0x3f3f3f3f ;

int a ,b ,k ;
int num[MAXN][MAXN] ;
int xmax[MAXN][MAXN] ,xmin[MAXN][MAXN] ;//记录1*n长方形中的最大、最小值
int ymax[MAXN][MAXN] ,ymin[MAXN][MAXN] ;//记录n*1长方形中的最大、最小值
int qmax[MAXN] ,qmin[MAXN] ;//双队列
int ans = INF ;
int headmax = 1 ;
int tailmax = 1 ;
int headmin = 1 ;
int tailmin = 1 ;

int main(){
    scanf("%d%d%d" , &a , &b , &k) ;
    for(int i = 1;i <= a;++i)
        for(int j = 1;j <= b;++j)
            scanf("%d" , &num[i][j]) ;
            
    //处理行值
    for(int i = 1;i <= a;++i){
        headmin = headmax = tailmin = tailmax = 1 ;
        qmax[1] = qmin[1] = 1 ;
        for(int j = 2;j <= b;++j){
            while(num[i][j] >= num[i][qmax[tailmax]] && headmax <= tailmax)
                tailmax-- ;
            while(num[i][j] <= num[i][qmin[tailmin]] && headmin <= tailmin)
                tailmin-- ;
                
            qmin[++tailmin] = j ;
            qmax[++tailmax] = j ;
            
            while(j - qmax[headmax] >= k)
                headmax++ ;
            while(j - qmin[headmin] >= k)
                headmin++ ;
            
            if(j >= k){//!
                xmax[i][j - k + 1] = num[i][qmax[headmax]] ;
                xmin[i][j - k + 1] = num[i][qmin[headmin]] ;
            }
        }
    }
    
    //处理列值
    for(int i = 1;i <= b - k + 1;++i){
        headmin = headmax = tailmin = tailmax = 1 ;
        qmax[1] = qmin[1] = 1 ;
        for(int j = 2;j <= a;++j){
            while(xmax[j][i] >= xmax[qmax[tailmax]][i] && headmax <= tailmax)
                tailmax-- ;
            while(xmin[j][i] <= xmin[qmin[tailmin]][i] && headmin <= tailmin)
                tailmin-- ;
                
            qmin[++tailmin] = j ;
            qmax[++tailmax] = j ;
            
            while(j - qmax[headmax] >= k)
                headmax++ ;
            while(j - qmin[headmin] >= k)
                headmin++ ;
            
            if(j >= k){
                ymax[j - k + 1][i] = xmax[qmax[headmax]][i] ;
                ymin[j - k + 1][i] = xmin[qmin[headmin]][i] ;
            }
        }
    }
    
    //取最小值
    for(int i = 1;i <= a - k + 1;++i)
        for(int j = 1;j <= b - k + 1;++j)
            ans = min(ans , ymax[i][j] - ymin[i][j])  ;
    
    //输出
    printf("%d" , ans) ;
    
    return 0 ;
}

单调队列的优化是基于基本的状态转移方程上的
所以虽然会有一定的 套路 一定的 模板
但是 最核心的 思想 最核心的 特征 一定要从 一步步简化的状态转移方程上
看出来!

2022.8.17

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值