【数据结构】队列

【数据结构】队列

概览

  • 队列的定义与先进先出
  • 循环队列与下标取模
  • 队空和队满的三种区分办法
  • 有效长度公式的推导
  • 队列在系统里的四处应用
  • 实测数据与边界值

核心知识

一、队列是什么

队列也是线性表,和栈一样,它对操作的位置做了限制,只是限制的方向相反。栈把操作都收在一端,队列把两端分开:插入只在队尾进行,删除只在队头进行。先进入队列的元素先离开,这条规则叫先进先出(First In First Out,FIFO)。

拿食堂排队打饭来理解最直接:新来的人站在队尾,打饭的窗口在队头,打完一个走一个。没有人从中间插进去,也没有人从队尾离开。队列里的元素只沿着一个方向移动,从队尾进来,往队头挪,最后从队头出去。

这个限制看起来让队列的能力变弱了:不能随机访问,不能从中间插队,连看一眼中间的元素都不行。换来的是顺序上的确定性。一批任务的提交顺序就是执行顺序,谁都插不进来,也就不需要额外的规则去决定先做哪一个。栈的确定性体现在最近的那个,队列的确定性体现在最早的那个,两者都是把选择权从使用方手里拿走,交给一条固定规则。

术语概览:

术语 含义
入队 在队尾放一个元素,元素个数加一
出队 把队头元素拿走,元素个数减一
队头 只能出元素的那一端
队尾 只能入元素的那一端
队空 队列里一个元素都没有,这时不能出队,也不能取队头
队满 只在容量固定的队列里才有这个概念

把栈和队列摆在一起看,能发现它们的接口条数完全一样:初始化、销毁、插入、删除、取端点、判空、取个数。区别只在于插入和删除分别发生在哪一端,而这个区别决定了它们各自适合处理什么问题。一批数据需要按进入的顺序处理,用队列;需要把最近的先处理,用栈。

两种底层的代价对照。 队列的实现方式决定了它的能力边界,链表实现和数组实现各有短板:

对比项 链表实现 数组实现(循环队列)
入队 O(1),每次要申请一个结点 O(1),容量满了只能报错
出队 O(1),每次要释放一个结点 O(1),下标加一取模
取队头、取队尾 O(1) O(1)
取元素个数 O(1),靠 size 成员 O(1),靠 size 或长度公式
内存形态 每个元素一块独立内存,另有指针开销 一块连续内存,容量固定
容量上限 没有上限,受限于可用内存 初始化时定死,之后不能改
缓存友好度 差,结点分散在堆上 好,元素挨在一起
适合的场景 长度事先不知道,或者波动很大 长度可控,追求速度和内存利用率

这张表里没有一处是绝对的优劣。链表把容量的问题交给分配器,代价是每个元素都要单独申请和释放,缓存也不友好;数组把容量在初始化时就定下来,换来的是连续内存和零分配开销。选哪一种,取决于队列的长度能不能提前预估。

图 1 队列的两端分工

二、链表实现

队列的底层可以用数组,也可以用链表。链表实现更常见,原因在于队列的两端都要动,而单链表恰好能在这两个位置都做到 O(1),前提是多存一个尾指针。

只存头指针的单链做不到这一点。尾插要走到最后一个结点,那是 O(n),队列最常用的操作变成最慢的一个。多存一个尾指针之后,尾插是改两个指针的动作,头删本来也只是一个指针的动作,两端都落在常数时间上。

队列的结点和队列本体分开定义:

c 复制代码
# 文件:/home/cocatrice/ds08/queue.h
typedef struct QNode
{
	QDataType data;
	struct QNode* next;
} QNode;

typedef struct Queue
{
	QNode* front;
	QNode* rear;
	int size;
} Queue;

三个成员各有分工。front 指向队头结点,出队从它下手;rear 指向队尾结点,入队挂在它后面;size 记录元素个数,让取个数的接口变成 O(1)。

size 这个成员可以有也可以没有。不记它,取个数就要从头到尾数一遍,是 O(n)。元素个数是使用方问得最多的一个值,用一个 int 把它记下来,代价是多四个字节,换来的是每次查询都是常数时间。

空队列要两个指针一起改。 队列为空时 front 和 rear 都是 NULL。出队把最后一个结点删掉之后,front 自然变成 NULL,rear 却还指着那块刚释放的内存,必须手动把它也置空。这一处漏掉,后面任何一次入队都会写到已经还给分配器的地址上。

图 2 链式队列的两个指针

另一种写法是加一个头结点。 在链表最前面放一个不存数据的结点,让 front 和 rear 一开始都指向它,空队列的判断就变成 front == rear,入队出队的代码里不再需要单独处理空队列的分支。代价是多申请一个结点,并且取队头元素要写成 front->next->data。两种写法都对,选一种并保持全篇一致即可。

接口一共八个:

c 复制代码
# 文件:/home/cocatrice/ds08/queue.h
void QueueInit(Queue* pq);
void QueueDestroy(Queue* pq);
void QueuePush(Queue* pq, QDataType x);
void QueuePop(Queue* pq);
QDataType QueueFront(Queue* pq);
QDataType QueueBack(Queue* pq);
int QueueSize(Queue* pq);
bool QueueEmpty(Queue* pq);

初始化和销毁负责生命周期,入队和出队是仅有的两个修改操作,取队头和取队尾只看不改,取个数和判空是查询。八个接口逐个过一遍,每一处都有需要拿捏的地方。

初始化和销毁管生命周期。 初始化把两个指针置空、计数清零。这一步不能省,声明在栈上的队列结构体里是一堆随机值,直接入队等于沿着野指针走。销毁沿着 next 一路释放,每一轮先把下一个结点的地址存进局部变量,再释放当前结点,顺序颠倒就取不到下一个地址了。

入队和出队是仅有的两个修改动作。 入队要处理两种情形:队列为空时新结点既是队头也是队尾,两个指针都要指向它;队列非空时新结点挂在 rear 后面,再把 rear 推到新结点上。出队只需要动队头,取走队头的数据、记住下一个结点、释放当前结点、让 front 前进一步,最后判断队列是否已经空了,空了要把 rear 一起置空。

取队头和取队尾只读不改。 两个函数都先做空队列的断言,再把值返回。队头取的是 front 的数据,队尾取的是 rear 的数据,因为 rear 指向的就是队尾结点本身,这里不需要任何偏移计算。

取个数和判空是查询接口。 判空看 front 是不是空指针,取个数直接返回 size。这两个接口在循环里被调用的次数最多,所以让它们都保持 O(1)。如果当初没有记 size,取个数就得遍历整条链表,每次循环都遍历一遍的话,整个程序的复杂度会被这一处拖垮。

还有一个位置容易被忽略:入队时申请结点失败。malloc 返回 NULL 时,如果直接给 node->data 赋值,立刻就是空指针解引用。这里用打印加 exit 结束程序,属于最省事的处理;库函数一般会把失败往外抛,让调用者决定是重试还是放弃。

入队为什么要判断 rear 是不是空。 队列为空时 front 和 rear 都是 NULL,第一个结点既是队头也是队尾,两个指针都要指向它。队列非空时,新结点挂在 rear 后面,再把 rear 推到新结点上。这两段代码合并不了,空队列时没有 rear->next 可以挂。判断条件写成 rear == NULL 和写成 front == NULL 都对,两者在正确的实现里始终同时为空或同时非空。

为什么不用双向链表。 队列只在两端动,入队在尾、出队在头,每个方向各走一步,前驱指针一次都用不上。双向链表每个结点多一个指针域,多出来的八个字节换不到任何能力。同理,带环的链表、加头结点的链表都不是必需品,头结点只是为了省掉空队列的判断,属于可选项。

带头结点的版本在代码上省了什么,对比一下就能看清。不带头的写法里,入队要分空队列和非空两条路,出队删掉最后一个结点后还要手动把 rear 置空;带头结点之后,队头永远是头结点的下一个,空队列时 front 和 rear 都指向头结点,入队永远挂在 rear 后面,出队永远从 front->next 下手,两条分支合成一条。代价是多一个结点,以及每次取队头要写成 front->next->data。

三、数组实现的麻烦

数组实现的第一版最自然:队头固定在下标 0,元素紧挨着排在最前面,再记一个元素个数。入队就是写在 size 的位置上,再把 size 加一,一步到位。

出队就没有这么轻松。队头元素在下标 0,拿走它之后,剩下的每一个元素都要往前挪一格,否则队头就不在 0 上了。这一步交给 memmove:

c 复制代码
# 文件:/home/cocatrice/ds08/seqqueue.c
void SeqQueuePop(SeqQueue* pq)
{
	assert(pq);
	assert(!SeqQueueEmpty(pq));
	memmove(pq->a, pq->a + 1, sizeof(QDataType) * (size_t)(pq->size - 1));
	pq->size--;
}

memmove 是整块内存的搬运,一行代码就完成了,语言层面看不出代价。代价在数据量上:出队一次要搬动剩下所有元素,复杂度是 O(n)。入队 O(1)、出队 O(n) 的队列,元素一多就没法用了。实测里五万个元素反复入队出队,数组版比链表版慢了一百六十倍,差异全部来自这一行。

第二条路是不搬数据,让队头和队尾都往后走。队头不再固定在 0,改用一个下标 front 指向队头元素;出队只是把 front 加一,元素本身留在原地;入队把数据写在 rear 的位置上,再把 rear 加一。两个操作都是 O(1),出队的代价降下来了。

代价转移到了空间上。front 前面的位置永远空着,虽然那些位置上的元素早就出队了。数组是定长的,rear 一直往后走,很快就会撞到末尾,这时候 front 前面明明还空着一大片,新元素却无处安放。容量 4 的数组里,连续入队四个元素再出队三个,此时队列里只剩一个元素,rear 却已经到头,第五个元素放不进来。这种现象叫假满,位置是空的,下标却不认。

把假满的具体数字摊开看:容量 4 的数组,下标是 0 到 3。连续入队四个元素之后,四个位置全满,rear 停在 4。接着出队三次,front 走到 3,队列里只剩一个元素,它待在下标 3 上。此时还有三个位置是空的,却被 front 抛在了身后,再想入队已经无处可写,因为 rear 已经等于容量。元素住在一个 4 格的房子里,实际只占了一格,另外三格被判定为不可用,这就是它必须被解决的原因。

图 3 数组队列出队要搬数据

两条路各亏一点:一条在时间上亏,一条在空间上亏。改法只有一个,让 rear 走到末尾之后回到下标 0,把数组当作首尾相接的环来用。

要把搬数据这一步的代价说透,可以算一笔账。队列里放 n 个元素再全部出队,出队第 k 个元素时要搬动 n - k 个元素,把 n 次出队加起来是 n × (n - 1) / 2 次元素移动。元素个数翻倍,移动次数翻四倍,这就是实测里数据量涨五倍、耗时涨二十多倍的原因。

数组队列也不是一无是处。队列的长度很小并且固定时,一次性申请一块数组反而更省事,比如固定几个任务的轮转。判断的依据有两条:队列的长度能不能提前确定,元素的大小是不是远大于一个指针。两条都成立时,数组的连续内存和零分配开销就压过了搬数据的代价。

接口上的差异。 数组队列对外提供的接口和链表队列完全一样,调用方从一种实现换到另一种,函数名改一改就能用。真正不同的是失败的语义:链表队列的入队只在申请内存失败时出问题,那是异常;数组队列的入队随时可能因为容量满而失败,那是常态。前者的失败可以直接中止程序,后者的失败必须交回给调用者处理,否则数据会无声无息地丢掉。

四、循环队列

循环队列把数组看成一个环:下标走到 capacity - 1 之后,下一个位置是 0。实现上只多一步取模:

c 复制代码
rear = (rear + 1) % capacity;
front = (front + 1) % capacity;

加一之后取模,下标自然绕回来,不需要任何额外判断。这一步也是循环队列里唯一和普通数组队列不同的地方,其余代码几乎照抄。

图 4 循环队列首尾相接

两个下标的语义要先写清楚,后面所有公式都建立在它上面:

  • front 指向队头元素所在的位置,出队时先取走 afront,再让 front 往前挪一格
  • rear 指向队尾元素的下一个位置,也就是下一次入队要写入的位置

rear 指向下一个空位而不是队尾元素本身,这个约定看起来绕,却让入队少一次判断:写入的位置就是 rear,写完再挪。取队尾元素的时候写成 a(rear - 1 + capacity) % capacity,减一之后可能变成负数,所以要先把 capacity 加上再取模。

c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.h
typedef struct CircQueue2
{
	QDataType* a;
	int capacity;
	int front;
	int rear;
	int size;
} CircQueue2;

入队和出队两个操作都只有三行:

c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.c
bool CircQueue2Push(CircQueue2* pq, QDataType x)
{
	assert(pq);
	if (CircQueue2Full(pq))
	{
		return false;
	}
	pq->a[pq->rear] = x;
	pq->rear = (pq->rear + 1) % pq->capacity;
	pq->size++;
	return true;
}

bool CircQueue2Pop(CircQueue2* pq, QDataType* out)
{
	assert(pq);
	if (CircQueue2Empty(pq))
	{
		return false;
	}
	if (out)
	{
		*out = pq->a[pq->front];
	}
	pq->front = (pq->front + 1) % pq->capacity;
	pq->size--;
	return true;
}

入队和出队失败时返回 false,而不是用断言中止程序,这一点和前面几个容器不一样。队列在系统里常常扮演缓冲区,满了、空了都是正常运行中会遇到的情况,让调用者拿到一个布尔值去做取舍,比直接崩掉合适。

容量是固定的时候,循环队列比链表队列更省:一块连续内存,没有每个元素的分配和释放,缓存也友好。它的代价是容量在初始化时就定死了,装不下只能报错或者丢数据。链表队列没有这个上限,代价是每次入队都要 malloc 一次。

rear 的两种约定。 循环队列里 rear 可以指向队尾元素本身,也可以指向队尾的下一个位置,两种写法都有人用:

rear 的约定 空队列怎么表示 入队怎么写 取队尾怎么写
指向队尾元素 要把两个下标绕一圈比较 先把 rear 推进一步,再往 arear 写 arear
指向队尾的下一个位置 两个下标相等 先往 arear 写,再推进 rear a(rear - 1 + capacity) % capacity

差别在于谁先动。指向队尾元素的写法,入队要先挪下标再写数据;指向下一个空位的写法,入队先写数据再挪下标。后者的判空条件正好是两个下标相等,读起来更直接,本文采用这一种。选定之后全篇要一致,中途换约定,前面推出来的长度公式全部作废。

数组是 malloc 出来的,初始化时要断言 capacity 大于 0。取模运算在除数为 0 时直接崩溃,而容量为负时下标会跑到数组外面去,这两个值都不该出现,入口处用断言挡掉最省事。

容量为什么常取 2 的幂。 队列容量取成 2 的幂,取模运算可以换成按位与。容量是 8 时,下标 & 7 和下标 % 8 结果完全相同,前者在机器码里是一条与指令,比除法快得多。这不是必须的写法,但库和内核里的环形缓冲区几乎都这么做,看到容量是 2 的幂时,多半就是为这一步准备的。

取模的代价。 取模在处理器上是一条除法指令,比加法慢不少。队列的容量常常取成 2 的幂,这时可以把 % capacity 换成 & (capacity - 1),两者对 2 的幂是等价的,机器码里省掉了除法。实测里五万次出队一共用了 0.720 毫秒,折算下来一次十几纳秒,取模的影响在这个量级上还看不出来,只有在每秒百万次的热路径上才值得动这种心思。

五、队空和队满的区分

循环队列里 front 和 rear 会反复撞在一起。队列空的时候它们相等,队列满的时候它们也相等,单看这两个下标分不出是哪种情况。三种办法都能解决,各有取舍。

办法一:留一个空位。 数组开 capacity 个位置,约定最多只放 capacity - 1 个元素,始终空着一个位置不用:

c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.c
bool CircQueue1Empty(CircQueue1* pq)
{
	return pq->rear == pq->front;
}

bool CircQueue1Full(CircQueue1* pq)
{
	return (pq->rear + 1) % pq->capacity == pq->front;
}

空的时候两个下标相等,满的时候队尾再走一步就撞上队头。这个办法不需要多记任何成员,判空判满各一行。代价是永远有一个位置闲置,并且这个代价在容量小的时候格外刺眼:容量为 1 的循环队列一个元素也放不下,实测里容量 1 的方案一收下 0 个,容量 2 收下 1 个,容量 3 收下 2 个。

办法二:加一个 size 计数。 多记一个元素个数,空和满看它一个数就够了,数组位置全部能用满:

c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.c
bool CircQueue2Empty(CircQueue2* pq)
{
	return pq->size == 0;
}

bool CircQueue2Full(CircQueue2* pq)
{
	return pq->size == pq->capacity;
}

代码最好懂,容量也不浪费。代价是多一个成员,并且每次入队出队都要记得维护它,漏改一处,判空判满立刻失真。

办法三:加一个 tag 标记位。 记下最后一次操作是入队还是出队,两个下标相等时,靠它区分:

c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.c
bool CircQueue3Empty(CircQueue3* pq)
{
	return pq->front == pq->rear && pq->tag == 0;
}

bool CircQueue3Full(CircQueue3* pq)
{
	return pq->front == pq->rear && pq->tag == 1;
}

位置同样能用满。它和 size 的区别在于记的东西不同:size 记的是数量,tag 记的是上一次动作的方向,两者都能唯一确定当前状态。tag 方案有一个容易忽略的坑,取长度的公式在满的时候会算出 0,这一处单独处理,实测里方案三满的时候裸公式算出 0,Size 接口给出的是容量。

三种办法汇总:

办法 判空 判满 最多放几个 多出的成员 要当心的地方
留一个空位 两下标相等 队尾再走一步撞上队头 capacity - 1 无 容量 1 时什么都放不下
加 size size 等于 0 size 等于 capacity capacity 一个 int 每次入队出队都要维护它
加 tag 两下标相等且 tag 为 0 两下标相等且 tag 为 1 capacity 一个 int 取长度的公式在满时要单独判

实际写代码时选哪一种,看容量紧不紧张。容量固定且不差那一个位置,第一种最省事;容量是 1 或者 2 这种极端值,第一种直接不能用;需要频繁问元素个数,第二种顺手就把答案存好了;状态只有空和满两种、又不想多维护计数的场合,第三种合适。

这三种办法之间没有性能上的差别,判空判满都是一次比较,取长度都是一次加减加取模。选择依据只在两处:容量能不能少一个位置,以及多一个成员会不会带来维护负担。

三种办法对使用方是完全透明的。同一个队列,不管内部是留空位、记 size 还是记 tag,外部的入队、出队、取队头、判空接口签名完全一样,返回值的含义也一样。差别只在两处:容量为 1 时第一种不可用,以及内部能不能承受多一个成员的开销。既然使用方感受不到区别,选择就纯粹是实现者的取舍。

真实的实现里也能看到这三种影子。 Java 的 ArrayBlockingQueue 用的是第二种,内部有 count、takeIndex、putIndex 三个成员,count 就是 size;Linux 内核里的 kfifo 走得更远,它让入队出队两个下标一直往上加、永不取模,靠无符号整数的自然回绕和一次按位与取出低若干位,把取模也省掉了;不少嵌入式代码里的环形缓冲区用的是第一种,因为省一个变量在资源紧张时也是收益。

六、有效长度的公式是怎么来的

除了 size 方案,另外两种都要现算长度。公式是:

c 复制代码
(rear - front + capacity) % capacity

推导分两种情况。rear 没有绕过数组末尾时,rear 在 front 后面,长度就是 rear - front,比如 front 是 1、rear 是 3,队列里有两个元素。rear 绕回去了的时候,rear 反而在 front 前面,长度是 rear + capacity - front,比如容量 4、front 是 3、rear 是 1,队列里是下标 3 和下标 0 两个位置。

把两种情况写成一个式子,办法是先补上一个 capacity 再取模。rear - front 在第二种情况下是负数,加 capacity 之后变成正数,取模又把第一种情况下多出来的 capacity 去掉。代进去验证:front 是 3、rear 是 1、容量 4,得到 (1 - 3 + 4) % 4 = 2,和下标的实际分布一致。

拿几组下标代进去验证,容量取 8:

front rear 计算式 长度 说明
0 3 (3 - 0 + 8) % 8 3 没有绕圈
3 1 (1 - 3 + 8) % 8 6 绕过末尾,占了下标 3 到 7 和 0
7 0 (0 - 7 + 8) % 8 1 只剩最后一个位置上的元素
5 5 (5 - 5 + 8) % 8 0 空;如果用 tag 方案,也可能是满

这个公式还有一处要注意:它在满的时候可能算出 0,因为满的时候 rear 又和 front 相等了。留一个空位的方案不会满到两个下标相等,所以直接套公式没问题;tag 方案会满到相等,必须先判满再决定用不用公式,这就是前面那张表里最后一列的由来。

最后说一个特别常见的算错方式:直接写 rear - front 当长度。没有绕圈时它是对的,一旦 rear 跑到 front 前面,结果就是负数。取长度的公式里那个加 capacity 不是为了好看,缺了它就会出现负数长度的队列。还有一种相关的心算错误,是看到两个下标相等就断定队列为空,这在留空位的方案里成立,在 tag 方案里不成立,因为满的时候两个下标同样相等。

图 5 三种判满办法的对照

七、队列在系统里的应用

生产者消费者。 一边产生数据,一边处理数据,两边的速度不可能一直相等,队列放在中间当缓冲区:来得快了先存着,处理得慢了就排队,上游不至于被拖住。实测里用容量 8 的循环队列模拟了一遍,每轮生产者想放两个、消费者取一个,队列长度一路涨到 7 就稳住了:满了之后生产者放不进,只能放一个,取走一个又腾出一个,水位就停在那个位置。

缓冲区的长度是可以算出来的。生产速度记作 p,消费速度记作 c,队列的平均长度约等于两者之差乘上波动持续的时间。这套估算在压测的时候很有用:队列一直往上涨,说明消费端跟不上,只把队列开大只是把问题往后推,最终要解决的还是消费速度。

任务调度。 操作系统把等待执行的进程排成就绪队列,调度器每次从队头取一个运行,新就绪的进程挂在队尾。先来先服务(FCFS)这个名字说的就是队列的规则。纯粹的先进先出有一个毛病:一个长任务排在前面,后面所有短任务都得等,平均等待时间被它一个人拖长。改进的办法是把一个就绪队列拆成多条,短任务和高优先级任务放在靠前的队列里,调度器先扫前面的队列,扫空了才往下走,这就是多级反馈队列,底子仍然是队列。

层序遍历与最短路。 树和图的按层访问靠队列实现:把起点入队,每次取出队头,把它的邻居入队,天然的访问顺序就是从近到远。实测里用队列在迷宫里跑了一遍,从左上角到右下角的最少步数一次就算出来了,每一格的最短步数也一并得到。

用队列做层序遍历还有一个前提:图的边不带权,也就是每一跳的代价相同。一旦边带上了不同的权重,先入队的不再是先到的,就得换成优先队列,那是后面堆那一篇的内容。

缓冲与消息传递。 打印机的任务队列、消息中间件里的消息队列、管道里的数据,形态都是队列:谁先提交谁先处理。几条数据连在一起的管道里,写入端不停往里塞,读出端按同样的顺序往外取,中间靠一段内核缓冲区兜住速度差,这就是队列在进程之间最直接的用法。

为什么这些场合都选队列。 它们的共同点是两边解耦:产生数据的一方和消费数据的一方互不认识,也不需要同时在线,唯一的约定是先来后到。队列正好提供了这个约定,同时把速度差吸收在缓冲区里。用栈处理同一类问题会出麻烦,最新的任务先被处理,早提交的任务可能永远排不上,这就是所谓的饿死。

图 6 生产者消费者的水位变化

八、双端队列

队列限制两端分工,插入只在一头、删除只在另一头。把这个限制松开,让两端都能进能出,得到的是双端队列(double-ended queue,deque)。它的接口在队列的基础上翻倍:两端各有一个入队和一个出队,取队头和取队尾则和普通队列一样。

用循环数组实现双端队列最自然,和前面的循环队列是同一套结构:front 往前挪一格就是前端的入队,rear 往前挪一格就是后端的出队,两端的操作都落在 O(1)。两端都要能绕圈,取模的写法一模一样,区别只在于四个方向的下标怎么动。

操作 下标怎么动
后端入队 写在 arear,rear 加一取模
后端出队 rear 减一,取 arear
前端入队 front 减一取模,写在 afront
前端出队 取 afront,front 加一取模

双端队列的用处是既能当队列又能当栈:只用后端进、后端出,它就是栈;后端进、前端出,它就是队列。它也是滑动窗口这一类问题的常用工具,后面讲单调队列时还会遇到。

一个典型的用途是滑动窗口:窗口向右移动一格,左边出队一个元素、右边入队一个新元素,两端各一次操作,中间的元素保持不动。如果只用普通队列,左边出队之后剩下的元素要整体前移,窗口每动一格就是一次 O(n) 的搬运,整体又退化成了平方级。

标准库里就有现成的。C++ 的 std::deque 是双端队列,std::queue 则是套在它外面的适配器,只暴露队列该有的那几个接口。适配器这层壳的作用是限制用法,让使用方没法从中间伸手进去,也就没法破坏队列的规则。

九、队列各操作的时间复杂度

三种实现对比:

操作 链表队列 数组队列(队头固定) 循环队列
入队 O(1) O(1),扩容时均摊 O(1) O(1)
出队 O(1) O(n),要搬数据 O(1)
取队头 O(1) O(1) O(1)
取队尾 O(1) O(1) O(1)
取个数 O(1) O(1) O(1)
判空 O(1) O(1) O(1)
空间 每元素一个结点 一块数组 一块数组

整张表里唯一一个不是常数的是数组队列的出队。把这一格降下来,就是循环队列存在的全部理由。

实例代码

工程结构

text 复制代码
ds08/
├── queue.h        链式队列的结构体与接口声明
├── queue.c        链式队列的实现
├── seqqueue.h     数组队列(队头固定在 0,出队搬数据)
├── seqqueue.c     数组队列的实现
├── circqueue.h    循环队列的三种写法
├── circqueue.c    循环队列的三种实现
├── test.c         功能测试,三种队列各过一遍
└── exp/
    ├── speed.c        出队耗时:链表队列与数组队列对照
    ├── boundary.c     容量 1、2、3 的边界与绕圈实验
    ├── prodcons.c     生产者消费者的水位模拟
    └── bfs.c          用队列跑迷宫的最短路

编译命令统一是 gcc -std=gnu99 -Wall -Wextra -g,出队耗时那一个另外带 -O2。-std=gnu99 是为了用上 C99 的写法,变量可以随用随声明;-Wall -Wextra 把常用的告警都打开,这类指针代码里漏写一个 return、比较有符号和无符号,都会在这里被点出来;-g 保留调试信息,valgrind 报错时能直接给出文件名和行号。

完整代码

链式队列的头文件与实现:

c 复制代码
# 文件:/home/cocatrice/ds08/queue.h
#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int QDataType;

// 链表结点:一个数据域加一个指针域
typedef struct QNode
{
	QDataType data;
	struct QNode* next;
} QNode;

// 队列本体:队头指针、队尾指针,再记一个元素个数
// 不记 size 也能写,代价是 QueueSize 要遍历一遍链表
typedef struct Queue
{
	QNode* front;
	QNode* rear;
	int size;
} Queue;

void QueueInit(Queue* pq);
void QueueDestroy(Queue* pq);
void QueuePush(Queue* pq, QDataType x);
void QueuePop(Queue* pq);
QDataType QueueFront(Queue* pq);
QDataType QueueBack(Queue* pq);
int QueueSize(Queue* pq);
bool QueueEmpty(Queue* pq);
c 复制代码
# 文件:/home/cocatrice/ds08/queue.c
#include "queue.h"

void QueueInit(Queue* pq)
{
	assert(pq);
	pq->front = NULL;
	pq->rear = NULL;
	pq->size = 0;
}

void QueueDestroy(Queue* pq)
{
	assert(pq);
	QNode* cur = pq->front;
	while (cur)
	{
		QNode* next = cur->next;
		free(cur);
		cur = next;
	}
	pq->front = NULL;
	pq->rear = NULL;
	pq->size = 0;
}

void QueuePush(Queue* pq, QDataType x)
{
	assert(pq);
	QNode* node = (QNode*)malloc(sizeof(QNode));
	if (node == NULL)
	{
		printf("malloc 失败\n");
		exit(1);
	}
	node->data = x;
	node->next = NULL;

	if (pq->rear == NULL)
	{
		pq->front = node;
		pq->rear = node;
	}
	else
	{
		pq->rear->next = node;
		pq->rear = node;
	}
	pq->size++;
}

void QueuePop(Queue* pq)
{
	assert(pq);
	assert(!QueueEmpty(pq));

	QNode* next = pq->front->next;
	free(pq->front);
	pq->front = next;
	if (pq->front == NULL)
	{
		pq->rear = NULL;
	}
	pq->size--;
}

QDataType QueueFront(Queue* pq)
{
	assert(pq);
	assert(!QueueEmpty(pq));
	return pq->front->data;
}

QDataType QueueBack(Queue* pq)
{
	assert(pq);
	assert(!QueueEmpty(pq));
	return pq->rear->data;
}

int QueueSize(Queue* pq)
{
	assert(pq);
	return pq->size;
}

bool QueueEmpty(Queue* pq)
{
	assert(pq);
	return pq->front == NULL;
}

数组队列,队头固定在下标 0,出队用 memmove 把后面的元素整体前移:

c 复制代码
# 文件:/home/cocatrice/ds08/seqqueue.h
#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <stdbool.h>

typedef int QDataType;

// 数组队列:队头永远在下标 0
// 出队要把后面所有元素往前搬一格,这一步是 O(n)
typedef struct SeqQueue
{
	QDataType* a;
	int capacity;
	int size;
} SeqQueue;

void SeqQueueInit(SeqQueue* pq);
void SeqQueueDestroy(SeqQueue* pq);
void SeqQueuePush(SeqQueue* pq, QDataType x);
void SeqQueuePop(SeqQueue* pq);
QDataType SeqQueueFront(SeqQueue* pq);
QDataType SeqQueueBack(SeqQueue* pq);
int SeqQueueSize(SeqQueue* pq);
bool SeqQueueEmpty(SeqQueue* pq);
c 复制代码
# 文件:/home/cocatrice/ds08/seqqueue.c
#include "seqqueue.h"

static void SeqQueueGrow(SeqQueue* pq)
{
	int newCapacity = pq->capacity == 0 ? 4 : pq->capacity * 2;
	QDataType* tmp = (QDataType*)realloc(pq->a, sizeof(QDataType) * (size_t)newCapacity);
	if (tmp == NULL)
	{
		printf("realloc 失败\n");
		exit(1);
	}
	pq->a = tmp;
	pq->capacity = newCapacity;
}

void SeqQueueInit(SeqQueue* pq)
{
	assert(pq);
	pq->a = NULL;
	pq->capacity = 0;
	pq->size = 0;
}

void SeqQueueDestroy(SeqQueue* pq)
{
	assert(pq);
	free(pq->a);
	pq->a = NULL;
	pq->capacity = 0;
	pq->size = 0;
}

void SeqQueuePush(SeqQueue* pq, QDataType x)
{
	assert(pq);
	if (pq->size == pq->capacity)
	{
		SeqQueueGrow(pq);
	}
	pq->a[pq->size] = x;
	pq->size++;
}

void SeqQueuePop(SeqQueue* pq)
{
	assert(pq);
	assert(!SeqQueueEmpty(pq));
	// 队头在 0,出队之后后面每一个元素都要往前挪一格
	memmove(pq->a, pq->a + 1, sizeof(QDataType) * (size_t)(pq->size - 1));
	pq->size--;
}

QDataType SeqQueueFront(SeqQueue* pq)
{
	assert(pq);
	assert(!SeqQueueEmpty(pq));
	return pq->a[0];
}

QDataType SeqQueueBack(SeqQueue* pq)
{
	assert(pq);
	assert(!SeqQueueEmpty(pq));
	return pq->a[pq->size - 1];
}

int SeqQueueSize(SeqQueue* pq)
{
	assert(pq);
	return pq->size;
}

bool SeqQueueEmpty(SeqQueue* pq)
{
	assert(pq);
	return pq->size == 0;
}

循环队列的三种写法放在同一对文件里,结构体各自不同,判空判满的方式也不同:

c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.h
#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int QDataType;

// 循环队列的三种判空判满写法,接口一样,内部的记账方式不同

// 方案一:空一个位置。数组开 capacity 个位置,最多只放 capacity - 1 个元素
typedef struct CircQueue1
{
	QDataType* a;
	int capacity;
	int front;
	int rear;
} CircQueue1;

// 方案二:多记一个 size,数组位置可以全部用满
typedef struct CircQueue2
{
	QDataType* a;
	int capacity;
	int front;
	int rear;
	int size;
} CircQueue2;

// 方案三:多记一个 tag,标记最后一次操作是入队还是出队
typedef struct CircQueue3
{
	QDataType* a;
	int capacity;
	int front;
	int rear;
	int tag;
} CircQueue3;

void CircQueue1Init(CircQueue1* pq, int capacity);
void CircQueue1Destroy(CircQueue1* pq);
bool CircQueue1Push(CircQueue1* pq, QDataType x);
bool CircQueue1Pop(CircQueue1* pq, QDataType* out);
QDataType CircQueue1Front(CircQueue1* pq);
int CircQueue1Size(CircQueue1* pq);
bool CircQueue1Empty(CircQueue1* pq);
bool CircQueue1Full(CircQueue1* pq);

void CircQueue2Init(CircQueue2* pq, int capacity);
void CircQueue2Destroy(CircQueue2* pq);
bool CircQueue2Push(CircQueue2* pq, QDataType x);
bool CircQueue2Pop(CircQueue2* pq, QDataType* out);
QDataType CircQueue2Front(CircQueue2* pq);
int CircQueue2Size(CircQueue2* pq);
bool CircQueue2Empty(CircQueue2* pq);
bool CircQueue2Full(CircQueue2* pq);

void CircQueue3Init(CircQueue3* pq, int capacity);
void CircQueue3Destroy(CircQueue3* pq);
bool CircQueue3Push(CircQueue3* pq, QDataType x);
bool CircQueue3Pop(CircQueue3* pq, QDataType* out);
QDataType CircQueue3Front(CircQueue3* pq);
int CircQueue3Size(CircQueue3* pq);
bool CircQueue3Empty(CircQueue3* pq);
bool CircQueue3Full(CircQueue3* pq);
c 复制代码
# 文件:/home/cocatrice/ds08/circqueue.c
#include "circqueue.h"

// ---------------- 方案一:空一个位置 ----------------

void CircQueue1Init(CircQueue1* pq, int capacity)
{
	assert(pq);
	assert(capacity > 0);
	pq->a = (QDataType*)malloc(sizeof(QDataType) * (size_t)capacity);
	assert(pq->a);
	pq->capacity = capacity;
	pq->front = 0;
	pq->rear = 0;
}

void CircQueue1Destroy(CircQueue1* pq)
{
	assert(pq);
	free(pq->a);
	pq->a = NULL;
	pq->capacity = 0;
	pq->front = 0;
	pq->rear = 0;
}

bool CircQueue1Full(CircQueue1* pq)
{
	assert(pq);
	return (pq->rear + 1) % pq->capacity == pq->front;
}

bool CircQueue1Empty(CircQueue1* pq)
{
	assert(pq);
	return pq->rear == pq->front;
}

bool CircQueue1Push(CircQueue1* pq, QDataType x)
{
	assert(pq);
	if (CircQueue1Full(pq))
	{
		return false;
	}
	pq->a[pq->rear] = x;
	pq->rear = (pq->rear + 1) % pq->capacity;
	return true;
}

bool CircQueue1Pop(CircQueue1* pq, QDataType* out)
{
	assert(pq);
	if (CircQueue1Empty(pq))
	{
		return false;
	}
	if (out)
	{
		*out = pq->a[pq->front];
	}
	pq->front = (pq->front + 1) % pq->capacity;
	return true;
}

QDataType CircQueue1Front(CircQueue1* pq)
{
	assert(pq);
	assert(!CircQueue1Empty(pq));
	return pq->a[pq->front];
}

int CircQueue1Size(CircQueue1* pq)
{
	assert(pq);
	return (pq->rear - pq->front + pq->capacity) % pq->capacity;
}

// ---------------- 方案二:多记一个 size ----------------

void CircQueue2Init(CircQueue2* pq, int capacity)
{
	assert(pq);
	assert(capacity > 0);
	pq->a = (QDataType*)malloc(sizeof(QDataType) * (size_t)capacity);
	assert(pq->a);
	pq->capacity = capacity;
	pq->front = 0;
	pq->rear = 0;
	pq->size = 0;
}

void CircQueue2Destroy(CircQueue2* pq)
{
	assert(pq);
	free(pq->a);
	pq->a = NULL;
	pq->capacity = 0;
	pq->front = 0;
	pq->rear = 0;
	pq->size = 0;
}

bool CircQueue2Full(CircQueue2* pq)
{
	assert(pq);
	return pq->size == pq->capacity;
}

bool CircQueue2Empty(CircQueue2* pq)
{
	assert(pq);
	return pq->size == 0;
}

bool CircQueue2Push(CircQueue2* pq, QDataType x)
{
	assert(pq);
	if (CircQueue2Full(pq))
	{
		return false;
	}
	pq->a[pq->rear] = x;
	pq->rear = (pq->rear + 1) % pq->capacity;
	pq->size++;
	return true;
}

bool CircQueue2Pop(CircQueue2* pq, QDataType* out)
{
	assert(pq);
	if (CircQueue2Empty(pq))
	{
		return false;
	}
	if (out)
	{
		*out = pq->a[pq->front];
	}
	pq->front = (pq->front + 1) % pq->capacity;
	pq->size--;
	return true;
}

QDataType CircQueue2Front(CircQueue2* pq)
{
	assert(pq);
	assert(!CircQueue2Empty(pq));
	return pq->a[pq->front];
}

int CircQueue2Size(CircQueue2* pq)
{
	assert(pq);
	return pq->size;
}

// ---------------- 方案三:多记一个 tag ----------------

void CircQueue3Init(CircQueue3* pq, int capacity)
{
	assert(pq);
	assert(capacity > 0);
	pq->a = (QDataType*)malloc(sizeof(QDataType) * (size_t)capacity);
	assert(pq->a);
	pq->capacity = capacity;
	pq->front = 0;
	pq->rear = 0;
	pq->tag = 0;
}

void CircQueue3Destroy(CircQueue3* pq)
{
	assert(pq);
	free(pq->a);
	pq->a = NULL;
	pq->capacity = 0;
	pq->front = 0;
	pq->rear = 0;
	pq->tag = 0;
}

bool CircQueue3Empty(CircQueue3* pq)
{
	assert(pq);
	return pq->front == pq->rear && pq->tag == 0;
}

bool CircQueue3Full(CircQueue3* pq)
{
	assert(pq);
	return pq->front == pq->rear && pq->tag == 1;
}

bool CircQueue3Push(CircQueue3* pq, QDataType x)
{
	assert(pq);
	if (CircQueue3Full(pq))
	{
		return false;
	}
	pq->a[pq->rear] = x;
	pq->rear = (pq->rear + 1) % pq->capacity;
	pq->tag = 1;
	return true;
}

bool CircQueue3Pop(CircQueue3* pq, QDataType* out)
{
	assert(pq);
	if (CircQueue3Empty(pq))
	{
		return false;
	}
	if (out)
	{
		*out = pq->a[pq->front];
	}
	pq->front = (pq->front + 1) % pq->capacity;
	pq->tag = 0;
	return true;
}

QDataType CircQueue3Front(CircQueue3* pq)
{
	assert(pq);
	assert(!CircQueue3Empty(pq));
	return pq->a[pq->front];
}

int CircQueue3Size(CircQueue3* pq)
{
	assert(pq);
	// 满的时候 front 和 rear 又撞在一起了,裸公式会算出 0,得先判满
	if (CircQueue3Full(pq))
	{
		return pq->capacity;
	}
	return (pq->rear - pq->front + pq->capacity) % pq->capacity;
}

正常情况

先把整个目录编出来:

bash 复制代码
# 所属目录:/home/cocatrice/ds08
gcc -std=gnu99 -Wall -Wextra -g queue.c seqqueue.c circqueue.c test.c -o test
./test

回显如下:

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./test
===== 链式队列 =====
入队 1 2 3 4 之后:队头 1,队尾 4,个数 4
出队两次之后:队头 3,队尾 4,个数 2
再入队 5 之后:队头 3,队尾 5,个数 3
出队 3
出队 4
出队 5
全部出完,空队列判断:是空

===== 数组队列 =====
数组队列:队头 1,队尾 3,个数 3
出队一次之后:队头 2,队尾 3,个数 2

===== 循环队列的三种写法 =====
容量 4 一直入队:方案一进去 3 个,方案二进去 4 个,方案三进去 4 个
各出队一次之后:队头分别是 2、2、2
方案一满了吗:没满,方案二:没满,方案三:没满

第一段验证链表队列的两端:入队四个之后队头是 1、队尾是 4,出队两次队头前进到 3,队尾留在 4,再入队 5 时队尾跟到 5。最后把剩下的三个依次出队,元素按 3、4、5 的顺序离开,这就是先进先出。

第二段是数组队列,行为一致,只是出队那一步在底层搬了一次数据,接口上看不出来。对照两边可以看出,数组队列的队头永远是下标 0 上的那个元素,出队之后剩下的元素整体往前挪一格,所以队列内容打印出来和链表队列一模一样。

接口一样、行为一样,这就是抽象的价值:使用方只关心入队出队的顺序,不关心底层是结点还是数组。要换实现时,函数名和参数都不用改,换一个头文件重新编译即可。

第三段把容量 4 的循环队列一直入队,方案一收下 3 个就满了,方案二和方案三各收下 4 个。差别来自方案一留了一个位置用来区分空和满,方案二靠 size、方案三靠 tag,位置都能用满。

这一组程序在 -Wall -Wextra 下编译零告警。这类代码最容易踩的两类告警,一类是比较有符号和无符号,比如把 size 定义成 int 却拿去和 sizeof 的结果比;另一类是函数声明了返回值却忘了写 return。加上这两个开关编译一遍,大部分笔误在编译期就被拦下来了,比等到运行时再查省事得多。

常见报错

第一组:空队列上出队。 出队之前没有判空,断言会当场拦下来。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./exp/err_empty
出队一次之后,队列为空吗:是空
在空队列上再出队一次:
err_empty: queue.c:54: QueuePop: Assertion `!QueueEmpty(pq)' failed.
Aborted (core dumped)
[cocatrice@hcss-ecs-4cd1 ds08]$ echo $?
134

改法有两种。一种是像现在这样在函数里用断言,把错误挡在发生的当场;另一种是把 QueuePop 的返回值改成 bool,让调用者自己判断。前一种适合自己写自己用的场景,后一种适合接口要给别人用的场景。

两种改法背后的取舍是错误该在哪一层被处理。断言把责任放在调用者身上:你答应过不为空,现在为空了,程序立刻停下,问题现场完整保留。返回错误码把责任放在被调用者身上:函数不崩,如实报告,由上层决定是重试、是跳过还是记一条日志。库函数不能用断言,因为它控制不了使用方的输入;小程序里断言更好用,因为崩溃的位置就是错误的位置。

第二组:出队删掉最后一个结点时忘了把 rear 置空。 这个错误当场看不出来,程序照常跑完,只有 valgrind 能抓住。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ valgrind ./exp/err_rear
==7234== Invalid write of size 8
==7234==    at 0x400621: Push (err_rear.c:31)
==7234==    by 0x4006EB: main (err_rear.c:55)
==7234==  Address 0x5205048 is 8 bytes inside a block of size 16 free'd
==7234==    at 0x4C2B06D: free (vg_replace_malloc.c:540)
==7234==    by 0x40065C: Pop (err_rear.c:39)
==7234==    by 0x40069E: main (err_rear.c:51)
==7234==  Block was alloc'd at
==7234==    at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==7234==    by 0x4005D5: Push (err_rear.c:21)
==7234==    by 0x400692: main (err_rear.c:50)
==7234==
出队之后 front 是 空,rear 是 非空
再入队一个,新结点的数据是 2
==7234== LEAK SUMMARY:
==7234==    definitely lost: 16 bytes in 1 blocks
==7234== ERROR SUMMARY: 1 errors from 1 contexts

出队之后 front 已经是空,rear 却还指着那块刚释放的内存,再入队时新结点的地址被写进了一个已经归还的块里。程序打印出来的结果看着正常,写的却是别人的内存。改法就是出队之后补一句判断:

c 复制代码
# 文件:/home/cocatrice/ds08/queue.c
	pq->front = next;
	if (pq->front == NULL)
	{
		pq->rear = NULL;
	}
	pq->size--;

第三组:判满漏了取模。 把 (pq->rear + 1) % pq->capacity == pq->front 写成 pq->rear + 1 == pq->front,在数组没绕圈的时候还能用,一旦 rear 走到末尾就失效。绕圈实验里容量 3、放满 1 2 3 时,front 是 0、rear 也是 0,此时判满应当成立;如果代码写的是 rear + 1 == front,就会得出没满的结论,下一次入队直接覆盖掉队头元素。

第四组:容量传成 0 或负数。 数组是 malloc 出来的,容量为 0 时一块内存都申请不到,后面每一次取模都会除以 0。

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./exp/err_cap
先试容量 0:
err_cap: circqueue.c:83: CircQueue2Init: Assertion `capacity > 0' failed.
Aborted (core dumped)

这类参数错误在入口处断言掉最合适,错误位置离原因只有一个函数的距离。如果放任它往下跑,崩溃会发生在第一次入队或者出队的时候,那时候栈已经绕了几层,定位反而更费劲。

边界情况

容量取 1、2、3 这三种极端值,三种方案的表现:

text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./exp/boundary
容量 1 一路入队:方案一收下 0 个,方案二 1 个,方案三 1 个
  方案三满的时候:裸公式算出 0,Size 接口给的是 1
  各出队一次:方案一是空的,方案二拿到 1,方案三拿到 1
  出队之后长度 0、0、0,空判断 1、1、1,满判断 1、0、0

容量 2 一路入队:方案一收下 1 个,方案二 2 个,方案三 2 个
  方案三满的时候:裸公式算出 0,Size 接口给的是 2
  各出队一次:方案一拿到 1,方案二拿到 1,方案三拿到 1
  出队之后长度 0、1、1,空判断 1、0、0,满判断 0、0、0

容量 3 一路入队:方案一收下 2 个,方案二 3 个,方案三 3 个
  方案三满的时候:裸公式算出 0,Size 接口给的是 3
  各出队一次:方案一拿到 1,方案二拿到 1,方案三拿到 1
  出队之后长度 1、2、2,空判断 0、0、0,满判断 0、0、0

容量 3 的绕圈实验(方案二):
  放满 1 2 3:长度 3,front=0 rear=0
  出队两次(1、2):长度 1,front=2 rear=0
  再放 4 5:长度 3,front=2 rear=2
  依次出队:3 4 5

容量 1 那一组最能说明问题:留一个空位的方案连一个元素都放不下,因为区分空和满所必需的那一个位置,就是全部的空间。容量 1 的队列在实际中确实会用到,比如同步用的信号量、单元素的消息槽,写这类代码时不要选留空位这一种。

方案三那三行的对比也值得看:满的时候两个下标相等,裸公式算出 0,Size 接口因为先判了满,给出的是容量。同一个队列,两个数差了一整个容量。

绕圈实验回答了另一个问题:队列绕回来之后还能不能正确出队。容量 3 的表放满 1 2 3 时 front 和 rear 都是 0,出队两次之后 front 走到 2、rear 仍在 0,再放 4 和 5,第一次写入落在下标 0,第二次落在下标 1,rear 从 0 走到 1 再到 2,中途绕过了数组末尾,最后按 3、4、5 的顺序出队,顺序没有乱。

容量取多少合适,也是边界的一部分。容量定得太小,入队频繁失败;定得太大,一块内存长期闲置。判断依据是同一个时刻队列里最多会积压多少元素,这个值可以从压测里量出来:把水位最高的那一刻记下来,再留一点余量。前面对生产者消费者的估算给的就是这个水位,估出来是 7,容量取 8 刚好,取 100 就浪费了九成。

三种写法在空队列上的表现也要确认一遍。容量 1 的那一组里,方案一入队 0 个之后就直接进入空状态,出队接口返回失败,判空给出 1;方案二和方案三各放进去 1 个,出队拿到 1,判空同样给出 1。三种写法在空队列上的判空结果一致,区别只在能装多少。满队列上的表现也一致:方案一还有空位就用不上满的判断,方案二和方案三在放满之后入队返回失败,不覆盖已有数据。

实测数据

出队耗时:链表队列与数组队列对照。 两个队列都先放满 n 个元素,再依次全部出队,只给这一段计时,n 取 2000、10000、50000。

bash 复制代码
# 所属目录:/home/cocatrice/ds08
gcc -std=gnu99 -Wall -Wextra -O2 exp/speed.c queue.c seqqueue.c -o exp/speed
./exp/speed
text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./exp/speed
元素个数 链表队列出队 数组队列出队 倍数
2000       0.027            0.194            7.1
10000      0.131            3.938            30.0
50000      0.720            115.278          160.0

把耗时折算到每一次出队,差距更直观:

元素个数 链表队列单次出队 数组队列单次出队
2000 0.0135 微秒 0.097 微秒
10000 0.0131 微秒 0.394 微秒
50000 0.0144 微秒 2.306 微秒

链表队列的单次出队稳定在十几纳秒,和元素个数几乎无关;数组队列从 0.097 微秒一路涨到 2.306 微秒,涨了二十多倍,因为它每出队一次,要搬动的元素数量跟着队列长度一起长。

三行数据把两条曲线的差别摆得很清楚。链表队列的耗时基本跟着元素个数线性走,数据量翻五倍,耗时也从 0.027 毫秒涨到 0.131 毫秒,再涨到 0.720 毫秒。数组队列不一样:2000 个元素时 0.194 毫秒,10000 个时 3.938 毫秒,数据量翻五倍,耗时涨了二十倍;再到 50000 个,耗时 115.278 毫秒,又翻了二十九倍。这正是 O(n) 操作被调用 n 次的结果,整体是平方级的。

两者的倍数从 7.1 倍一路涨到 160 倍,而且这个差距只会随着数据量继续拉大。队列一出队就搬数据的写法,在小数据量下看不出问题,数据一多就露馅。

差距里还藏着一层缓存的因素。链表队列每出队一个元素都要 free 一次,结点分散在堆上的各个位置,处理器每次访问都要重新加载缓存行;数组队列的元素挨在一起,搬运本身是顺序读写,缓存命中的比例高得多。这一层在这组数据里没有单独量出来,但在元素更小、数量更大的场景下会更明显。计时只覆盖出队这一段,入队和申请内存的开销都在计时之外,所以这两列的对比说的是两种出队方式的差别,不是两种队列的整体优劣。

valgrind 检查。

bash 复制代码
# 所属目录:/home/cocatrice/ds08
valgrind --leak-check=full ./test
text 复制代码
==6692== HEAP SUMMARY:
==6692==     in use at exit: 0 bytes in 0 blocks
==6692==   total heap usage: 9 allocs, 9 frees, 144 bytes allocated
==6692==
==6692== All heap blocks were freed -- no leaks are possible
==6692==
==6692== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

九次申请对应九次释放:链表队列在整个测试里申请过七个结点,两段数组队列各申请了一块连续空间,销毁时全部归还,没有泄漏也没有越界。

实例:两个小程序

生产者消费者。 缓冲区用容量 8 的循环队列,每轮生产者尝试放两个,消费者取一个,跑十二轮,把每一轮的队列长度画出来。

bash 复制代码
# 所属目录:/home/cocatrice/ds08
gcc -std=gnu99 -Wall -Wextra -g exp/prodcons.c circqueue.c -o exp/prodcons
./exp/prodcons
text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./exp/prodcons
缓冲区容量 8,每轮生产者想放 2 个,消费者取 1 个

第  1 轮  放 2 取 1  长度 1  =
第  2 轮  放 2 取 1  长度 2  ==
第  3 轮  放 2 取 1  长度 3  ===
第  4 轮  放 2 取 1  长度 4  ====
第  5 轮  放 2 取 1  长度 5  =====
第  6 轮  放 2 取 1  长度 6  ======
第  7 轮  放 2 取 1  长度 7  =======
第  8 轮  放 1 取 1  长度 7  =======
第  9 轮  放 1 取 1  长度 7  =======
第 10 轮  放 1 取 1  长度 7  =======
第 11 轮  放 1 取 1  长度 7  =======
第 12 轮  放 1 取 1  长度 7  =======

一共放进去 19 个,取出来 12 个,缓冲区里还剩 7 个

前七轮生产比消费快,队列长度一轮涨一个,涨到 7 就停住了。第七轮结束时缓冲区里有 7 个元素,容量 8 的循环队列,方案二最多放 8 个,此时还能再放一个,但生产者的节奏是每轮两个,放第二个的时候已经满了,于是只能放进去一个。从第八轮开始,放一个、取一个,长度稳定在 7,这就是缓冲区的作用:上游快出来的部分被存住,没有丢,也没有把下游压垮。

真实系统里的这一步还有两种选择。一种是生产者发现队列满了就阻塞等待,等消费者取走一个再继续,这就是阻塞队列;另一种是直接丢弃新数据或者丢弃最旧的数据,比如日志缓冲区和视频的帧缓冲。选哪一种取决于数据能不能丢:订单不能丢,监控的采样点丢几个没关系。

这个模拟里只有一条执行流,生产者和消费者是轮流登场的,所以队列满、队列空都不会真的等待,接口返回失败就跳过去。换成两条线程之后,满和空就变成需要等待的条件,那时候要配一把锁和一个条件变量,队列本身的结构不会变,变的只是谁来唤醒谁。

迷宫最短路。 6 行 8 列的格子,0 是通路,1 是墙,用队列从左上角开始一层一层往外扩,每一格的最短步数一次算出来。

bash 复制代码
# 所属目录:/home/cocatrice/ds08
gcc -std=gnu99 -Wall -Wextra -g exp/bfs.c queue.c -o exp/bfs
./exp/bfs
text 复制代码
[cocatrice@hcss-ecs-4cd1 ds08]$ ./exp/bfs
迷宫:S 是起点,E 是终点,█ 是墙
S . . █ . . . .
. █ . █ . █ . .
. █ . . . █ █ .
. . . █ . . . .
█ █ . █ █ █ . .
. . . . . . . E

每一格的最短步数(-1 表示走不到):
  0  1  2 -1  8  9 10 11
  1 -1  3 -1  7 -1 11 12
  2 -1  4  5  6 -1 -1 11
  3  4  5 -1  7  8  9 10
 -1 -1  6 -1 -1 -1 10 11
  9  8  7  8  9 10 11 12

从左上角走到右下角,最少 12 步

起点入队时步数是 0,每取出一个位置,就把它的上下左右四个邻居里还没走过的入队,步数加一。队列保证了先入队的先被处理,所以访问顺序天然是从近到远,第一次走到某一格时的步数就是最短步数。表里右上角那一格是 11 步,它左边隔着墙,只能从下方绕过去;右下角是 12 步。有几格是 -1,被墙围住了,从起点到不了。

这段代码里有两个细节值得记下来。dist 数组既记步数又当访问标记用,初始化为 -1 表示还没走到,这样就不用再开一个 visited 数组;队列里放的是位置编码而不是行列两个数,一个 int 就能装下,取出来的时候除一次、取一次模就还原成行列。整个算法的复杂度是 O(行 × 列),每个格子最多入队一次,队列的容量上限也就是格子的总数。

踩坑点

  • 出队删掉最后一个结点时,必须把 rear 一起置空,否则它指着已经释放的内存,下一次入队会写到别人的块里。
  • 只存头指针的单链表做不了 O(1) 的尾插,队列至少要两个指针,一个管出、一个管进。
  • front 指向队头元素、rear 指向队尾的下一个位置,这个约定全篇要统一,混用之后长度和判满都会算错。
  • 判满条件里的取模不能省,写成 rear + 1 == front 在数组绕圈之后立刻失效。
  • 有效长度用 (rear - front + capacity) % capacity,先补一个 capacity 是为了让 rear 小于 front 时不出负数。
  • 满的时候这个公式可能算出 0,tag 方案必须先判满再决定用不用公式。
  • 留一个空位的方案,容量 n 的表只能放 n - 1 个元素,容量是 1 时一个都放不下。
  • 数组队列出队用 memmove 搬数据是 O(n),整体一跑就是平方级,五万个元素比链表队列慢一百六十倍。
  • 数组队列让头尾都往后走、不做绕圈的写法会出现假满,前面空着也放不进新元素。
  • 空队列上出队、取队头都要先判空,断言或者返回错误码都行,直接取值会把已释放的内存读出来。
  • 循环队列的下标取模之前,capacity 必须大于 0,初始化时用断言把 0 和负数挡掉。
  • 出队取元素建议用出参返回,函数返回值留给成功与否,调用者才有机会处理满和空这两种正常情况。
  • 初始化时容量必须大于 0,容量为 0 会在第一次取模时除以 0,入口处断言挡掉最省事。
  • 取长度不要直接写 rear - front,绕圈之后这一式会算出负数。
  • 看到两个下标相等就判断队列为空,只在留空位的方案里成立,tag 方案里满也是相等。
  • 队列用作出参的那个参数要判空指针,允许调用者只关心成功与否、不关心丢的是哪个元素。

本篇总结(模拟面试问题)

问:队列和栈的区别是什么?

核心要点:两者都是操作受限的线性表,接口条数一样,限制的位置反了。栈只允许在一端插入和删除,后进先出;队列在一端插入、另一端删除,先进先出。需要按到达顺序处理用队列,需要把最近的先处理用栈。

问:队列为什么常用链表实现,不常用数组?

核心要点:队列两端都要动,单链表加一个尾指针就能让头删尾插都是 O(1)。数组实现如果队头固定在下标 0,出队要把剩下的元素整体前移,是 O(n);如果不搬数据,让头尾都往后走,又会出现假满。要用数组就得绕成循环队列。

问:循环队列怎么区分队空和队满?

核心要点:三种办法。留一个空位,两个下标相等是空、队尾再走一步撞上队头是满,代价是浪费一个位置;多记一个 size,看它是 0 还是等于容量;多记一个 tag,记录上一次是入队还是出队,两个下标相等时靠它区分。后两种位置都能用满。

问:循环队列的有效长度怎么算,为什么先加一个 capacity?

核心要点:(rear - front + capacity) % capacity。rear 没绕圈时长度是 rear - front;绕圈之后 rear 反而比 front 小,差值是负数,先把 capacity 加回去变成正数,再取模把没绕圈时多加的那一份去掉。

问:留一个空位的写法,容量 n 的队列最多放几个元素?

核心要点:n - 1 个。至少要空出一个位置,才能把满和空这两种状态区分开。容量是 1 的时候一个元素都放不下,因为区分状态要用的那个位置就是全部空间。同步用的信号量和单元素消息槽经常会遇到容量 1,这种场合不要用留空位的写法。

问:为什么循环队列里 front 和 rear 相等时,既可能是空也可能是满?

核心要点:两个下标只记录了位置,没有记录数量。绕圈之后,元素个数是容量的整数倍时,两个下标都会撞在一起。空的时候相等、满的时候也相等,所以必须再引入一个信息,要么牺牲一个位置,要么记 size,要么记最后一次操作的方向。

问:出队之后为什么要把 rear 也置空?

核心要点:队列为空时两个指针都应该是 NULL。出队把最后一个结点释放掉之后,front 跟着变成 NULL,rear 还指着那块已释放的内存。这时再入队,会先访问 rear 所在的块,属于使用已释放的内存,valgrind 会直接报出来。

问:队列能不能用数组做,容量不够了怎么办?

核心要点:能用,做法是循环队列,容量在初始化时定死,满了就返回失败。要支持动态扩容得自己写:申请一块两倍大的新数组,把元素按顺序从 front 开始逐个搬到新数组的下标 0 开始的位置,再把 front 置 0、rear 置成元素个数。搬完之后旧数组释放,容量变成两倍。要注意的是扩容之后所有下标都变了,如果外部保存过下标,那些下标全部失效。

问:BFS 为什么用队列,不用栈?

核心要点:BFS 要按层访问,离起点近的先处理。队列先进先出,入队顺序就是层序;用栈会变成后进先出,访问顺序退化成了深度优先,第一次到达某一格的步数不再是最短的。

参考

相关推荐
小此方1 小时前
Linux网络(十九):TCP流量控制与滑动窗口详解,超时重传和快重传到底有什么区别
linux·运维·服务器·网络·网络协议·tcp/ip
码上有光2 小时前
Linux进程通信——共享内存、消息队列和信号量
android·linux·运维·共享内存·通信
沫璃染墨2 小时前
《从零入门Linux系统篇(五十四):线程篇·七——互斥锁底层原理:从原子交换到线程竞争与锁实现》
linux·运维·服务器·开发语言·c++·驱动开发·系统架构
不会就选b2 小时前
算法日常・每日刷题--<动态规划>3
数据结构·算法
Java后端的Ai之路2 小时前
Python进阶探索24_应用案例_linux系统的监控
linux·开发语言·python·应用·探索
H.莓飛3 小时前
【数据结构】队列_OJ题
linux·数据结构·centos
H.莓飛3 小时前
【数据结构】栈_OJ题
linux·数据结构·算法·centos
Ruiery3 小时前
Linux 6.6内核 CPU 深度解析(一):BSP 拉起 — 从实模式到 kernel_init 的完整旅程
linux·运维·服务器
赵民勇3 小时前
dconf命令详解
linux·运维