【笔记】队列

一种数据结构。

一、队列基础

队列(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. 使用队列模拟

P1996 约瑟夫问题

ybt1332 周末舞会

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。

ybt1252 走迷宫

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

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

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

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

与此类似的题还有 ybt1254 走出迷宫ybt1251 仙岛求药 等。

ybt1359 围成面积

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

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

ybt1335 连通块

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

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;
}

P1135 奇怪的电梯

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

P10752 [COI 2024] Sirologija

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

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

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

2. 单调队列

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

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

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;
}

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

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\) 相当于让等于当前元素的提前出队了,而那些元素的位置也小于当前元素,迟早会在队首比当前元素先出队,所以不会影响结果。

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}\) 中的任意一个转移过来。然后我们就会发现,这个题跟上一个题其实是一样的。

P2629 好消息,坏消息

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

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

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

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

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;
}

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)\)

#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  阅读(5)  评论(0)    收藏  举报