【无标题】

队列:一座只允许排队的容器

为什么需要

顺序表、链表给出的自由度,上一课被栈收走一半:只动栈顶,"最近的最优先"。可现实里更常见的秩序恰好反过来------先来的人先被服务。食堂排队、打印机任务队列、进程排队等待,规则只有一条:别插队,来得早的处理得早。

与其每次手写这种约束,不如抽象成一个容器:队列(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*,由它去改自己内部的 pheadptail。这是上篇封装的延续:内部状态对调用方不可见,接口只留一份。

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 判断。
  • 删到只剩一个节点,忘了清 ptailphead 挪走后 ptail 变成悬垂指针。特判 phead->next == NULL,头尾一起置空。
  • 照搬上篇"数组更优"到队列:栈的删除在尾部、队列的删除在头部------头删用数组要搬 n 个元素。选数组还是链表,看增删在哪一端,不是哪个容器更高级。
  • front == rear 直接当"空" :它也可能是满。不记 size 或牺牲一个单元,判空判满就撞车。
  • 在队列里"删除第 i 个元素 / 按值查找":那叫顺序表或链表,不叫队列。想绕开 FIFO 去翻中间,就是逃票走野路------省了排队,丢了纪律。
  • 以为"入队 1 2 3 4,出队必为 1 2 3 4"只在全部入完时成立 :恰恰相反,队列比栈更严格------栈可以边入边出地改变全局次序,而队列只要遵守 FIFO,出队次序恒等于入队次序,无论怎么穿插。

本节要点

  • 队列 = 先进先出(FIFO);队尾入、队头出,接口只有七八个,中间操作一律不提供。
  • 实现选择的判据是增删落在哪一端:栈删在尾所以数组优,队列删在头所以链表优。
  • pheadptail 收进 Queue 一个结构,免去二级指针;ptail 让入队 O(1)。
  • 出队删最后一个节点时,必须把 pheadptail 一起 置空,否则 ptail 悬垂。
  • 顺序数组实现会"假溢出",循环队列是补救:front == rear 空满难分,需要 size 或牺牲一个单元;有效长度 (rear - front + N) % N
  • 用两个栈实现队列:只在 pop 栈空时倒一次,摊还 O(1)。

📖 参考:《数据结构知识库》第3节(队列);《高质量C++/C编程指南》第6章(assert 防御式编程)、第7章(malloc 返回值检查)。


相关推荐
zcmodeltech2 小时前
冷链物流沙盘模型控制系统设计——基于STM32与Modbus RTU的冷藏车、冷库全流程动态展示方案
数据结构·stm32·单片机·嵌入式硬件·物联网·能源
Shan12053 小时前
经典算法题示例与详解:飞地的数量(二)
java·数据结构·算法
小七在进步6 小时前
类和对象(一)
java·数据结构·算法
charlie1145141916 小时前
浅谈C++的列表初始化std::initializer_list
开发语言·数据结构·c++·list·开源项目
一个初入编程的小白10 小时前
数据结构:二叉树链式结构的实现
数据结构
雨落在了我的手上17 小时前
Java数据结构(十一):优先级队列(堆)
数据结构
带多刺的玫瑰1 天前
Leecode#26刷题之删除有序数组中的重复项
数据结构·算法·leetcode
洋不写bug1 天前
二叉树(二) 常见基础操作解析|结点数、树高、查找结点、判断完全二叉树
java·开发语言·数据结构·完全二叉树·二叉树结点数·树高·查找结点
化雨成烟1 天前
C++之AVL树
数据结构