文章目录
上一章我们介绍了栈这一种特殊的线性结构。栈的特点是后进先出,所有操作都围绕栈顶展开。而和栈经常一起出现的另一种结构,就是队列。它们都属于受限线性表,但是它们的规则恰好不同。也正因为这个不同,队列在应用场景上和栈形成了非常鲜明的对比。
这一章我们就来系统介绍队列,并继续按照前面几章的写法,把概念、操作、复杂度以及 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;
}
从这里就能很明显地看出来,循环队列最核心的东西其实就是模运算。只要把 front 和 rear 的变化规律理顺,循环队列就没那么神秘了。
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 的核心并不在于展示所有底层细节,而在于:
只暴露先进先出该有的那部分操作。
常见操作包括:
pushpopfrontbackemptysize
这和我们手写队列时维护的那些核心能力,其实是一致的。
队列和栈的对比
讲到这里,可以把队列和栈放在一起做个非常直观的比较。
| 对比项 | 栈 | 队列 |
|---|---|---|
| 规则 | 后进先出 | 先进先出 |
| 插入位置 | 栈顶 | 队尾 |
| 删除位置 | 栈顶 | 队头 |
| 典型应用 | 括号匹配、DFS、函数调用 | BFS、排队系统、任务调度 |
从这个表里也能看出来,栈和队列虽然都属于受限线性表,但它们的使用逻辑其实是完全不同的。
本章小结
队列是一种特殊的线性表,它只允许在一端插入,在另一端删除,因此具有非常典型的先进先出特点。也正因为这个规则明确,所以队列在广度优先搜索、任务调度、消息处理以及各种排队模型中都有非常广泛的应用。
本章主要介绍了队列的基本概念、常见操作、优缺点、复杂度分析,同时也补充了 C 和 Java 中手动实现队列的部分。对于队列这一章来说,真正重要的依然不是记住几个函数名,而是弄清楚"为什么队列一定是一头进、一头出"。只要这个规则理解清楚了,后面的循环队列、双端队列甚至优先级队列,也都会更容易接受。
好的,那么本章关于队列的内容就先介绍到这里。到这里为止,顺序表、链表、栈、队列这些最基础的线性结构就都已经接触过了。后面继续学习树、堆、图的时候,这一部分打下的基础会越来越重要。