队列:一座只允许排队的容器
为什么需要
顺序表、链表给出的自由度,上一课被栈收走一半:只动栈顶,"最近的最优先"。可现实里更常见的秩序恰好反过来------先来的人先被服务。食堂排队、打印机任务队列、进程排队等待,规则只有一条:别插队,来得早的处理得早。
与其每次手写这种约束,不如抽象成一个容器:队列(Queue)。它只允许在一端插入、在另一端删除:
- 队尾 (
tail,练习里叫ptail):入队从这儿进来排队; - 队头 (
head,练习里叫phead):出队从这儿被处理掉。
数据进出遵守先进先出(FIFO,First In First Out),与栈的 LIFO 恰好相反。栈处理"最近的最优先",队列处理"最早的最优先"------线性容器两端最常用的两条纪律,到此凑齐。
💡 背景补充:操作系统与网络协议里到处是队列:消息队列、打印队列、TCP 收发缓冲、BFS 层序遍历。那是系统维护的队列,本课实现的是用户态的队列------和上篇"用户态栈"同一立场,同名不同物,别混。
接口设多少个,由需求定:只要"入队、出队、看队头(队尾)、判空、求大小、销毁"这几件。调用者不需要随机访问,也不需要删除中间的某个元素------"从队列中删除第 i 个元素"恰不是队列的基本运算。接口只暴露必需的,其余锁死。这条纪律,上篇在栈上已经讲透。
核心机制
决策一:为什么是链表,不是数组
上篇的结论:栈用数组更优,因为入栈、出栈都发生在尾部,数组尾插尾删 O(1)。
队列把两个端点都用上了:入队在尾没问题,可出队在头。换用数组,每次出队都要把后面所有元素往前搬一格,O(n);链表头删只改一个指针,O(1)。
| 容器 | 插入端 | 删除端 | 数组的代价 | 结论 |
|---|---|---|---|---|
| 栈 | 栈顶 = 尾部 | 尾部 | 尾插尾删 O(1) | 数组优 |
| 队列 | 队尾 | 队头 | 头删要搬 n 个元素 O(n) | 链表优 |
这不是推翻栈课的结论,而是同一句话的两面:实现怎么选,取决于增删操作落在哪一端。先想清楚每次操作动的是头还是尾,再决定用数组还是链表。
结构:把两个指针收进一个家
单链表做"尾插 + 头删",光一个头指针不够------删头要 head,接尾要 tail,两个节点指针得同时维护:
c
typedef struct QueueNode { // 一个在排队的节点
struct QueueNode* next; // 排在它后面的那个人
QdataType val; // 带的"号码牌"
} QNode;
typedef struct Queue { // 把"队"整个封装起来
QNode* phead; // 队头:出队从这里走
QNode* ptail; // 队尾:入队从这里进
int size; // 当前人数,顺带用于判空
} Queue;
为什么把它们装进一个 Queue 结构,而不是 QueuePush(QNode** pphead, QNode** pptail, x) 那样拆开传?因为头删、清空都要同时改两个指针 ------拆开传就得用二级指针 QNode**,每个函数签名长一截、错一截。收进结构后,函数只收一个 Queue*,由它去改自己内部的 phead、ptail。这是上篇封装的延续:内部状态对调用方不可见,接口只留一份。
ptail 也不是装饰品。没有它,每次入队都得从 phead 一路找到尾,O(n)------练习代码头文件里的注释写的正是这句话。有了它,入队 O(1)。
入队与出队
c
void QueuePush(Queue* pq, QdataType x) { // 队尾入队
assert(pq);
QNode* newnode = (QNode*)malloc(sizeof(QNode));
if (newnode == NULL) {
perror("malloc fail");
return; // 内存没给够,别硬塞
}
newnode->val = x;
newnode->next = NULL;
if (pq->ptail == NULL) { // 空队:头尾都指向它
pq->phead = pq->ptail = newnode;
} else { // 有人排队:接在队尾后面
pq->ptail->next = newnode;
pq->ptail = newnode;
}
pq->size++;
}
📖 参考:《高质量C++/C编程指南》第7章------malloc 申请后应立刻检查返回值是否为 NULL(规则7-2-1)。
出队删队头,藏着一个最隐蔽的坑------删到最后一个人时:
c
void QueuePop(Queue* pq) { // 队头出队
assert(pq);
assert(pq->size); // 空队再弹 = 未定义行为,先拦下
if (pq->phead->next == NULL) { // 只剩最后一个人
free(pq->phead);
pq->phead = pq->ptail = NULL; // 头尾一起清!只清 phead,ptail 就悬垂了
} else {
QNode* next = pq->phead->next; // 先记住第二个,再释放第一个
free(pq->phead);
pq->phead = next;
}
pq->size--;
}
QdataType QueueFront(Queue* pq) {
assert(pq);
assert(pq->phead); // 空队取队头 = 解引用空指针
return pq->phead->val;
}
bool QueueEmpty(Queue* pq) {
assert(pq);
return pq->size == 0;
}
多数情况下头删只需把 phead 往后挪一格。但删到最后一个 节点时,ptail 还指着那块刚被 free 掉的内存------不把它一并置 NULL,下一次 QueuePush 的判空会失效,新节点会接在一个悬垂指针后面。删除时多问一句"还有哪个指针指着它",这种敏感度后面写树、写链表全程通用。
📖 参考:《高质量C++/C编程指南》第6章------用 assert 在入口检查"不应该发生"的情况,是防御式编程的第一步。
循环队列:数组想排队,就把路接成环
链表队列没有上限问题。但如果硬要用数组实现队列(固定大小的硬件缓冲、生产者-消费者模型),会撞上一个尴尬:出队后 front 下标往前走,数组前面腾出来的空位再也用不上 ------物理有空间,逻辑上却满了。这叫假溢出。
补救是把数组头尾接起来当环用,下标加一后对容量取模,这就是循环队列(Circular Queue)。它引出两个必须背熟的数学结论:
- 空还是满,分不清 :
front == rear既可能是空,也可能是满。两种解法:牺牲一个单元不存数据,判满用(rear + 1) % N == front;或者额外加一个size计数(本课链队就是这样)。课件里那道Q(1:100)的题,出现front == rear时问队里有几个元素,答案正是"0 或者 100"------除非你记了size,否则无解。 - 有效长度公式 (牺牲一个单元的写法):
(rear - front + N) % N。加N是为防止rear绕圈后小于front出现负数,再取模把结果拉回[0, N)。
📖 参考:《数据结构知识库》第3节------队列:顺序队列用循环队列避免"假溢出";队空队满判定需牺牲一个单元或另加 size 计数。
环形队列用数组或循环链表都能实现。它是本课的"扩展了解",主实现仍是链表。但很多嵌入式缓冲、操作系统队列是固定大小、不扩容、无 malloc 的环,这套下标取模的数学是那里唯一正确的写法。
代码演示一:遍历会清空队列
与上篇栈的演示一对称------给队列写个"从头打印全部元素"的 Print?别写。从头到尾打印一遍,本身就违反了"只能从队头取"的纪律。合法的访问姿势同样是循环"看队头 → 出队",副作用是队列被清空:
c
while (!QueueEmpty(&q)) {
QdataType x = QueueFront(&q); // 看最老的那一位
QueuePop(&q); // 处理完就放他走,下一个才轮到队头
printf("%d ", x); // 对 x 做点什么......
}
QueueDestory(&q); // 用完释放,别忘了
栈课说过"取一个弹一个,符合处理完最近的语义";这里同理:队列永远只让你碰最老的一个。想访问全部,就得付出清空的代价------这不是 bug,是纪律。
代码演示二:LeetCode 232 用两个栈实现队列
题目(用栈实现队列):只允许用栈的接口,实现一个先进先出的队列,提供 push、pop、peek、empty。
栈和队列互为镜像:栈顶是最新的,队头是最老的。要让"栈顶变成最老的",办法是把数据倒一次:先进 push 栈的元素,一次性全部倒进另一个 pop 栈,次序正好反个身,pop 栈顶就成了最早入队的那个。
核心在于"倒"的时机------只在 pop 栈空的时候才倒:
c
// 保证 popST 里一定有一份"可出队"的数据
void pushToPop(MyQueue* obj) {
if (STEmpty(&obj->popST)) { // pop 栈空,才需要倒
while (!STEmpty(&obj->pushST)) { // 把 push 栈全部倒空
STPush(&obj->popST, STTop(&obj->pushST));
STPop(&obj->pushST);
}
}
}
其余四个方法因此都很短:
c
void myQueuePush(MyQueue* obj, int x) {
STPush(&obj->pushST, x); // 新来的先进 push 栈排队
}
int myQueuePop(MyQueue* obj) {
pushToPop(obj); // 要出队,才检查要不要倒
int front = STTop(&obj->popST); // 倒过之后,栈顶 = 最早入队者
STPop(&obj->popST);
return front;
}
int myQueuePeek(MyQueue* obj) {
pushToPop(obj); // 同 pop,只是不弹掉
return STTop(&obj->popST);
}
bool myQueueEmpty(MyQueue* obj) {
return STEmpty(&obj->pushST) && STEmpty(&obj->popST);
}
(MyQueue 结构、create 与 free 略------OJ 提交时需把上篇那整套 ST 实现连同本段一并贴入。)
用一组调用序列验证"倒"只发生一次:
| 操作序列 | push 栈 | pop 栈 | 返回 |
|---|---|---|---|
| push(1) push(2) push(3) | 1 2 3 | 空 | -- |
| pop() | 空 | 倒成 3 2 1,弹顶 | 1 |
| push(4) | 4 | 3 2 | -- |
| pop() | 空 | 弹顶 | 2 |
| pop() | 空 | 弹顶 | 3 |
每批元素只在需要时被倒一次,之后连续出队都不再倒------摊还下来每个操作平均 O(1)。两次结构互相转,代码几乎全是"搬运",这正好验证上篇的结论:接口设计得当,调用方永远不需要知道底下是栈还是队列。
常见误区
- 空队列直接 pop / 取队头 :解引用空指针,读到随机值。自研代码用 assert 兜底;OJ 上没人帮你断言,要手动
QueueEmpty判断。 - 删到只剩一个节点,忘了清
ptail:phead挪走后ptail变成悬垂指针。特判phead->next == NULL,头尾一起置空。 - 照搬上篇"数组更优"到队列:栈的删除在尾部、队列的删除在头部------头删用数组要搬 n 个元素。选数组还是链表,看增删在哪一端,不是哪个容器更高级。
- 把
front == rear直接当"空" :它也可能是满。不记size或牺牲一个单元,判空判满就撞车。 - 在队列里"删除第 i 个元素 / 按值查找":那叫顺序表或链表,不叫队列。想绕开 FIFO 去翻中间,就是逃票走野路------省了排队,丢了纪律。
- 以为"入队 1 2 3 4,出队必为 1 2 3 4"只在全部入完时成立 :恰恰相反,队列比栈更严格------栈可以边入边出地改变全局次序,而队列只要遵守 FIFO,出队次序恒等于入队次序,无论怎么穿插。
本节要点
- 队列 = 先进先出(FIFO);队尾入、队头出,接口只有七八个,中间操作一律不提供。
- 实现选择的判据是增删落在哪一端:栈删在尾所以数组优,队列删在头所以链表优。
phead、ptail收进Queue一个结构,免去二级指针;ptail让入队 O(1)。- 出队删最后一个节点时,必须把
phead、ptail一起 置空,否则ptail悬垂。 - 顺序数组实现会"假溢出",循环队列是补救:
front == rear空满难分,需要size或牺牲一个单元;有效长度(rear - front + N) % N。 - 用两个栈实现队列:只在 pop 栈空时倒一次,摊还 O(1)。
📖 参考:《数据结构知识库》第3节(队列);《高质量C++/C编程指南》第6章(assert 防御式编程)、第7章(malloc 返回值检查)。