Loading

【笔记】队列

一种数据结构。

一、队列基础

队列(queue)是一种线性表。这种表可以在一端加入元素(队尾),在另一端删除元素(队首)。

就像排队一样,先进入队列的元素一定会先出队。

最基本的队列支持以下几种操作:

  • 在队尾加入元素
  • 在队首删除元素
  • 查询队首元素
  • 查询队尾元素

队列的运算是受限的,一般不会访问队列中间的元素。

1. 队列的实现

数组模拟

定义两个变量 frontrear(或 headtail),分别表示队首和队尾。

为了方便,我们规定:front 指向队首的前一个位置(空位),rear 指向队尾的位置(最后一个元素的位置)。

q[i] front 16 10 21 (rear)
i 0 1 2 3 4

在队尾加入元素:q[++rear]=x;

在队首删除元素:front++;

取队首元素:q[front+1]

取队尾元素:q[rear]

frontrear 初始化为 \(0\)。当 front 小于 rear 时,队列不为空。

注意元素并没有真的删除,只是通过更改队首下标的方式,相当于移到了队列的前侧。

因此这种方法需要的数组空间较大且与入队次数挂钩。具体来说,每有一次入队,数组空间就需要多 \(1\)

STL

比较推荐这种实现。

C++ 的 queue 头文件中,包含了多种队列实现。

定义队列:queue<int>q;(或是任意类型,包括结构体。)

判断队列是否为空:q.empty(),为空返回 \(1\),否则返回 \(0\)

加入元素:q.push(x);

删除元素:q.pop();

取队首:q.front()

取队尾:q.back()

注意:删除元素、取队首队尾前一定要先判空,否则会 RE。

2. 使用队列模拟

Luogu P1996 约瑟夫问题

ybt 1332 周末舞会

3. 队列的简单变体

循环队列

初赛可能会用。可用于解决数组模拟队列时的假溢出,这样数组的空间就无需开到入队次数,只开到可能同时在队中的元素数量的最大值即可。

即用数组实现队列时,判断队头、队尾是否指到了一个上界 \(M\),如果指到,下一次入队就从数组开头开始循环使用数组。具体实现为给 frontrear 取模 \(M\)

在普通队列中,队列判空操作是 front==rear,判满是 rear-front==M

在循环队列中,数组所有空间都被用上时,队首前一个位置 front 同时也是队尾。因此 front==rear 既可以表示队空,也可以表示队满。

对循环队列进行改进:多开一个空间,使 front 指向的位置永远为空而不会被覆盖,使得队满时 rear 不会和 front 指向同一个位置。判空仍是 front==rear,判满改为 (rear+1)%(M+1)==front

双端队列

顾名思义,在队首队尾都可以加入删除元素的队列。

STL 里的双端队列是 deque,但常数很大,无法通过 B3656【模板】双端队列 1

二、队列应用

1. 广度优先搜索(BFS)

一种搜索算法。在搜索时优先访问同一层的节点,直到该层访问完,再访问下一层。

适合求解是否到达、最少步骤类问题。

需要使用队列维护要访问的节点。

BFS 有一个大体的框架,大多数 BFS 都遵循这个框架执行。以下是伪代码:

初始状态进入队列;
为初始状态加入队标记;
while 队列不空{
    front = 队头;
    front 出队;
    视情况而定删除 front 的入队标记(是否可以重复入队);
    for front 可能扩展出的每个状态 v{
        if v 合法{
            if v 是目标 return 要求关于 v 的值;
            else if v 不在队中{
                v 入队;
                为 v 加入队标记;
            }
        }
    }
}

注意到 BFS 其实有两种写法,一种是在入队时判断状态是否为目标,一种是在出队时判断状态是否为目标。两种写法都正确,但在出队时判可能会导致奇奇怪怪的 TLE。

ybt 1252 走迷宫

最少步骤问题。需要维护地图上每个点的地形、坐标和走到该点的步数,所以队列中要放结构体。

BFS 保证在目标第一次入队时,一定是到达目标的最少步骤,因此在求解最少步骤时不用重复入队。

介绍一种简单的扩展状态的写法:定义两个数组 \(\delta x\)\(\delta y\),分别存放在每一种扩展方式下 \(x,y\) 坐标的变化情况,在扩展时使用 for 循环遍历该数组,用其变化量加上当前状态的坐标即可。

状态坐标在迷宫界内且不是墙为合法状态。

与此类似的题还有 ybt 1254 走出迷宫ybt 1251 仙岛求药 等。

ybt 1359 围成面积

洪水填充问题。正难则反,在图边缘 BFS,可以搜到所有没被围起来的点,用总点数减去搜到的点数和墙的点数即为答案。

与此类似的题还有 Luogu P1162 填涂颜色 等。

ybt 1335 连通块

判连通块问题。搜每个黑色点,搜到的其他点打标记,打过标记的黑点不再搜。

Luogu P1141 01迷宫

是否到达问题。与判连通块类似,为每个坐标求出所在连通块的大小。

#include<iostream>
#include<cstdio>
using namespace std;
const int maxn=1000+10;
const int dx[5]={0,1,0,-1,0};
const int dy[5]={0,0,1,0,-1};
struct Node{
	int x,y;
}q[maxn*maxn];
int n,m,cnt;
int a[maxn][maxn],f[maxn][maxn],num[maxn*maxn];
int bfs(int x,int y,int cnt){//cnt 为当前搜索连通块编号
	int head=0,tail=1;
	q[1].x=x;q[1].y=y;
	f[x][y]=cnt;
	while(head<tail){
		head++;
		int xh=q[head].x;
		int yh=q[head].y;
		for(int i=1;i<=4;i++){
			int xx=q[head].x+dx[i];
			int yy=q[head].y+dy[i];
			if(a[xx][yy]!=a[xh][yh]&&f[xx][yy]==0&&xx>=1&&xx<=n&&yy>=1&&yy<=n){
				tail++;
				q[tail].x=xx;
				q[tail].y=yy;
				f[xx][yy]=cnt;//为能走到的点打上连通块标记
			} 
		}
	}
	return tail;
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			char ch;
			cin>>ch;
			a[i][j]=ch-48;
		}
	}
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			if(f[i][j]==0){//没有打过连通块编号标记
				cnt++;
				num[cnt]=bfs(i,j,cnt);
			}
		}
	}		
	for(int i=1;i<=m;i++){
		int x,y;
		scanf("%d%d",&x,&y);
		int t=f[x][y];
		printf("%d\n",num[t]);
	}
	return 0;
}

Luogu P1135 奇怪的电梯

最少步骤问题。BFS 维护当前在哪一层,搜索在这一层上或下会进入到的层数。

Luogu P10752 [COI 2024] Sirologija

一个使用 BFS 预处理并求连通块的题。详见 题解

更多习题:Luogu P1443 马的遍历Luogu P1506 拯救oibh 总部

BFS 的应用很广泛,在有关图论等多个算法中都有涉及(如 SPFA,拓扑排序等),这里不再涉及。

2. 单调队列

一种特殊的队列。这种队列保证队列中的元素对应在原来的列表中的顺序单调递增,且队列中要维护元素的信息满足某种单调性。

单调队列较少单独考察,通常会与 DP 结合。

Luogu P1886 滑动窗口 /【模板】单调队列

朴素做法:每次暴力求出当前窗口的 \(l,r\),在 \(l,r\) 内找到最小值。时间复杂度 \(O(nk)\)

考虑优化。可以将窗口看作一个队列,每次滑动队首元素出队,同时将原列表的下一位加入队列。这样就满足了单调队列的第一个单调性。

手玩一下样例,看看怎样处理这个队列才能让其满足第二个单调性。以求最小值举例。

因为是求最小值,我们不妨让队列单调递增。

  1. 开始,队为空,\(1\) 进队。
  2. 考虑 \(3\),若 \(3\) 后两数都比 \(3\) 大,那么 \(3\) 就可以作为最小值做贡献,因此 \(3\) 进队。
  3. 考虑 \(-1\)\(-1\) 加入后,\(3\) 便不能在后续的窗口中做贡献,\(3\) 从队尾出队,同理 \(1\) 从队尾出队,\(-1\) 进队。
  4. 考虑 \(-3\)\(-1\) 从队尾出队,\(-3\) 进队。
  5. \(5\),同理,进。
  6. \(3\),同理,\(5\) 从尾出,\(3\) 进。
  7. \(6\),同理,进。但此时队列大小已大于窗口,所以队首 \(-3\) 从队首出队。
  8. \(7\),同理,进。

注意到,让元素从队尾出队的操作,其实就是让比当前要入队的元素大的元素出队,保证队列的单调性。

考虑如何求出答案。因元素满足单调性,且我们已把窗口外的元素从队首出队,所以每次出入队操作后队首必为当前窗口最小值。输出队首即可。最大值同理。

这样,使用单调队列这一数据结构,可以将时间复杂度优化到 \(O(n)\)

#include<iostream>
#include<cstdio>
using namespace std;
const int maxn=1e6+10;
int n,k;
int a[maxn],q[maxn],front,rear;
int main(){
	scanf("%d%d",&n,&k);
	for(int i=1;i<=n;i++){
		scanf("%d",&a[i]);
	}
	front=1,rear=0;
	for(int i=1;i<=n;i++){
		while(front<=rear&&a[i]<=a[q[rear]]) rear--;
		q[++rear]=i;
		while(i-q[front]>=k) front++;
		if(i>=k) printf("%d ",a[q[front]]);
	}
	printf("\n");
	front=1,rear=0;
	for(int i=1;i<=n;i++){
		while(front<=rear&&a[i]>=a[q[rear]]) rear--;
		q[++rear]=i;
		while(i-q[front]>=k) front++;
		if(i>=k) printf("%d ",a[q[front]]);
	}
	return 0;
} 

此种实现中为了方便输出,front 并不是队首的前一个位置,而是队首。

当然也可以用 STL deque 实现。

#include<bits/stdc++.h>
using namespace std;
const int N=2e6+10;
int n,k;
int a[N];
struct Node{
    int pos,val;
};
deque<Node>q;
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>k;
    for(int i=1;i<=n;i++) cin>>a[i];
    for(int i=1;i<=n;i++){
        while(!q.empty()&&a[i]<q.back().val) q.pop_back();
        q.push_back((Node){i,a[i]});
        while(!q.empty()&&i-q.front().pos+1>k) q.pop_front();
        if(i>=k) cout<<q.front().val<<' ';
    }
	cout<<'\n';
	q.clear();
	for(int i=1;i<=n;i++){
        while(!q.empty()&&a[i]>q.back().val) q.pop_back();
        q.push_back((Node){i,a[i]});
        while(!q.empty()&&i-q.front().pos+1>k) q.pop_front();
        if(i>=k) cout<<q.front().val<<' ';
    }
    return 0;
}

与此类似的模板题还有 Luogu P1440 求m区间内的最小值Luogu P2032 扫描 等。

Luogu P1725 琪露诺

一道很基本的单调队列优化 DP。

容易看出这是一道 DP,定义 \(f_i\) 为琪露诺在第 \(i\) 格时的最大冰冻指数,状态转移方程 \(f_i=\max(f_j)+A_i (i-R\le j \le i-L)\)

很容易想到 \(O(n^2)\) 转移,枚举可以转移到每个位置的位置。接着考虑优化。注意到可以转移到第 \(i\) 个位置的区间是 \([i-R,i-L]\),最优策略一定是从区间最大值转移。而第 \(i+1\) 个位置的区间是 \([i-R+1,i-L+1]\),显然可以通过单调队列由上一个区间得出,因此我们就不必枚举,求出 \(f_i\) 后将其加入单调队列,再求 \(f_{i+1}\)。最后统计答案,根据题意,只要 \(i+R>N\) 都可以作为最终状态,枚举求最大值即可。

#include<iostream>
#include<cstdio>
#include<cstring>
#define int long long
using namespace std;
const int N=2e5+10;
int n,l,r,ans;
int a[N],q[N],f[N];
signed main(){
	scanf("%lld%lld%lld",&n,&l,&r);
	for(int i=0;i<=n;i++) scanf("%lld",&a[i]);
	int front=1,rear=1;//此时默认 0 已入队,所以 rear=1
	memset(f,0xcf,sizeof(f));//初始化 f 为极小值,让 1~l-1 都能入队
	ans=f[0],f[0]=0;
	int p=0;
	for(int i=l;i<=n;i++){
		while(front<=rear&&f[q[rear]]<f[p]) rear--;
		q[++rear]=p;
		while(q[front]+r<i) front++;
		f[i]=f[q[front]]+a[i];
		p++;//i 从 l 开始,p 从 0 开始,保证每次入队都是能转移到 i 的最后位置
	}
	for(int i=n+1-r;i<=n;i++) ans=max(ans,f[i]);
	printf("%lld",ans);
	return 0;
} 

对于单调队列的比较写 \(<\) 还是写 \(\le\),其实是无所谓的,\(\le\) 相当于让等于当前元素的提前出队了,而那些元素的位置也小于当前元素,迟早会在队首比当前元素先出队,所以不会影响结果。

Luogu P2034 选择数字

思路一:直接推状态转移方程。

对于每个数字,都有两个决策:取与不取。那么状态设计为:\(f_{i,0}\) 为不取第 \(i\) 个位置的最大值,\(f_{i,1}\) 为取。

先考虑 \(f_{i,0}\),因为不涉及连续区间问题,易得状态转移方程 \(f_{i,0}=\max(f_{i-1,0},f_{i-1,1})\)

然后是 \(f_{i,1}\)。注意到,如果要取 \(i\) 且不出现连续 \(k\) 个数,我们就需要在 \([i-k,i-1]\) 中至少有一个位置不选。那么枚举这个不选的位置,状态转移方程 \(f_{i,1}=\max(f_{j,0}-a_j)+a_i(i-k\le j<i)\)

让数字和最大,即让不选的位置最小,不难看出这又是一个滑动窗口最值问题。单调队列优化 \(O(n^2)\) 转移即可。

思路二:正难则反

注意到数字非负,那么我们只需要选择尽可能多的数字。因为选的数字比不选的还多,我们不妨正难则反,直接考虑删哪些数字使他们和最小。

为了不出现超过连续 \(k\) 个数字没被删,所有删除数字的间距都不能 \(>k+1\)

我们定义状态 \(f_i\) 为前 \(i\) 个数中删除数字的最小和,然后我们发现 \(f_i\) 会从 \(f_{i-k-1}\sim f_{i-1}\) 中的任意一个转移过来。然后我们就会发现,这个题跟上一个题其实是一样的。

Luogu P2629 好消息,坏消息

首先考虑在什么情况下顺序合法。不难注意到,该通报顺序下所有前缀和都不小于 \(0\) 才能成立。

也就是说,我们只要找到在一个通报顺序下前缀和的最小值,看其是否小于 \(0\)

然后考虑断环为链,将序列复制一份拼到后面去。这样就把问题转化成了一个长度为 \(n\) 的滑动窗口。

前缀和预处理一下,跑滑动窗口时拿窗口最小前缀和减窗口前第一个前缀和判断即可。

Luogu P3957 [NOIP 2017 普及组] 跳房子

把跳房子的过程稍微转化一下,其实就能发现与琪露诺那道题类似,但有一些区别:格子间的间距不再都是 \(1\),且 \(l\)\(r\) 的值不确定。

再观察一下不难发现,答案是具有单调性的:增加改进金额后,原来的 DP 转移区间严格包含于新区间内。所以如果花费 \(g\) 个金币合法,那么花费大于 \(g\) 个金币也一定合法。

所以我们用二分答案找到能够合法的最少金币即可。

不过单调队列的使用有些不同。在琪露诺那道题中,由于各格子间距都是 \(1\),我们在维护当前格 \(i\) 和能转移到 \(i\) 的最后一个格 \(p\) 时,直接同步向后 \(+1\) 即可。但在这道题中,我们需要在遍历每个 \(i\) 的时候,\(p\) 向后扫描,判断距离直到其是能转移到 \(i\) 的最后一格,并把此前扫到的都入队。所有都入队后,再进行出队和转移。

最后在 check 时要注意,跳房子可以在任意时刻结束,所以要判断所有 \(f_i\) 是否有 \(\ge k\) 的,而不仅是 \(f_n\)

#include<bits/stdc++.h>
using namespace std;
const int N=5e5+10;
const long long INF=-1e18;
struct Node{
    int x,s;
}a[N];
int n,d,k;
long long f[N];
int q[N],front,rear;
//f[i]=max(f[j])+a[i].s(i-d-g<=j<=min(i-d+j,i-1))
//ans=max(f[i])(1<=i<=n)
bool check(int mid){
    front=1,rear=0;
    int l=max(d-mid,1),r=d+mid,p=0;
    for(int i=1;i<=n;i++) f[i]=INF;//INF 不能太小,否则下面 a[i].s 为负时会溢出
    f[0]=0;
    for(int i=1;i<=n;i++){
        while(a[p].x+l<=a[i].x){
            while(front<=rear&&f[q[rear]]<=f[p]) rear--;
            q[++rear]=p;
            p++;
        } 
        while(front<=rear&&a[q[front]].x+r<a[i].x) front++;
        if(front<=rear){
            f[i]=f[q[front]]+1ll*a[i].s;
            if(f[i]>=k) return 1;
        } 
    }
    return 0;
}
int bfind(){
    int l=0,r=a[n].x,mid;
    while(l<r){
        mid=(l+r)>>1;
        if(check(mid)) r=mid;
        else l=mid+1;
    }
    if(!check(l)) return -1;
    else return l;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>d>>k;
    for(int i=1;i<=n;i++) cin>>a[i].x>>a[i].s;
    cout<<bfind();
    return 0;
}

更多习题:Luogu P2216 [HAOI2007] 理想的正方形(二维单调队列)、Luogu P2564 [SCOI2009] 生日礼物&Luogu P1638 逛画展

Luogu P1776 宝物筛选

单调队列优化多重背包。

多重背包问题,与 01 背包类似。不同的是,每种物品都有其数量。

考虑朴素做法,我们可以在枚举物品时枚举物品个数,权值和当成一个大物品跑 01 背包;也可以把所有物品都拆成单个的小物品跑 01 背包。这两种做法都是 \(O(\sum m\times W)\),效率极低。

想想怎么优化。一种优化方法是二进制拆分,就是将每个物品拆分成 \(2\) 的次幂个物品的形式,用这些物品组合就能表示所有可能的物品数目。同样用这些拆出来的物品跑 01 背包。这样时间复杂度来到了 \(O(\sum \log m_i \times W)\)

但实际上,枚举每个物品所有可能可选的数量,有很多转移是浪费了的。具体来说,转移只会发生在总体积对当前物品体积取余相同的状态之间。道理很显然:每多选一件该物品,总体积增加了该物品的体积,对该物品的体积取余不变。

即:\(f_i\) 只能由 \(w_i \mod C\)\(C\) 是当前总体积)相同且比 \(i\) 小,选择物品数量不超过 \(m_i\) 的状态转移而来。

那么这依然是一个滑动窗口问题。枚举物品和余数,遍历编号为余数 \(+\) 物品重量倍数的状态。窗口下界是当前物品重量倍数 \(-\) 转移状态的物品重量倍数等于最大当前物品个数,即选上当前所有物品才能达到当前重量。每次取窗口中最大值转移。时间复杂度 \(O(n\times W)\)

推荐阅读:背包问题入门(单调队列优化多重背包 - 顾z,可以看一下推式子的过程。

#include<bits/stdc++.h>
using namespace std;
const int N=4e4+10;
int n,W;
int f[N],front,rear;
int v[N],w[N],m[N];
struct Node{
    int x,a;
}q[N];
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>W;
    for(int i=1;i<=n;i++){
        cin>>v[i]>>w[i]>>m[i];
        int r=min(W/w[i],m[i]);
        for(int j=0;j<w[i];j++){
            front=1,rear=0;
            for(int k=0;k<=(W-j)/w[i];k++){
                int p=k,val=f[j+k*w[i]]-k*v[i];
                while(front<=rear&&q[rear].a<=val) rear--;
                q[++rear].x=p,q[rear].a=val;
                while(front<=rear&&q[front].x+r<k) front++;
                f[j+k*w[i]]=q[front].a+k*v[i];
            }
        }
    }
    cout<<f[W];
    return 0;
}
posted @ 2025-12-11 22:32  Seqfrel  阅读(39)  评论(2)    收藏  举报