学习记录#1——单调队列

本人比较早之前(其实也就是大一下学期刚开始的样子哈哈)就接触过算法竞赛的内容,不过一直比较缺乏系统性和深入性,这段时间刚好可以趁着暑假的时间写一写博客记录一下(可能开学了也会坚持哦)既是方便以后回顾相关内容,也可以协助总结每个阶段所学的知识点吧。本文如果有问题敬请斧正。

一、单调队列简介

回到主题,单调队列不是一种新的队列类型,它在结构上仍然是一种双端队列,是队列的一种使用方式,其主要的功能就是在某个元素序列的给定区间内寻找一个最小值。

具体实现是,依次遍历数组,每次让一个数字从队尾进入队列,利用双端队列能且仅能在队头和队尾进行插入和删除的特点,始终保持队列内元素有序。例如:让元素x入队前,与队尾元素y相比较,如果y>=x,就从队尾弹出y,依次循环直到x可以进入队尾,这一过程就保证了新入队的一定是队内最大元素,队头一定是最小元素。那么为何需要弹出队头呢?我们注意到,目标是在一个数组的给定区间内寻找最小值,以上内容的确可以确保能够寻找到最小元素,却无法保障该元素还在这个区间内!所以,当队头元素不在这个区间后,我们就需要从队头弹出这个元素。在这一过程中,我们就可以得到每个区间内的最小值,从而更好实现题目要求。

以上可以概括出单调队列的两个核心操作:删头、去尾。单调队列实现时依次遍历数组,时间复杂度为O(n),而暴力解法需要每次计算区间中k个数的大小,时间复杂度为O(kn),在大多数情况下可能会超时,因此单调队列常常用于优化计算。


下面是单调队列的模板题,也是操作最基础的题目:洛谷P1886 滑动窗口。

有一个长为 n 的序列 a,以及一个大小为 k 的窗口。现在这个从左边开始向右滑动,每次滑动一个单位,求出每次滑动后窗口中的最大值和最小值。

例如,对于序列  [1, 3, −1, −3, 5, 3, 6, 7] 以及 k = 3,有如下过程:

(图片来源于洛谷)

输入一共有两行,第一行有两个正整数 n,k。 第二行 n 个整数,表示序列 a。

输出共两行,第一行为每次窗口滑动的最小值,第二行为每次窗口滑动的最大值。

数据范围:1\leqslant k\leqslant n\leqslant 10^{6}-2^{31} \leqslant a_{i} < 2^{31}

该题数据量庞大,如果用暴力法,显然会超时,下面用单调队列解题,还是比较简单的:

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

int a[1000005];
deque<int>q;
//双端队列q中储存的是数组元素的下标,很方便地滑动窗口

int main(){
	int n,k;
	cin>>n>>k;
	for(int i=1;i<=n;i++) cin>>a[i];
	for(int i=1;i<=n;i++){
		while(!q.empty()&&a[q.back()]>a[i]) q.pop_back();
        //非空且尾大于头,去尾
		q.push_back(i);
		if(i>=k){ //第一次输出时至少要读入k个元素
			if(q.front()<=i-k) q.pop_front();//删头,防止窗口外的小元素占着不走
			cout<<a[q.front()]<<" ";
		}
	}
	cout<<endl;
	q.clear();//清空队列,再来一次
	for(int i=1;i<=n;i++){
		while(!q.empty()&&a[q.back()]<a[i]) q.pop_back();
		q.push_back(i);
		if(i>=k){
			if(q.front()<=i-k) q.pop_front();
			cout<<a[q.front()]<<" ";
		}
	}
    return 0;
}

二、单调队列与前缀和

有的时候,单调队列所操作的不一定是数组本身,而是数组的前缀和,实际上,这种结合要更普遍一些(因为更复杂也更难嘛 QWQ)往往体现在,我需要在数组中找到某段子序列,使得这段子序列满足某种要求,这就是该类题目——单调队列和子序和

(1)单调队列与最大子序和

其实,单调队列用于求解最大子序和时,一般需要限制子序列的长度。对这种情况的原因,我们可以设想,如果子序列的长度不限,反映在数组中,就是可以随便去取(随便拿,没事的(杰哥音))那么在单调队列中,我们就无法确定一个具体的区间值,更无法进行核心的“删头”操作,你不限制长度怎么去掉前面的数字呢是吧。只有当子序列的极限长度确定之后,我们才能在这个区间内去取(有关不定长子序和的问题另开博客说吧(。>∀<。))。

求解最大子序和时,我们需要利用前缀和,把数组 num[i] 求前 i 项和得到 s[i] ,那么问题就从求数组内的最大子序和,转化为了在前缀和数组中寻找两个位置 a 和 b ,使得 s[a] - s[b] 最大,且a - b <= m。

所以,我们每一次把新的元素入队时,都要让新元素减去队头元素,值就是一个可能的最大子序和,因为它代表这一次滑动窗口(选取区间)的元素减去最小元素。此时我们检查这个值,如果是当前的最大子序和,就更新maxsum。编程中要注意删头去尾。遍历完所有元素后,maxsum的值就是在给定长度下的最大子序和。复杂度为O(n)。

这里有一些概念需要区分和理解:

我们是在一段区间内取数据,这个区间就可以称作一个“窗口”,“窗口”也就是我们取元素的范围。所以区间、窗口和范围是等价的,是同一种概念。每次更新区间就是在数组中滑动窗口(我又使用了P1886的题目名,因为很直观),也就是去掉一个前面的旧元素,加入一个后面的新元素。把这个新元素放入队列,保障队列的有序性,就需要“去尾”。可知,队列仅展示某一个窗口中的最小值。

在我学习的时候有一个疑问,虽然队头元素是最小元素,但新入队的不是当前区间内的最大元素呀,为什么可以用两者相减的值更新maxsum呢?

因为我们每次操作,都会得到新入队元素与当前区间内最小元素的差值,这些操作覆盖了所有元素。换而言之,我们比较了所有元素在它进入区间时与最小元素出现的最大差值,虽说范围内可能有元素比新元素更大,但别忘了它们早就进入了该区间,也早就在进入时就计算过它会出现的maxsum了。所以我们只需要用新元素来计算并更新就行,最后得到的maxsum一定是最大差值,也就是最大子序和。


下面是例题,折磨过我一段时间:洛谷P1714 切蛋糕。

今天是小 Z 的生日,同学们为他带来了一块蛋糕。这块蛋糕是一个长方体,被用不同色彩分成了 n 个相同的小块,每小块都有对应的幸运值。

小 Z 作为寿星,自然希望吃到的蛋糕的幸运值总和最大,但小 Z 最多又只能吃 m(m ≤ n) 小块的蛋糕。

请你帮他从这 n 小块中找出连续的 k(1 ≤ k ≤ m) 块蛋糕,使得其上的总幸运值最大。

形式化地,在数列 {p_{n}​} 中,找出一个子段 [l, r] (r − l + 1 ≤ m),最大化 \sum_{i = l}^{r} p_{i}

输入格式:

第一行两个整数 n,m。分别代表共有 n 小块蛋糕,小 Z 最多只能吃 m 小块。第二行 n 个整数,第 i 个整数 p_{i} 代表第 i 小块蛋糕的幸运值。

输出格式:

仅一行一个整数,即小 Z 能够得到的最大幸运值。

这题就是比较经典的单调队列求解最大子序和的问题,利用前缀和,解题如下:

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

int n, m;
long long num[500005], s[500005];
deque<int>d;

int main() {
	cin >> n >> m;
	long long maxsum = -0x7fffffff;
    //初始化为极小值,之后再更新
	for (int i = 1; i <= n; i++) {
		cin >> num[i]; //数组
		s[i] = s[i - 1] + num[i]; //前缀和数组
	}
	d.push_back(0); //需要一个初始数据0,后文细说
	for (int i = 1; i <= n; i++) {
		while (!d.empty() && s[d.back()] >= s[i])d.pop_back(); //去尾
		d.push_back(i);
		while (!d.empty() && i - m > d.front())d.pop_front(); //删头
		maxsum = max(s[i] - s[d.front()], maxsum);
	}
	cout << maxsum;
	return 0;
}

在理解以上原理之后,这类题目思路是不难的,就是有一些细节需要注意:

细节一:

删头去尾之前要在队列中压入元素0。结合数组初始化时从1开始,这里的0就是代表数组中的0号元素,这一步处理的目的是防止第一个元素被错误处理。考虑数组 5 3 2 1 1 ,前缀和为 5 8 10 11 12 。在 5 入队之后,不需要删头,在更新maxsum时,就要用新入队元素减去队头元素,好巧不巧,这俩是一个元素啊!那么maxsum就被更新成了 0 ,但实际上应该是 5 ,就出现了错误处理。

那为什么后面不需要考虑新元素就是队头元素这一特殊情况呢?

因为在后面出现这种情况时,新元素成为队头是因为原队头元素是超出范围自行离队的!而非原本队列为空被迫成为队头。前者的maxsum为 0 是这个区间内本身就没有比新元素更小的元素。

细节二:

不同于滑动窗口那题,有前缀和的题目删头的条件都是 i - m > 队头元素,不取等,这是由前缀和的性质决定的,可以画图试一试,s[4] - s[2] 是第 3 和第 4 元素的和,当队头元素等于当前元素减去区间长度时,刚好就包含了 m 个元素,是合法的,此时长度为 2 需要包含第 2 元素,不需要删头。但是从元素的角度出发,第 4 元素和第 2 元素之间隔着一个元素,若是长度为 2 就不能包含第 2 元素。两种情况下包含第 2 元素的性质不一样。

细节三:

删头去尾是有顺序的,目前好像是先去尾入队,再删头,再进行操作,不然可能会将未来得及离队的队头纳入计算。

(2)利用单调队列的性质解题


单调队列的最大特点就是可以方便地寻找到某个元素序列给定区间内的最小值,利用好这一点,我们就可以解决一些比较有意思的题目,如:洛谷P2629 好消息,坏消息。

Uim 在公司里面当秘书,现在有 n 条消息要告知老板。每条消息有一个好坏度,这会影响老板的心情。告知完一条消息后,老板的心情等于老板之前的心情加上这条消息的好坏度。最开始老板的心情是 0,一旦老板心情到了 0 以下就会勃然大怒,炒了 Uim 的鱿鱼。

Uim 为了不被炒,提前知道了这些消息(已经按时间的发生顺序进行了排列)的好坏度,希望知道如何才能不让老板发怒。

Uim 必须按照事件的发生顺序逐条将消息告知给老板。不过 Uim 可以使用 “倒叙” 的手法,例如有 n 条消息,Uim 可以按 k, k+1, k+2, … n, 1, 2, … k−1(事件编号)这种顺序通报。

他希望知道,有多少个 k,可以使从 k 号事件开始通报到 n 号事件然后再从 1 号事件通报到 k−1 号事件可以让老板不发怒。

输入格式:

第一行一个整数 n(1 \leqslant n\leqslant 10^{6}),表示有 n 个消息。

第二行 n 个整数,按时间顺序给出第 i 条消息的好坏度 A_{i}-10^{3} \leqslant A_{i} \leqslant 10^{3}

输出格式:

一行一个整数,表示可行的方案个数。

这道题目需要使用化环为链的思想,因为它本质上是取后面一段数组拼到前面来,我就干脆再复制一段数组接到原数组后面,每次取长度为原数组长度的窗口,模拟各种截取法。此时题目就变成了求区间段长度固定为原数组长度(n)的拼接数组(2n)中,各区间段是否存在前缀和为负数的情况

考虑使用前缀和解决问题,求出拼接数组的前缀和数组,区间长度为原数组长度,那么问题就化为了找到这个区间内是否存在小于 0 的元素,逐一判断比较麻烦,复杂度为O(n^{2}),显然会超时,则需要使用单调队列维护最小值,找到每个区间内的最小值,每次最小值有小于 0 的元素,就代表这种截取法出现了前缀和为负数的情况,计数器ans++。以下是代码实现:

#include<iostream>
#include<algorithm>
#include<deque>
using namespace std;

deque<int>d;
int num[2000005], s[2000005];
int ans = 0;

int main() {
	int n; cin >> n;
	for (int i = 1; i <= n; i++) {
		cin >> num[i]; //原数组
	}
	for (int i = n + 1; i < 2 * n; i++) {
		num[i] = num[i - n]; //制作拼接数组
	}
	for (int i = 1; i < 2 * n; i++) {
		s[i] = s[i - 1] + num[i]; //前缀和数组
	}
	for (int i = 1; i < 2 * n; i++) {
		while (!d.empty() && s[i] <= s[d.back()])d.pop_back(); //去尾
		d.push_back(i);
		if (i >= n) { //满n之后开始输出
			while (!d.empty() && i - d.front() >= n)d.pop_front(); //删头
			if (s[d.front()] >= s[i - n])ans++;
		}
	}
	cout << ans;
	return 0;
}

三、单调队列用于DP优化

说来惭愧,DP我学过一点点,单调队列也学了,但是这俩结合起来还是有一点点搞不清楚,不过这类题目大部分都是多种知识点的组合,如洛谷P3957 [NOIP2017 普及组]跳房子,是二分法、DP和单调队列的组合题,难得我哭爹喊娘(实力太弱了还是)这一部分我干脆放到DP里面去算了吧哈哈哈。

四、总结

单调队列是一种队列的使用方式,可以方便地寻找到某个元素序列给定区间内的最小值,从而解决最大有限子序和等问题,类似的还有单调栈等。单调队列常常用于优化算法,在一堆数据里面找到最小的那一个,利用好这一点可以解出很多看上去比较复杂的题。单纯考单调队列比较简单,重点是将题目的目标进行转化,再利用单调队列的性质。这就需要我们多思考多尝试,比如往前缀和等方面去靠啦之类的,也许现在还很难,但相信练习多了,总会有收获的!✧*。٩(ˊωˋ*)و✧*。

  • 15
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值