队列

队列是一种 先进先出(First-In, First-Out, FIFO) 的数据结构。可以把它想想成现实生活中的排队。

  • 入队(Enqueue/Push):新来的人总是站到队伍的末尾。
  • 出队(Dequeue/Pop):每次办理业务的总是站在队伍最前面的人。
  • 队头(Front):队伍的最前面。
  • 队尾(Back/Rear):队伍的末尾。

image


数组实现

最直接、最朴素的实现队列的想法是什么?

  1. 准备一个足够大的数组 q。
  2. 用一个指针 tail(队尾)来记录新元素应该插入的位置。每次入队,tail 就向后移动。
  3. 用一个指针 head(队头)来记录队头元素的位置。每次出队,head 就向后移动。

选择题:下述代码实现的数据结构是

int data[100], f = 1, r;
void insert(int value) {
	data[++r] = value;
}
void pop() {
	f++;
}

A. 链表
B. 栈
C. 队列
D. 平衡树

答案

C


P1540

#include <cstdio>
#include <queue>
using std::queue;
const int N = 1005;
bool inq[N]; // inq[i]表示i是否在队列中
int main()
{
	int m, n; scanf("%d%d",&m,&n);
	queue<int> q;
	int cnt=0; // 队列中元素个数
	int ans=0;
	for (int i=1;i<=n;i++) {
		int x; scanf("%d",&x);
		if (!inq[x]) {
			if (cnt>=m) {
				inq[q.front()]=false;
				cnt--;
				q.pop();
			} 
			q.push(x); inq[x]=true; cnt++;
			ans++;
		}
	}
	printf("%d\n",ans);
    return 0;
}

例题:UVA540 团体队列 Team Queue

可以抽象为两个层级的队列结构:

  1. 主队列:维护排队团队的顺序,队列中的元素是“团队编号”。
  2. 团队子队列:维护每个团队内部成员的排队顺序,每个团队都有一个独立的队列。

为了高效地实现逻辑,需要一个团队映射数组记录每个人所属的团队 ID,这个数组在读取所有团队信息时构建。

当处理 ENQUEUE x 操作时,查找 \(x\) 所属的团队 ID,检查该团队是否已经在排队,如果对应的团队子队列为空,说明该团队之前没人,现在来了第一个人,需要把这个团队 ID 加入到主队列的末尾。而无论是否是新来的团队,都将 \(x\) 加入到该团队的子队列中。

当处理 DEQUEUE 操作时,找到主队列队首的团队 ID,输出该团队子队列队首的成员,将该成员出队。关键点在于,如果出队后,该团队的子队列变空了,说明该团队目前没人了,需要将该团队 ID 从主队列中移除,这样下一个排队的团队就变成了主队列的队首。

这种设计模拟了题目要求的插队逻辑:有队友时进入队友所在的子队列(相当于插到了队友后面),没队友时团队 ID 进主队列末尾(相当于排在整个队伍最后)。

参考代码
#include <cstdio>
#include <queue>
using namespace std;

// team[id] 存储编号为 id 的人所属的团队编号
int team[1000000];
// tq[i] 是一个队列,存储第 i 个团队当前在排队的成员(子队列)
queue<int> tq[1000];
char cmd[8];

int main()
{
    int s = 1;
    while (true) {
        int t; scanf("%d", &t);
        if (t == 0) break; // 输入 t=0 时结束
        printf("Scenario #%d\n", s++);
        
        // 读取每个团队的成员信息
        for (int i = 0; i < t; i++) {
            int n; scanf("%d", &n);
            for (int j = 0; j < n; j++) {
                int id; scanf("%d", &id);
                team[id] = i; // 建立映射关系:人员编号 -> 团队编号
            }
            // 初始化/清空上一组数据残留的团队子队列
            while (!tq[i].empty()) tq[i].pop();
        }
        
        // q 是主队列,存储当前在队伍中的团队编号(按进入顺序排列)
        queue<int> q;
        while (true) {
            scanf("%s", cmd);
            if (cmd[0] == 'S') { // STOP 指令
                break;
            } else if (cmd[0] == 'E') { // ENQUEUE 指令
                int x; scanf("%d", &x);
                int tid = team[x]; // 获取 x 所属的团队编号
                
                // 如果该团队当前没有人在排队(即子队列为空),说明该团队是新来的
                // 需要将该团队编号加入主队列
                if (tq[tid].empty()) q.push(tid);
                
                // 无论是否新来,都要将人加入到对应的团队子队列中
                // 如果是新来的,上面已经把团队加入主队列了
                // 如果不是新来的,直接插队到队友后面(即加入子队列尾部)
                tq[tid].push(x);
            } else { // DEQUEUE 指令
                if (!q.empty()) {
                    int tid = q.front(); // 获取排在主队列最前面的团队编号
                    printf("%d\n", tq[tid].front()); // 输出该团队排在最前面的成员
                    tq[tid].pop(); // 将该成员从团队子队列中移除
                    
                    // 如果该团队的所有成员都出队了(子队列变空)
                    // 则该团队从主队列中移除,后面若再有该团队成员来,需重新排到队尾
                    if (tq[tid].empty()) q.pop();
                }
            }
        }
        printf("\n"); // 每组测试数据后输出一个空行
    }
    return 0;
}

P2058

#include <cstdio>
#include <queue>
using std::queue;
const int N = 1e5 + 5;
struct Person {
	int t, id;
};
int cnt[N]; // cnt[i] i国家的人在船上有多少个
int main()
{
	queue<Person> q;
	int n; scanf("%d",&n);
	int ans=0; // 船上不同国籍数
	for (int i=1;i<=n;i++) {
		int t,k; scanf("%d%d",&t,&k);
		// 上船
		for (int j=1;j<=k;j++) {
			int x; scanf("%d",&x);
			q.push({t,x}); cnt[x]++;
			if (cnt[x]==1) { // 只有0->1说明国籍数+1
				ans++;
			}
		}
		// 一天前的人下船
		while (q.front().t <= t-86400) {
			int x=q.front().id;
			cnt[x]--;
			if (cnt[x]==0) { // 只有1->0说明国籍数-1
				ans--;
			}
			q.pop();
		}
		printf("%d\n",ans);
	}
    return 0;
}

例题:P7912 [CSP-J 2021] 小熊的果篮

给出一排共 \(n \ (1 \le n \le 2 \times 10^5)\) 个水果,水果分为苹果(用 \(1\) 表示)和桔子(用 \(0\) 表示),连续且类型相同的若干个水果组成一个“块”。小熊重复执行以下操作直到水果全部被挑完:

  • 在当前状态下,找到每一个“块”中最左边的水果,将它们同时拿走组成一个果篮,拿完后可能会有原来的“块”之间发生合并。
  • 按编号从小到大的顺序输出该果篮中所有水果的编号。

请计算并输出每次拿走的水果编号序列。

如果直接按照题目描述进行暴力模拟,每次扫描整个数组查找每个“块”的开头,删除对应的水果并将后续元素向前移动,或者重新扫描剩余数组,单词操作的时间复杂度最坏可达 \(O(n)\)。在最坏情况下,总时间复杂度可能退化至 \(O(n^2)\)。

因此,需要更高效的数据结构来降低“删除水果”和“寻找下一个块”的时间复杂度。

  1. 双向链表:水果被拿走后,需要迅速将其左侧和右侧剩余的水果连接起来。使用数组 \(l_i\) 和 \(r_i\) 维护编号为 \(i\) 的水果的前驱与后继,可以实现 \(O(1)\) 时间复杂度的删除操作。
  2. 块头队列:每次只需要拿走每个“块”的最左侧水果(即“块头”),用队列记录当前轮次中所有活跃块的块头编号。当一个块头水果 \(u\) 被拿走后,其原本紧邻的下一个水果 \(r_u\) 会成为该块的新块头(需要满足其存在且种类相同)。需要特别注意块合并的情况,如果拿 \(u\) 后,\(r_u\) 的种类与上一个刚刚入队的新块头种类相同,说明这两个块在删去中间的水果后合并成了一个大块,此时 \(r_u\) 不需要作为新的块头重复入队。
参考代码
#include <iostream>
#include <queue>
using namespace std;
const int N = (int)2e5 + 5;
int a[N], l[N], r[N];
int main()
{
    int n; cin >> n;
    // 初始化双向链表
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        l[i] = i - 1;
        r[i] = i + 1;
    }
    // 设置哨兵节点
    a[0] = a[n + 1] = -1;
    r[0] = 1; l[n + 1] = n;
    // 找到所有初始的“块头”并入队
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (a[i] != a[i - 1]) {
            q.push(i);
        }
    }
    // 按轮次处理每一个果篮
    while (!q.empty()) {
        int s = q.size();
        bool first = true; // 记录本轮是否已经有新块头入队
        for (int i = 0; i < s; i++) {
            int u = q.front();
            q.pop();
            cout << u << " ";
            int pre = l[u], nxt = r[u];
            // 从双向链表中删除节点 u
            l[nxt] = pre; r[pre] = nxt;
            // 如果该块还有剩余水果,尝试将新块头 nxt 放入下一轮队列
            if (nxt <= n && a[nxt] == a[u]) {
                // 如果这是本轮压入的第一个新块头,或者种类与上一新块头不同(即未合并)
                if (first || a[nxt] != a[q.back()]) {
                    q.push(nxt);
                    first = false;
                }
            }
        }
        cout << "\n";
    }
    return 0;
}

扫描一次长度为 \(n\) 的数组,建立链表并提取初始块头,耗时 \(O(n)\)。每个水果节点作为“块头”最多入队 \(1\) 次、出队 \(1\) 次,双向链表节点的删除操作均在 \(O(1)\) 时间内完成,进行块合并判断同样耗时 \(O(1)\),因此,所有节点的入队、出队及链表修改的总耗时为 \(O(n)\),综合时间复杂度为 \(O(n)\)。

习题:P4447 [AHOI2018初中组] 分组

给定 \(n \ (1 \le n \le 10^5)\) 个队员的实力值 \(a_i \ (-10^9 \le a_i \le 10^9)\),需要将所有队员分成若干个小组,满足以下要求:

  1. 每个小组内的队员实力值必须连续(例如 \([2,3,4,5]\) 是合法的,但 \([1,2,4]\) 不合法)。
  2. 同一个小组内不能出现重复的实力值(例如 \([1,1,2]\) 不合法)。
  3. 每个队员必须且只能分配到一个小组中。

请制定一种合法的分组方案,使得人数最少的小组的人数尽可能多,输出该最小人数的最大值。

解题思路

每个合法分组本质上是一个连续整数区间 \([s,e]\),若将所有队员的实力值升序排序,可以按照实力值从小到大依次处理。考虑分组过程,以下称“还没封顶、正在等待接纳下一位队员”的小组为“活跃分组”。

假设当前正在处理实力值为 \(x\) 的队员,其数量为 \(c\),当前有 \(g\) 个活跃分组(即上一次末尾刚好为 \(p = x-1\) 的小组)。

  1. 数值不连续(断层,即 \(x \gt p+1\)):中间断开了,当前所有活跃分组都无法再拼入 \(x\),必须强制全部终止(封顶结算)。
  2. 队员不足,需要终止部分分组(\(c \lt g\)):当实力值为 \(x\) 的队员数 \(c\) 少于活跃分组数 \(g\) 时,有 \(g-c\) 个分组无法继续扩展,必须被终止。优先终止长度较长的分组,把有限的 \(x\) 优先分配给长度较短的分组。例如 \([1,2,2,3,\dots]\),处理实力值为 \(3\) 的队员时,因为数量只有 \(1\) 个,这时应该考虑分组是 \([1,2], [2,3,\dots]\) 而不是 \([2], [1,2,3, \dots]\)。
  3. 队员有剩余,需要新建分组 \((c \gt g)\):当实力值为 \(x\) 的队员数 \(c\) 超过活跃分组数 \(g\) 时,现有的 \(g\) 个分组各获得一个 \(x\) 继续延伸,剩余的 \(c-g\) 个 \(x\) 只能各自作为新分组的起点。例如 \([1,2,3,3,3,\dots]\),处理实力值为 \(3\) 的队员时,取出一个 \(3\) 分配给 \([1,2,3, \dots]\),剩下的 \(2\) 个 \(3\) 无法加入已有分组,各自作为分组的起点,新建两个 \([3,\dots]\) 形式的分组。
  4. 队员数量刚好匹配(\(c=g\)):所有活跃分组正好各分配到一个 \(x\),无须终止任何分组,也无须新建任何分组。

因为按照实力值从小到大依次创建分组,先入队的分组其起始值 \(s\) 一定较小(即当前已积累长度相对较长),后入队的分组其起始值 \(s\) 较大(即当前长度相对较短)。借助队列存储每个活跃分组的起始值 \(s\),那么队头始终对应起始值最小、长度最长的活跃分组,队尾始终对应起始值最大、长度最短的活跃分组。当 \(c \lt g\) 需要弹出(终止)分组时,直接从队头弹出即可自动优先终止较长的分组。

参考代码
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
const int N = (int)1e5 + 5;
int a[N];
int main()
{
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }
    // 对实力值进行升序排序
    sort(a, a + n);
    int ans = n, pre = (int)(-2e9);
    queue<int> q; // 用于存储当前活跃的分组的起始实力值
    int i = 0;
    while (i < n) {
        int j = i + 1;
        // 统计当前实力值相同的队员数量
        while (j < n && a[j] == a[i]) {
            j++;
        }
        int x = a[i], c = j - i;
        // 如果当前实力值与上一个实力值不连续,则当前活跃的所有分组都将被终止
        if (x > pre + 1) {
            while (!q.empty()) {
                int s = q.front();
                q.pop();
                ans = min(ans, pre - s + 1);
            }
        }
        int g = q.size();
        if (c < g) {
            // 当前实力值的人数少于活跃分组数,需要终止部分分组
            // 根据贪心策略,终止起始实力值最小的(即长度最长的)分组,以最大化最短分组长度
            int d = g - c;
            for (int k = 0; k < d; k++) {
                int s = q.front();
                q.pop();
                // 终止的分组最后延伸到的值为 x-1
                ans = min(ans, x - s);
            }
        } else if (c > g) {
            // 当前实力值的人数大于活跃分组数,需要创建新的分组
            int d = c - g;
            for (int k = 0; k < d; k++) {
                q.push(x);
            }
        }
        pre = x;
        i = j;
    }
    // 处理循环结束时仍然活跃的分组
    while (!q.empty()) {
        int s = q.front();
        q.pop();
        ans = min(ans, pre - s + 1);
    }
    cout << ans << "\n";
    return 0;
}
posted @ 2026-01-04 19:09  RonChen  阅读(49)  评论(0)    收藏  举报