【第五章】队列

文章目录

上一章我们介绍了栈这一种特殊的线性结构。栈的特点是后进先出,所有操作都围绕栈顶展开。而和栈经常一起出现的另一种结构,就是队列。它们都属于受限线性表,但是它们的规则恰好不同。也正因为这个不同,队列在应用场景上和栈形成了非常鲜明的对比。

这一章我们就来系统介绍队列,并继续按照前面几章的写法,把概念、操作、复杂度以及 C 和 Java 中手动造轮子的部分都补齐。


队列的基本概念

队列同样是一种特殊的线性表。它的特殊之处在于:

只允许在一端插入,在另一端删除。

通常来说:

  • 插入的一端叫做队尾
  • 删除的一端叫做队头

也就是说,新来的元素只能从后面进入,原来已经在前面的元素则要先出去。

先进先出

队列最核心的特点就是:

text 复制代码
First In First Out

也就是我们经常说的 FIFO,中文一般翻译成"先进先出"。

这个规则可以把它想象成排队:

  • 先来的人先站到前面
  • 后来的人只能排到后面
  • 最终也是前面的人先离开

例如依次进入队列:

text 复制代码
1 2 3 4

如果按队列规则出队,那么顺序仍然是:

text 复制代码
1 2 3 4

这就是先进先出。


队列中的常见操作

和栈一样,队列的基本操作也比较固定。无论是自己手写实现,还是后面使用 Java 中的队列接口,核心都离不开下面这些操作。

入队

入队就是把新元素放到队尾。

常见英文写法一般为:

text 复制代码
offer / enqueue

如果把过程展开来看,那么队列的"增"其实就是在队尾增加一个元素。

例如原来队列从队头到队尾依次是:

text 复制代码
1 2 3

这时候如果要入队一个 4,那么一般步骤就是:

1.先判断队列是否还能继续放元素

2.如果可以,就把 4 放到当前队尾后面

3.然后更新队尾位置

最终队列就会变成:

text 复制代码
1 2 3 4

所以说,队列的"增"一定发生在后面,而不是前面。

出队

出队就是把队头元素删除。

常见英文写法一般为:

text 复制代码
poll / dequeue

队列的"删"则恰好发生在另一端,也就是队头。

例如当前队列从队头到队尾是:

text 复制代码
1 2 3 4

如果执行一次出队,那么删除的一定是最前面的 1。对应步骤一般可以理解为:

1.先判断队列是否为空

2.如果不为空,就取出当前队头元素

3.让队头向后移动一格,或者让头指针指向下一个结点

最终队列就会变成:

text 复制代码
2 3 4

这也就体现出了队列和栈的明显不同:队列是一头进,一头出。

获取队头元素

有时候我们只是想看一眼当前队头是谁,而不是立即把它删除,这时就会用到获取队头元素的操作。

常见写法一般为:

text 复制代码
peek / front

这个操作其实就可以理解成队列中的"查"。只不过这里查的并不是任意位置元素,而是当前最前面的那个元素。

例如当前队列是:

text 复制代码
1 2 3 4

那么获取队头元素时,得到的就是 1。并且这个操作只是查看,不会把 1 真正删掉。

判断队列是否为空

在出队或取队头之前,一般都要先判断队列是否为空,否则就会出现非法操作。

获取队列中元素个数

很多场景下还需要知道当前队列里有多少个元素,因此通常也会提供长度相关操作。

如果从"改"的角度来看,队列和栈其实很像,它也不强调去修改中间任意一个元素。因为队列真正重要的是维护先进先出的顺序,所以它更关注的是:

  • 队尾怎么进
  • 队头怎么出
  • 当前队头是谁

也就是说,队列的重点不是"改值",而是维持规则。


队列的顺序存储与链式存储

和栈类似,队列也可以用不同方式实现。常见的主要有两种:

1.顺序存储

2.链式存储

顺序存储实现队列

如果直接用数组实现普通队列,那么会遇到一个很典型的问题:

  • 出队以后,前面的空间空出来了
  • 但如果只是单纯让队尾继续向后走,数组前面空出来的空间就会浪费

所以为了更合理地利用空间,数组实现队列时通常会引出一个非常重要的结构,也就是循环队列。

循环队列本质上就是:

把数组头尾连起来用。

这样一来,即使前面出队腾出空间,后面也可以继续复用这些位置。

如果把这个问题再说得更直白一点,那么普通顺序队列的问题在于:

  • 队头不断往后移动
  • 队尾也不断往后移动
  • 前面明明已经空出来了,但是后面却可能先用完

这就会造成一种"假溢出"现象。也就是说,数组里并不是一点位置都没有了,而是后面的空间用完了,前面的空间却没法直接复用。

例如:

text 复制代码
初始: [ ][ ][ ][ ]
入队1: [1][ ][ ][ ]
入队2: [1][2][ ][ ]
入队3: [1][2][3][ ]
出队1: [ ][2][3][ ]
出队2: [ ][ ][3][ ]
入队4: [ ][ ][3][4]

这时候如果继续入队,队尾已经到数组最后了,但前面其实还有空位。如果不用循环队列,这些空位就浪费掉了。

而循环队列的思想就是:

队尾走到数组末尾以后,如果前面有空位,就从数组开头继续用。

所以它本质上不是让数据真的变成一个圆,而是让下标的使用方式变成"绕圈"。

循环队列中的判空与判满

循环队列里最关键的地方,除了"绕回来继续用",还有判空和判满。

通常来说:

  • 队空:front == rear
  • 队满:(rear + 1) % capacity == front

这里为什么队满不能也写成 front == rear

因为如果不专门空出一个位置,那么队空和队满的状态就会冲突,不好区分。所以循环队列里很常见的一种处理方式,就是:

故意少用一个位置。

这样就能通过头尾关系把队空和队满区分开。

循环队列中的入队与出队过程

如果采用循环队列,那么:

  • 入队时先把元素放到 rear 所在位置
  • 然后让 rear = (rear + 1) % capacity

出队时则是:

  • 先取出 front 位置的元素
  • 然后让 front = (front + 1) % capacity

也就是说,循环队列本质上是把原来直线增长的下标,变成了按模运算循环前进。

链式存储实现队列

如果用链表来实现队列,那么通常只要维护:

  • 队头指针
  • 队尾指针

这样一来:

  • 入队时在尾部插入
  • 出队时在头部删除

这种实现方式逻辑清晰,也比较自然。

在很多教学代码里,链式队列往往比顺序队列更容易先讲清楚。因为顺序队列如果不引入循环队列,空间利用率问题会比较明显。


队列的优缺点

队列作为一种受限线性表,也有自己的优点和局限。

优点

非常适合处理先进先出场景

只要一个问题本身符合"先来的先处理,后来的后处理"这种规则,那么队列往往就是非常自然的选择。

入队出队规则清晰

队列的使用方式很固定,一端进,一端出,因此很多问题建模时都比较直观。

应用场景广

队列的典型应用场景非常多,例如:

  • 排队系统
  • 广度优先搜索
  • 操作系统任务调度
  • 打印任务排队
  • 消息队列思想

这些问题背后其实都能看到队列结构的影子。

缺点

不能随机访问中间元素

队列本质上只关心队头和队尾,中间元素一般不能像顺序表那样随意访问。

使用受先进先出规则限制

如果一个问题本身不满足先进先出的特征,那么强行用队列就不一定合适。

顺序实现时可能涉及空间浪费问题

如果只是简单数组队列而不做循环处理,那么随着不断入队出队,数组前面的空间可能被浪费。


复杂度分析

对于队列来说,复杂度分析一般也比较清晰,因为大部分操作都集中在队头和队尾。

入队

如果队列实现合理,比如链式队列维护了尾指针,或者顺序队列使用了循环数组,那么入队通常都只需要改动少量变量,因此时间复杂度一般为:

text 复制代码
O(1)

出队

出队本质上就是删除队头元素,因此如果结构设计合理,时间复杂度一般也为:

text 复制代码
O(1)

获取队头元素

队头通常是直接可知的,因此获取队头元素的时间复杂度一般也是:

text 复制代码
O(1)

判空

无论是通过大小判断,还是通过头尾关系判断,判空通常也都只需要常数次操作,因此复杂度为:

text 复制代码
O(1)

所以队列和栈一样,在局部操作上的效率通常也都很高。


C和Java中手动实现

前面把概念讲完以后,队列这一章同样还是要落到"自己动手写一遍"上。因为只要自己真正写过一次队列,尤其是写过循环队列或者链式队列以后,很多规则就不再只是抽象概念了。

这里我们仍然分成两部分:

1.C语言手动实现队列

2.Java中手动实现队列

C语言手动实现队列

在 C 语言中,队列常见的手写方式有两种:

1.链式队列

2.循环队列

这里先讲最容易理解的一种,也就是链式队列。

如果采用链表来实现队列,那么一般需要一个结点结构:

c 复制代码
typedef int QDataType;

typedef struct QListNode
{
    QDataType data;
    struct QListNode* next;
}QNode;

同时还要维护一个队列结构:

c 复制代码
typedef struct Queue
{
    QNode* head;
    QNode* tail;
}Queue;

这里:

  • head 指向队头
  • tail 指向队尾

初始化:

c 复制代码
void QueueInit(Queue* pq)
{
    pq->head = NULL;
    pq->tail = NULL;
}

入队:

c 复制代码
void QueuePush(Queue* pq, QDataType x)
{
    QNode* newnode = (QNode*)malloc(sizeof(QNode));
    if(newnode == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }

    newnode->data = x;
    newnode->next = NULL;

    if(pq->tail == NULL)
    {
        pq->head = pq->tail = newnode;
    }
    else
    {
        pq->tail->next = newnode;
        pq->tail = newnode;
    }
}

出队:

c 复制代码
void QueuePop(Queue* pq)
{
    assert(pq);
    assert(pq->head);

    QNode* next = pq->head->next;
    free(pq->head);
    pq->head = next;

    if(pq->head == NULL)
    {
        pq->tail = NULL;
    }
}

获取队头元素:

c 复制代码
QDataType QueueFront(Queue* pq)
{
    assert(pq);
    assert(pq->head);
    return pq->head->data;
}

从这里就能看出来,链式队列的核心其实非常清楚:

  • 入队:往尾巴接
  • 出队:从头删

除了链式队列之外,在顺序存储这一块,循环队列也是必须掌握的一种写法。

如果用数组实现循环队列,那么通常会维护:

  • 数组本身
  • 队头下标 front
  • 队尾下标 rear
  • 数组容量

结构体一般可以这样定义:

c 复制代码
typedef struct CircularQueue
{
    QDataType* a;
    int front;
    int rear;
    int capacity;
}CQ;

初始化:

c 复制代码
void CircularQueueInit(CQ* pq, int k)
{
    pq->a = (QDataType*)malloc(sizeof(QDataType) * (k + 1));
    pq->front = 0;
    pq->rear = 0;
    pq->capacity = k + 1;
}

这里为什么是 k + 1

因为前面已经说过,循环队列中经常会故意多开一个位置,用来区分队空和队满。

判空:

c 复制代码
int CircularQueueEmpty(CQ* pq)
{
    return pq->front == pq->rear;
}

判满:

c 复制代码
int CircularQueueFull(CQ* pq)
{
    return (pq->rear + 1) % pq->capacity == pq->front;
}

入队:

c 复制代码
void CircularQueuePush(CQ* pq, QDataType x)
{
    assert(!CircularQueueFull(pq));
    pq->a[pq->rear] = x;
    pq->rear = (pq->rear + 1) % pq->capacity;
}

出队:

c 复制代码
void CircularQueuePop(CQ* pq)
{
    assert(!CircularQueueEmpty(pq));
    pq->front = (pq->front + 1) % pq->capacity;
}

从这里就能很明显地看出来,循环队列最核心的东西其实就是模运算。只要把 frontrear 的变化规律理顺,循环队列就没那么神秘了。

Java中手动实现队列

Java 中手写队列时,也可以像 C 语言一样,自己维护一个结点类和头尾引用。

例如:

java 复制代码
class Node {
    public int val;
    public Node next;

    public Node(int val) {
        this.val = val;
    }
}

然后定义一个队列类:

java 复制代码
public class MyQueue {
    private Node head;
    private Node tail;
    private int usedSize;
}

这里:

  • head 是队头
  • tail 是队尾
  • usedSize 表示当前元素个数

入队:

java 复制代码
public void offer(int val) {
    Node node = new Node(val);
    if (head == null) {
        head = tail = node;
    } else {
        tail.next = node;
        tail = node;
    }
    usedSize++;
}

出队:

java 复制代码
public int poll() {
    if (isEmpty()) {
        throw new RuntimeException("队列为空");
    }

    int oldVal = head.val;
    head = head.next;
    if (head == null) {
        tail = null;
    }
    usedSize--;
    return oldVal;
}

获取队头元素:

java 复制代码
public int peek() {
    if (isEmpty()) {
        throw new RuntimeException("队列为空");
    }
    return head.val;
}

判空:

java 复制代码
public boolean isEmpty() {
    return usedSize == 0;
}

所以 Java 中手写队列,本质上和 C 语言思路依然是一样的,只不过不需要再自己手动释放结点空间。

如果在 Java 中想手写循环队列,那么思路和 C 语言也是一样的,只不过写法更偏面向对象。

例如可以定义:

java 复制代码
public class MyCircularQueue {
    private int[] elem;
    private int front;
    private int rear;
}

初始化时一般会故意多开一个位置:

java 复制代码
public MyCircularQueue(int k) {
    elem = new int[k + 1];
}

判空:

java 复制代码
public boolean isEmpty() {
    return front == rear;
}

判满:

java 复制代码
public boolean isFull() {
    return (rear + 1) % elem.length == front;
}

入队:

java 复制代码
public boolean enQueue(int value) {
    if (isFull()) {
        return false;
    }
    elem[rear] = value;
    rear = (rear + 1) % elem.length;
    return true;
}

出队:

java 复制代码
public boolean deQueue() {
    if (isEmpty()) {
        return false;
    }
    front = (front + 1) % elem.length;
    return true;
}

获取队头元素:

java 复制代码
public int Front() {
    if (isEmpty()) {
        return -1;
    }
    return elem[front];
}

获取队尾元素:

java 复制代码
public int Rear() {
    if (isEmpty()) {
        return -1;
    }
    return elem[(rear - 1 + elem.length) % elem.length];
}

所以说,Java 中手写循环队列,关键点也没有变,依然是:

  • front 指向队头
  • rear 指向下一个可插入位置
  • 通过模运算实现"绕回去"

手动造轮子的意义

很多同学在学队列的时候,直接会去用:

  • Java 中的 Queue
  • C++ 中的 queue

这些当然没有问题,但如果完全不自己写一遍,那么对队列底层的理解往往还是停留在"会用接口"的层面。

真正自己写过一遍以后,通常就会更清楚地理解:

  • 为什么队列要维护队头和队尾
  • 为什么入队出队能做到 O(1)
  • 为什么顺序实现队列时会引出循环队列

所以"手动造轮子"这一环节依然很重要。


Java中队列的分析

在 Java 中,队列通常不是直接使用某个叫 Queue 的具体类,而更多是通过接口加实现类的方式来使用。

例如:

java 复制代码
Queue<Integer> queue = new LinkedList<>();

或者:

java 复制代码
Queue<Integer> queue = new ArrayDeque<>();

这里常见操作一般包括:

  • offer()
  • poll()
  • peek()

从底层思想上看,这些接口本质上依然是在维护先进先出的规则。

在实际使用中,ArrayDeque 往往是一个比较常见的选择,因为它的综合表现通常比较好。


C++中对于queue的分析

在 C++ STL 中,也提供了一个非常典型的队列适配器,也就是 queue

stack 类似,queue 也更像是一种行为限制器。它会把底层容器的使用方式限制成"队列应该有的样子"。

也就是说,queue 的核心并不在于展示所有底层细节,而在于:

只暴露先进先出该有的那部分操作。

常见操作包括:

  • push
  • pop
  • front
  • back
  • empty
  • size

这和我们手写队列时维护的那些核心能力,其实是一致的。


队列和栈的对比

讲到这里,可以把队列和栈放在一起做个非常直观的比较。

对比项 队列
规则 后进先出 先进先出
插入位置 栈顶 队尾
删除位置 栈顶 队头
典型应用 括号匹配、DFS、函数调用 BFS、排队系统、任务调度

从这个表里也能看出来,栈和队列虽然都属于受限线性表,但它们的使用逻辑其实是完全不同的。


本章小结

队列是一种特殊的线性表,它只允许在一端插入,在另一端删除,因此具有非常典型的先进先出特点。也正因为这个规则明确,所以队列在广度优先搜索、任务调度、消息处理以及各种排队模型中都有非常广泛的应用。

本章主要介绍了队列的基本概念、常见操作、优缺点、复杂度分析,同时也补充了 C 和 Java 中手动实现队列的部分。对于队列这一章来说,真正重要的依然不是记住几个函数名,而是弄清楚"为什么队列一定是一头进、一头出"。只要这个规则理解清楚了,后面的循环队列、双端队列甚至优先级队列,也都会更容易接受。


好的,那么本章关于队列的内容就先介绍到这里。到这里为止,顺序表、链表、栈、队列这些最基础的线性结构就都已经接触过了。后面继续学习树、堆、图的时候,这一部分打下的基础会越来越重要。

相关推荐
吃好睡好便好1 小时前
MATLAB仿真框图3
开发语言·matlab·仿真·simulink
happy_baymax1 小时前
MATLAB MCP Server+ MATLAB Agentic Toolkit+Simulink Agentic Toolkit自动更新批处理文件.ps1
人工智能·算法
-银雾鸢尾-1 小时前
C#中的反射关键类-Type
开发语言·c#
大黄说说1 小时前
Java 与 Kotlin 混合开发避坑指南:老项目平滑迁移 Kotlin 实操手册
java·开发语言·kotlin
breeze jiang1 小时前
JavaScript 单例模式:用静态属性保证 Popup 只创建一次
开发语言·javascript·单例模式
Doraemomo2 小时前
数据结构-内存调试工具
数据结构
闪闪发亮的小星星2 小时前
STK中计算两星距离
笔记
Thom5802 小时前
【迅投 QMT】QMT如何获取ETF申赎清单?download_etf_info()与get_etf_info()教程
人工智能·经验分享·量化交易·ptrade·量化编程
橘和柠2 小时前
知识图谱实战(一):数据采集与实体关系抽取(完整代码)
算法