文章目录
一、队列的实现
想要理解队列很简单,因为它的性质和我们日常生活中的排队很类似,线性、先到先处理、后到排末尾。很显然,队列也是一种线性表,但比线性表的要求更严格,线性表可以对中间的元素进行访问和操作,队列则只能从首尾两端访问或操作元素,队列是一种操作受限的线性表,队列的所有特性都可以由线性表实现。
可能你会疑惑:既然链表、数组、游标数组都可以实现队列的所有特性,我们为何还要为队列单独实现一种数据结构呢?从功能上来说,数组或链表确实可以代替队列,但你要知道,特定的数据结构是对特定场景的抽象。而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。为队列专门实现一个数据结构,可以限制对队列的操作,保证其”先到先处理、后到排末尾“的规矩。
队列作为一种符合”先进先出(FIFO—first in first out)“特性的数据结构,应用非常广泛,特别是一些具有额外特性的队列,比如循环队列、阻塞队列、并发队列,它们在很多偏底层系统、框架、中间件的开发中,起着关键的作用,比如环形缓存就用到了循环队列。
队列作为一种操作受限的线性表,完全可以由任意一种线性表来实现,顺序表实现的队列称为顺序队列,链式表实现的队列称为链式队列。由于队列只有入队enqueue()、出队dequeue()两个操作,不需要随机访问,所以顺序队列与链式队列的选择,主要考虑队列容量的可扩展性,假如队列容量大小预期变化不大,可以使用数组实现队列;假如队列容量在后期不断增加,则可以使用链表实现队列,当然也可以使用变长数组实现,C++ STL队列容器就是使用双向变长数组deque实现的。
1.1 顺序队列的实现
前面介绍顺序表时提到,数组随机访问效率比较高,插入、删除元素需要涉及其后元素的搬移操作,因此效率较低。队列需要的两种操作:队尾入队、队首出队,实际上就是元素的尾部插入与首部删除,数组删除首部元素需要后面的所有元素全部向前搬移一个位置,会导致数组实现的队列出队效率很低。
既然顺序队列出队效率低的原因是元素搬移操作,我们能否让存储单元不动,借鉴游标数组的技巧,分别使用一个队首游标与队尾游标来实现队列呢?就好像早期的工厂流水线,机器或零部件不动,工人移动,完成组装焊接等操作,比来回搬移机器零部件效率提升了不少。
使用队首与队尾游标完成入队与出队操作,确实能省去元素搬移的操作,提高效率。但细想后发现,伴随着入队、出队操作,队尾与队首游标会不断后移,数组的地址空间是有限的,当队尾游标移动到数组的末尾后,就没法继续插入新元素了,如果数组中还有空位置,就需要想办法重新利用这些空位置。怎么重复利用已从队首取出元素留下的空位置呢?
按照线性思维,很容易想到,当队尾游标移动到数组末尾后,把队首游标到队尾游标之间的元素(也即队列中的有效元素)全部搬移到数组最前面,不就能重新利用前面已被队列使用过的空位置吗?就像下面图示的:
上面的方法虽说比每次出队都搬移元素的效率高了不少,每当队尾游标到末尾时才整体搬移一次全部队列元素,但还是免不了搬移元素导致的效率降低。有没有什么方法能避免元素的搬移呢?
回想下前面介绍的循环链表,既然链表可以实现循环访问,游标数组自然也可以模拟循环链表的特性。当队尾游标后移到数组末尾时,让其重新移回到数组起始位置不就可以实现循环访问了吗,毕竟游标也是显式存储的元素间指向关系。
实现循环访问的队列称为循环队列,循环队列既可以通过循环链表实现,也可以通过循环游标数组实现。循环队列的逻辑结构图示如下(为了更形象,以环形展示):
循环队列就可以很好的避免数据搬移操作,效率比着前两种方案能提升很多。接下来看看循环队列如何实现?
循环队列的入队和出队操作比较简单,入队时只需要只需要将数据保存到队尾游标指向的存储单元,并将队尾游标后移一位即可;出队时只需要将队首游标指向的存储单元中的数据取出,并将队首游标后移一位即可。对于线性表来说,最重要的问题是防止访问越界,对于循环队列来说,最重要的问题则是确定队列为空和队列已满的判定条件。
队列为空比较好判断,队首与队尾游标指向同一个存储单元即表示队列为空。队列已满的判断条件在非循环队列中是队尾游标到数组末尾边界了,对于循环队列来说如何判定呢?下面先看一个循环队列已满的状态图示:
从上面的循环队列已满状态图可以看出,循环队列已满时队尾游标指向的位置实际上并没有存储数据,循环队列会浪费一个数组的存储空间。当队列已满时,按照游标增长方向,队尾指针后移一位会与队首指针重合,这个可以看作循环队列已满的判定条件(也即tail + 1 = head)。但循环队列到末尾后,每次重新让其指向首部比较麻烦,既然涉及到循环,我们很容易想到取模运算,假设数组大小为n,则循环队列已满的判定条件就是(tail + 1)%n = head。
由于循环队列的队首游标与队尾游标处于变化中,我们需要经常访问队首与队尾游标,那么如何能快速访问该循环队列的队首与队尾游标呢?回顾下之前介绍单向链表时,使用了一个额外的链表头,不存放用户数据信息,只存放该链表的统计信息,包括链表内元素数目、指向首结点的指针、指向尾结点的指针等信息。我们可以借鉴这种技巧,也为循环队列提供一个队列头,用于存储该循环队列的队首游标、队尾游标、队列中有效元素数量、顺序表首地址、顺序表容量等信息,这些信息可以封装在一个结构体内,封装示例如下:
// datastruct\queue.c
struct QueueHead{
int capacity;
int haed;
int tail;
int size;
ElementType *Array;
};
typedef struct QueueHead *Queue;
- 循环队列创建与销毁
有了循环队列头,要想执行入队、出队操作,需要先创建循环队列,循环队列的创建与删除函数实现代码如下:
// datastruct\queue.c
Queue Create(int capacity)
{
Queue Q = malloc(sizeof(struct QueueHead));
if(Q == NULL)
{
printf("Out of space!");
return NULL;
}
Q->Array = malloc(capacity * sizeof(ElementType));
if(Q->Array == NULL)
printf("Out of space!");
Q->capacity = capacity;
Q->head = 0;
Q->tail = 0;
Q->size = 0;
return Q;
}
void Destroy(Queue Q)
{
free(Q->Array);
free(Q);
}
- 循环队列入队与出队:
有了上面的循环队列为空、已满的判定条件,实现其入队与出队函数就简单了,实现代码如下:
// datastruct\queue.c
bool Enqueue(Queue Q, ElementType x)
{
if((Q->tail + 1)%Q->capacity == Q->head)
return false;
Q->Array[Q->tail] = x;
Q->tail = (Q->tail + 1) % Q->capacity;
Q->size++;
return true;
}
ElementType Dequeue(Queue Q)
{
if(Q->head == Q->tail)
return (ElementType)NULL;
ElementType ret = Q->Array[Q->head];
Q->head = (Q->head + 1) % Q->capacity;
Q->size--;
return ret;
}
接下来给出一个循环队列操作的示例程序,为了更直观,实现了一个打印循环队列中所有有效元素的函数。上面出队的实现代码并不严谨,当队列为空时,假如ElementType为int,将返回0,我们无法判定返回的是队列已空还是队首元素值0(这个问题可以通过返回元素值地址的方式解决,返回NULL则表示队列已空,因为元素值地址不为NULL)。我们在操作循环队列时,最好养成入队与出队前,先判断队列是否已满或为空再继续操作。示例程序代码如下:
//datastruct\queue.c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define ElementType int
struct QueueHead{
int capacity;
int head;
int tail;
int size;
ElementType *Array;
};
typedef struct QueueHead *Queue;
Queue Create(int capacity);
bool Enqueue(Queue Q, ElementType x);
ElementType Dequeue(Queue Q);
void Destroy(Queue Q);
void printQueue(Queue Q)
{
printf("Queue has %d elements: ", Q->size);
int i = Q->head;
while(i != Q->tail)
{
if(i != Q->head)
printf(" ");
printf("%d", Q->Array[i]);
i = (i + 1) % Q->capacity;
}
printf("\n\n");
}
int main(void)
{
Queue Q = Create(100);
for(int i = 1; i < 9; i++)
{
if(Enqueue(Q, i * i) == false)
{
printf("The queue is full.\n");
break;
}
}
printf("\n");
printQueue(Q);
for(int i = 1; i < 7; i++)
{
if(Q->size == 0)
{
printf("The queue is empty.\n");
break;
}
printf("%d ", Dequeue(Q));
}
printf("\n");
printQueue(Q);
Destroy(Q);
return 0;
}
上述示例程序的执行结果如下:
1.2 链式队列的实现
使用链表不需要元素搬移,也不需要维持一个循环链表,入队时将新元素插入链表尾部,出队时移除链表首节点,并将链表原