1. 队列这东西,真的不只是“排队”那么简单
1.1 从食堂打饭看队列的核心规则
我刚开始学数据结构的时候,对队列最大的误解就是觉得它太简单了——“不就是排队吗?”直到后来用链表手写队列时,被一堆指针绕得晕头转向,才意识到越简单的逻辑,落地到代码里越需要抠细节。
队列的核心规则只有一条:先进先出(First In First Out,FIFO)。你可以想象学校食堂打饭的窗口,第一个到的同学先打饭,打完走人,后面的人依次往前挪。新来的同学只能排在队尾,不能插队。这个“只能在队尾加入、只能在队头离开”的规则,就是队列和数组、链表最大的区别。
放到编程里,队列就是一类操作受限的线性表。它允许插入的一端叫队尾(rear),允许删除的一端叫队头(front)。注意,和日常排队不一样,在代码里我们通常是用两个指针分别记录队头和队尾,而不是让元素真的“往前挪”。这种设计直接决定了队列的实现方式。
从零开始学队列,你只需要搞明白三件事:队列里存什么、队头和队尾怎么表示、入队和出队时指针怎么动。把这三个问题想清楚,不管是用数组实现还是用链表实现,都不会慌。
1.2 为什么顺序队列会有“假溢出”,链式队列才是出路
很多教材会先讲顺序队列,也就是用数组实现队列。数组队列本身没问题,但如果你傻乎乎地只在队尾加数据、在队头删数据,很快就会发现一个尴尬的局面:队尾指针已经指到数组最后一个位置了,但数组前面全是空闲空间,因为出队只是把front往后移,并没有真正释放前面的空间。
这就是经典的“假溢出”问题。你明明有空间,却因为rear到了数组末尾而无法继续入队。
解决假溢出的常规思路是循环队列,让rear到末尾后绕回开头,用取模运算实现“环形”。循环队列确实是个经典方案,很多考试也爱考,但它有个绕不开的缺点:容量固定。队列一旦满员,想扩容得重新申请内存、拷贝数据,麻烦不说,还容易出错。
链式队列就没有这个烦恼。链式队列本质是一个单链表,每个结点存储数据和指向下一个结点的指针,队头指针front指向首元结点,队尾指针rear指向尾结点。入队就是给rear后面挂一个新结点,出队就是让front指向下一个结点。内存按需分配,理论上队列可以无限长,只要内存够。
所以我个人觉得,真正在工作里用得多的队列,底层大多是基于链表或者更高级的数据结构实现的。数组队列更多出现在教材和考试题里,链式队列才是让你真正理解“队列是一种抽象逻辑”的关键。
1.3 链式队列到底能解决什么问题
链式队列最朴素的价值是“数据缓冲”。举个很常见的例子:你的程序需要从网络接收数据,但处理速度跟不上接收速度。这时候你可以在中间放一个队列,接收线程把数据塞进队列,处理线程从队列里取数据慢慢处理。这个队列就像一个蓄水池,把忽快忽慢的输入输出节奏拉平。
再比如操作系统里的任务调度、打印机任务队列、线程池里的任务排队,全是队列的典型应用。你手机上的消息通知、游戏里的操作指令序列,背后也少不了队列。
链式队列相比顺序队列,在解决这些问题时更有优势:任务数量往往不可预知,链式队列可以随时扩容;频繁的入队出队会导致数组队列不断搬移或取模,而链式队列的操作只涉及指针修改,时间复杂度都是O(1)。当然,链表结点有额外的指针开销,内存碎片也更多,但在绝大多数业务场景下,这点代价是值得的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链式队列的设计:结点、指针和带头结点之争
2.1 单链表与队头队尾指针的组合
链式队列在形式上就是一个单链表,但它比普通单链表多了一个尾指针。你可能要问:单链表本身就有头指针,为什么队列还要单独维护一个尾指针?
因为入队操作需要在链表尾部插入新结点。如果你只有头指针,每次入队都得从头遍历到尾,时间复杂度是O(n)。这完全违背了队列“入队出队都应该是O(1)”的初衷。所以链式队列必须同时保存front和rear两个指针,front指向队头结点,rear指向队尾结点。这样入队直接操作rear,出队直接操作front,两头都是O(1)。
这也意味着你需要额外定义一个“队列结构体”,把front和rear以及队列长度打包在一起。以后写函数时,只需要传这个结构体的指针,所有操作都能拿到上下文。
2.2 C语言里的结构体定义
如果用C语言实现链式队列,标准做法是两个结构体:一个是结点结构体,一个是队列结构体。
c复制// 结点结构体
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个结点
} Node;
// 链式队列结构体
typedef struct Queue {
Node *front; // 队头指针,指向队头结点
Node *rear; // 队尾指针,指向队尾结点
int length; // 队列当前长度,方便统计
} LinkQueue;
这里用int作为数据域,实际项目中可以替换成任意类型,比如void*指针、结构体等。也可以把data定义成通用指针,让队列更通用,但初学阶段先用int把逻辑跑通,再考虑泛型化。
注意,队列结构体里存放的是两个指针,而不是两个结点。这样队列结构体本身很小,可以放在栈上,也可以动态分配,操作非常灵活。
2.3 带头结点和不带头结点怎么选
带头结点的链式队列是指front指向一个额外的头结点,这个头结点的data域为空,next才指向真正的首元结点。不带头结点的队列front直接指向首元结点。
这个选择直接影响出队和判空的代码复杂度。
带头结点的好处是:当队列为空时,front和rear都指向头结点,判空条件可以统一写成“front == rear”。出队时,即使删到只剩最后一个元素,也不需要特殊处理front的变化。对于初学者来说,带头结点的写法更友好,边界情况更容易处理。
不带头结点的队列实现起来更精简,内存少一个结点,但代码里很多地方要条件判断。比如队列为空时front和rear都是NULL,入队时要区分“空队列”和“非空队列”,出队时要判断“删除后队列是否变空”。一旦漏一个判断,程序就崩给你看。
我的建议是:学习阶段先写带头结点的版本,逻辑清晰,bug少。等理解了队列的指针变化规律,再尝试去掉头结点,加深对边界条件的理解。两种写法都要会,因为考试和工作里都可能遇到。
3. 链式队列的核心操作,手写代码一步步来
3.1 初始化:让空队列长什么样
初始化要做的事情很简单:创建头结点,让front和rear都指向它,length记为0。注意,这一步很多人会漏掉头结点的内存分配,直接声明两个指针就完事,后面一访问就段错误。
c复制// 初始化链式队列
void InitQueue(LinkQueue *q) {
Node *head = (Node *)malloc(sizeof(Node));
if (head == NULL) {
printf("内存分配失败\n");
exit(1);
}
head->next = NULL;
q->front = head;
q->rear = head;
q->length = 0;
}
初始化之后,队列结构体的front和rear指向同一个头结点,length为0。此时队列为空,但头结点已经存在。
提示:写完malloc一定要检查返回值是否为NULL。很多初学者觉得malloc不会失败,但当你处理大规模数据时,系统内存不足是真实存在的。以后写工程项目,这种防御性检查是基本素养。
3.2 入队:新结点从尾巴接上去
入队操作分三步:创建一个新结点,把数据填进去;让当前rear指向的结点的next指向新结点;让rear指向新结点。最后length加1。
c复制// 入队操作
void EnQueue(LinkQueue *q, int value) {
Node *newNode = (Node *)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return;
}
newNode->data = value;
newNode->next = NULL;
q->rear->next = newNode; // 原队尾结点指向新结点
q->rear = newNode; // 更新队尾指针
q->length++;
}
这里有个细节:为什么要先让新结点的next指向NULL?因为新结点即将成为新的队尾,队尾结点的next必须是NULL,表示链表结束。如果你忘了这一步,新结点next会是随机值,后面遍历链表时就会出错。
入队的核心是“先接后移”。先把新结点挂到链表上,再让rear指针移动到新结点。顺序绝对不能反,否则你就丢掉了原队尾的地址,链表就断了。
3.3 出队:从队头弹出去,别忘了释放内存
出队是链式队列里最容易出错的点。带头结点的情况下,真正的队头是front->next指向的结点。出队时,先保存要删除的结点的指针,然后让front的next跳过它指向下一个结点,最后释放内存。
c复制// 出队操作
int DeQueue(LinkQueue *q, int *value) {
if (q->front == q->rear) {
printf("队列为空,无法出队\n");
return 0;
}
Node *del = q->front->next; // 找到队头结点
*value = del->data; // 传出数据
q->front->next = del->next; // 头结点直接指向原队头的下一个结点
// 删除的是最后一个结点时,rear也要更新
if (q->rear == del) {
q->rear = q->front;
}
free(del); // 释放结点内存
q->length--;
return 1;
}
这个函数里我用int*参数传出数据,返回值表示出队是否成功。为什么不用int直接返回数据?因为当队列为空时,你没法用一个int同时表达“出错”和“返回数据”,所以要么用返回值做状态标志,要么用双重指针。这里采用“返回值表示成功,参数传出数据”的方式,是C语言里很常见的写法。
特别注意那段判断“删除的是最后一个结点”的逻辑。当队列里只有一个元素时,出队前front->next指向这个唯一结点,出队后front->next变成NULL,但rear还指着那个已经被free的结点。如果不把rear也指向front,rear就变成了悬空指针,后续入队就会崩溃。
3.4 判空、取队头、队列长度这些辅助操作
判空很简单:带头结点时,front == rear就说明队列为空。之前有人问我,为什么不能用front->next == NULL?其实两种写法等价,但front == rear读起来更直观,也更能体现队列结构。
取队头操作不入队不出队,只是看一眼队头元素的值。
c复制// 取队头元素
int GetHead(LinkQueue *q, int *value) {
if (q->front == q->rear) {
return 0;
}
*value = q->front->next->data;
return 1;
}
// 获取队列长度
int GetLength(LinkQueue *q) {
return q->length;
}
// 判断队列是否为空
int IsEmpty(LinkQueue *q) {
return q->front == q->rear;
}
取队头不需要释放内存,所以简单很多。注意GetHead和DeQueue的区别,前者只读,后者会删除结点。
3.5 完整可运行的C语言示例
学习数据结构,只看代码不跑一遍等于没学。我把上面的函数拼起来,加个main函数,让你可以直接拷贝编译运行。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct Queue {
Node *front;
Node *rear;
int length;
} LinkQueue;
void InitQueue(LinkQueue *q) {
Node *head = (Node *)malloc(sizeof(Node));
if (head == NULL) { printf("内存分配失败\n"); exit(1); }
head->next = NULL;
q->front = head;
q->rear = head;
q->length = 0;
}
void EnQueue(LinkQueue *q, int value) {
Node *newNode = (Node *)malloc(sizeof(Node));
if (newNode == NULL) { printf("内存分配失败\n"); return; }
newNode->data = value;
newNode->next = NULL;
q->rear->next = newNode;
q->rear = newNode;
q->length++;
}
int DeQueue(LinkQueue *q, int *value) {
if (q->front == q->rear) {
printf("队列为空,无法出队\n");
return 0;
}
Node *del = q->front->next;
*value = del->data;
q->front->next = del->next;
if (q->rear == del) {
q->rear = q->front;
}
free(del);
q->length--;
return 1;
}
int GetHead(LinkQueue *q, int *value) {
if (q->front == q->rear) return 0;
*value = q->front->next->data;
return 1;
}
int IsEmpty(LinkQueue *q) {
return q->front == q->rear;
}
int GetLength(LinkQueue *q) {
return q->length;
}
// 销毁队列:释放所有结点,包括头结点
void DestroyQueue(LinkQueue *q) {
Node *cur = q->front;
while (cur != NULL) {
Node *next = cur->next;
free(cur);
cur = next;
}
q->front = NULL;
q->rear = NULL;
q->length = 0;
}
int main() {
LinkQueue q;
InitQueue(&q);
EnQueue(&q, 10);
EnQueue(&q, 20);
EnQueue(&q, 30);
printf("当前队列长度: %d\n", GetLength(&q));
int val;
if (GetHead(&q, &val)) {
printf("队头元素: %d\n", val);
}
while (!IsEmpty(&q)) {
if (DeQueue(&q, &val)) {
printf("出队元素: %d\n", val);
}
}
if (DeQueue(&q, &val)) {
printf("出队元素: %d\n", val);
} else {
printf("出队失败,队列为空\n");
}
DestroyQueue(&q);
return 0;
}
建议你亲手编译运行一下,并且在入队出队的位置多打印几行中间状态,比如打印front和rear指向的地址,这样你能直观看到指针是怎么移动的。
4. 链式队列不只是在课本里:线程池、消息队列和Redis
4.1 阻塞队列与线程池:队列如何充当缓冲区
很多学完数据结构的人会问:“这东西写代码时真能用上吗?”答案是能,而且你每天都在用。
拿Java里的线程池举例,ThreadPoolExecutor内部就有一个阻塞队列(BlockingQueue)。当你往线程池提交任务时,如果当前线程数还没到核心线程数,就直接创建新线程执行;如果线程数已满,任务就被扔进队列里排队。这个队列的底层实现有多种,包括基于数组的有界队列ArrayBlockingQueue,以及基于链表的无界队列LinkedBlockingQueue。
LinkedBlockingQueue的底层就是一条链表,逻辑和链式队列非常像。它额外加了锁和条件变量,实现了“生产者消费者模型”:当队列为空时,消费者线程调用take()会被阻塞,直到生产者放入新任务;当队列满了,生产者调用put()会被阻塞,直到消费者取走任务。
你看,链式队列加上线程同步机制,就成了支撑高并发系统的核心组件。学数据结构时把链式队列的底层逻辑搞清楚,再看这些工程实现会轻松很多。
4.2 消息队列里的生产消费模型
消息队列(Message Queue)这个名字里直接带Queue,但它已经从一个数据结构演化成了一个分布式系统中的中间件。Kafka、RabbitMQ、RocketMQ这些消息队列,本质上都在做一件事:让生产者把消息放进队列,消费者从队列里取消息处理。
这里有个很有意思的点:为什么业务系统需要消息队列?因为生产者和消费者的处理速度往往不一致。比如订单系统每秒产生1万个订单,但库存系统每秒只能处理1000个,如果直接同步调用,库存系统会被打垮。中间加一个消息队列,订单系统把消息迅速放入队列,库存系统按自己的节奏慢慢消费。队列就像一个“削峰填谷”的缓冲池。
链式队列的动态扩容特性,在这种“吞吐量不可预知”的场景下非常有价值。如果队列长度会突发增长,基于数组的有界队列就可能触发拒绝策略,而基于链表的无界队列可以暂时扛住压力。当然,无界队列也可能导致内存耗尽,所以工程上需要权衡。
4.3 Redis中的List与队列
Redis里的List数据结构也常被用来实现队列功能。List的底层是双向链表(quicklist),支持在头部和尾部操作。
用Redis做队列的经典命令是LPUSH配合RPOP:生产者用LPUSH从左边压入消息,消费者用RPOP从右边弹出消息。这样先进先出的语义就和链式队列完全一致。Redis官方还提供了阻塞版本BRPOP,如果列表为空,消费者会一直阻塞等待,直到有新消息进来。
值得一提的是,Redis还推出了Stream数据结构,专门用于消息队列场景,支持消费者组、消息持久化等高级功能。但不管怎么包装,核心思想依然是队列:一端进,一端出,靠指针维护顺序。
如果你学数据结构时只关注代码,不思考这些真实场景,你会觉得队列很抽象。但当你看到Redis的LPUSH/RPOP、线程池的LinkedBlockingQueue时,你会发现它们骨子里都是链式队列的变体,只是包了一层壳。
4.4 单调队列:给排队加一个条件
数据结构课程里还有一个“隐藏副本”——单调队列。它和普通队列最大的区别是:队列中的元素按单调递增或单调递减排列,而且入队时会把破坏单调性的元素从队尾弹出。
单调队列最经典的场景是解决“滑动窗口最大值”问题。给你一个数组和一个窗口大小k,窗口每次右移一格,要求输出窗口内的最大值。暴力法每次遍历窗口是O(k),用单调递减队列可以做到O(1)取最大值,总复杂度O(n)。
单调队列的底层依然是链式或数组队列,但入队操作不再是无脑追加,而是先比较再决定是否弹出队尾元素。这说明队列不只是“先进先出”的容器,还可以通过自定义规则,衍生出强大的算法工具。学链式队列时,如果能把front和rear的移动理解透,再看单调队列会容易很多。
5. 链式队列常见的坑与调试技巧
5.1 出队不释放结点,内存泄漏
这是初学者最容易犯的错误。写出队操作时,只改了指针,却忘了free。短时间内程序内存可能没什么变化,但如果你在一个循环里反复入队出队,内存占用会不断上涨,最后OOM。
我见过有人写了一个长跑服务,每隔几秒就入队一个任务,出队时没释放结点,运行一天后内存爆掉。排查了半天才发现是这个问题。所以记住:在C语言里,凡是malloc出来的内存,不用了一定要free。链式队列的所有结点都是动态分配的,销毁队列时也要逐个释放,不然同样泄漏。
判断是否泄漏的工具,Linux下可以用valgrind,macOS可以用leaks。我建议初学者养成习惯,写完队列后跑一下内存检测,这是程序员的基本素养。
5.2 忘了更新rear指针
入队时你创建了newNode,也让原队尾的next指向了newNode,但如果你忘了更新q->rear = newNode,会发生什么?下一次入队时,你还是往原来的队尾后面追加结点,新结点没有成为队尾,而是被“跳过去”了。链表会出现多条链互相交错,或者明明入队了两个元素,队尾却一直指向第一个元素。
调试这类问题时,最好的方法是打印rear->data看看是不是最后一个入队的元素。如果不一致,那就说明rear的更新出了问题。
注意:入队操作里“先接后移”是铁的纪律。先让原队尾结点的next指向新结点,再让rear指向新结点。两个操作顺序不能换,也不能漏。
5.3 front是指向头结点还是首元结点搞不清
带头结点和不带头结点的写法,最大的区别就在front的指向。带头结点时,front始终指向头结点,队头元素是front->next。不带头结点时,front直接指向首元结点,当队列为空时front是NULL。
很多教材的代码混用了这两种约定,你看的时候一会儿看到front->next,一会儿看到front直接操作,很容易晕。我的建议是:固定使用一种约定,写代码时用注释标明“带头结点”还是“不带头结点”,避免自己搞混。
如果你在调试时发现取队头操作取到的值不对,先检查front到底指向哪个结点,不要急着怀疑别的。
5.4 链表操作顺序错乱导致断链
链表操作里最经典的错误是:在出队时,先用q->front->next = del->next,然后又访问del->next为NULL,发现取不到数据。这就是操作顺序错了。
正确的顺序是:先拿着del指针,再修改前一个结点的next,最后释放del。在释放之前,你可以通过del访问它的data和next。一旦你执行了free(del),就绝对不能再去访问del的任何成员,否则就是使用已释放内存,属于未定义行为。虽然程序有时候看起来还好,但那是运气好,不是代码对。
如果程序出现“段错误”或者“双击运行时崩溃”,大概率和指针使用顺序有关。老老实实一行一行读代码,加上printf大法,很快能定位。
5.5 快速定位问题的调试心得
链式队列的调试比数组调试难,因为你看到的是指针,而不是连续的内存。我调试链式队列时通常分三步。
第一步,检查初始化。初始队列为空,front和rear是否相等?如果不相等,说明初始化就错了。第二步,入队几个元素,打印每次入队后的front->data和rear->data。在带头结点的写法中,front->data没用,但rear->data应该是最后一个入队的值。第三步,出队几个元素,打印出队值和出队后的队头值,判断是否符合先进先出。
我还会把队列遍历一遍打印出来。注意,遍历时要从front->next开始,因为front是头结点,不是首元结点。这个“多走一步”的细节,新手很容易栽。
c复制// 打印队列所有元素,带头结点版本
void PrintQueue(LinkQueue *q) {
Node *cur = q->front->next;
while (cur != NULL) {
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
6. 学习链式队列的一点个人体会
链式队列是我学数据结构时觉得“代码最像代码”的一个结构。它没有数组那么直白,但也没有红黑树那么复杂,恰到好处地让你体会到“指针操作”的乐趣和陷阱。
我踩过最深的坑,就是当年忘了在出队时更新rear,导致队列元素删光之后,入队再入队,程序直接崩溃。那次调试花了我三四个小时,最后用打印地址的方式找到了问题。从那以后,我写链式队列时总是习惯性地问自己三句话:front现在指向谁?rear现在指向谁?如果我要增删结点,这几条指针关系是否还能保持一致?
如果你刚学到这里,我的建议是别急着背代码,先自己画图。拿纸笔把每个结点的地址、front和rear的指向画出来,模拟入队和出队过程。画三轮之后,相信我,指针不会再是你的噩梦。
链式队列学完之后,你其实已经掌握了链表的一大半精髓。后面学栈、二叉树、图,很多代码模式都是相通的,只是规则变了而已。数据结构的学习本质上是建立“抽象模型”的能力,队列就是一个非常好的起点。把链式队列吃透,你后面会越学越快。
