说实话,我当年学数据结构的时候,一直有个执念:栈是栈、队列是队列,两种东西为什么要放在一个章节里讲。直到我把顺序栈、头插链栈、尾插链栈、顺序队、优先级链表队列这五种实现全部手写了一遍,才真正想明白一件事——栈和队列本质上都只是操作受限的线性表,底层要么用数组、要么用链表,区别只在"从哪头进、从哪头出"。
这篇文章就把这五种实现放在一起捋清楚,包括进制转换为什么天然适合用栈、循环队列怎么绕开普通顺序数组的"假溢出"、优先级链表队列如何在插入时维护有序性。代码都是完整可跑的C语言实现,穿插我踩过的坑和事后的理解。适合正在学数据结构的学生、准备面试的开发者,以及那些和我一样"背了代码但没真懂"的人。
1. 从操作受限的线性表说起:为什么栈和队列总被放在一起讲
1.1 栈和队列的本质:一种"访问策略"而不是一种"存储结构"
很多初学者第一个认知误区,就是把栈当成一种存储结构。实际上,栈和队列不决定数据在内存里怎么摆,只决定你允许怎么去读写。
- 栈:只允许在同一端(栈顶)插入和删除,叫做后进先出(LIFO)。
- 队列:一端插入(队尾)、另一端删除(队头),叫做先进先出(FIFO)。
- 优先级队列:出队顺序不按插入先后,而按数据元素的优先级。
这个"操作受限"四个字特别关键。数组和链表本身什么都能存,但当我们把访问方式限制成"只能从栈顶操作"时,它就是栈;限制成"只能从队尾进、队头出"时,它就是队列。限制产生结构,这句话在后端系统设计里同样成立——Redis的List、消息队列、线程池的任务缓冲,全都是在用不同策略限制读写。
1.2 数组和链表:实现线性表的两块底座
实现栈和队列,底层存储介质无非两种:
顺序存储(数组):连续内存,按下标访问,随机访问O(1)。缺点是插入删除需要搬移元素;队列用数组实现时,还要面对"出队后队头空间无法复用"的假溢出问题。
链式存储(链表):节点分散,靠指针串起来,插入删除只改指针,O(1)。缺点是不能按下标随机访问,且每个节点多存一个next指针,空间开销更大。
栈和队列的多种实现,本质上就是"数组/链表 × 存取策略"的排列组合。明白了这个,标题里的五种实现就不该靠死记硬背,而应该自己推出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序栈与进制转换:后进先出如何天然承担"倒序"任务
2.1 顺序栈的结构设计与入栈出栈过程
顺序栈就是用数组模拟栈,核心就一个栈顶指针top。我用的top从-1开始,这种设计在初始化和判空时最直观:
c复制#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top;
} SqStack;
void initStack(SqStack *s) {
s->top = -1;
}
int isEmpty(SqStack *s) {
return s->top == -1;
}
int isFull(SqStack *s) {
return s->top == MAX_SIZE - 1;
}
void push(SqStack *s, int x) {
if (isFull(s)) {
printf("栈满,无法入栈\n");
return;
}
s->data[++s->top] = x;
}
int pop(SqStack *s) {
if (isEmpty(s)) {
printf("栈空,无法出栈\n");
return -1;
}
return s->data[s->top--];
}
这里有一个细节值得多说一句:++s->top 和 s->top++ 的区别。入栈是先移动指针再写数据,所以用前置自增;出栈是取完数据再下移指针,所以用后置自减。顺序错了,索引就会对不上。这个虽然是小事,但笔试手写代码时很多人就是栽在这种地方。
2.2 十进制转二/八/十六进制:栈为什么天生适合干这个
进制转换的数学原理很简单:对十进制数持续除以目标进制,记录每次的余数,最后把余数从后往前拼起来。举个具体例子,十进制数13转二进制:
code复制13 / 2 = 6 余 1
6 / 2 = 3 余 0
3 / 2 = 1 余 1
1 / 2 = 0 余 1
余数依次是 1, 0, 1, 1,但正确的二进制是从最后一个余数往前读:1101。这个"先得到的余数最后输出"的顺序,正好是后进先出。所以进制转换用栈来实现,不是凑巧,而是这个场景天然就是LIFO的。
顺带说一句,网上常有人问"十进制转八进制为什么余数顺序要反着排",如果你理解了栈的LIFO,这个问题就根本不需要问——先除先余的数,在结果里恰好排最后。
2.3 一个能转二/八/十六进制的小程序
下面这段代码把余数统一收集到栈里,最后依次弹栈输出。十六进制需要处理10到15这些大于9的数字,所以我加了一个字母映射:
c复制void conversion(int n, int base) {
if (n <= 0 || base < 2 || base > 16) {
printf("参数不合法\n");
return;
}
SqStack s;
initStack(&s);
while (n > 0) {
push(&s, n % base);
n /= base;
}
while (!isEmpty(&s)) {
int digit = pop(&s);
if (digit < 10) {
printf("%d", digit);
} else {
printf("%c", 'A' + digit - 10);
}
}
printf("\n");
}
int main() {
int num, base;
printf("输入一个十进制正整数:");
scanf("%d", &num);
printf("输入目标进制(2/8/16):");
scanf("%d", &base);
printf("转换结果:");
conversion(num, base);
return 0;
}
这里要提醒两个边界情况。第一,很多版本会漏掉n等于0的情况——如果输入0,while循环不会执行,栈里什么都没有,输出是空的。但0转成任何进制都应该是0。第二,如果输入的是负数,按位取余的逻辑会出问题,所以我在conversion开头直接做了参数校验。考虑边界是数据结构代码能不能用在正式场合的分水岭,这个意识建议从学数据结构那天就开始培养。
3. 链表实现栈与队列:头插和尾插的两种人生
3.1 头插链栈(LIFO):入栈出栈都在头部完成
链表实现栈,最大的好处是没有容量上限(只要内存够),不用像数组那样判断是否满。实现思路是:始终在头部插入节点,始终从头部删除节点,head节点就是栈顶。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct {
Node *head;
} LinkedStack;
void initStack(LinkedStack *s) {
s->head = NULL;
}
void push(LinkedStack *s, int x) {
Node *node = (Node *)malloc(sizeof(Node));
if (node == NULL) {
printf("内存分配失败\n");
return;
}
node->data = x;
node->next = s->head;
s->head = node;
}
int pop(LinkedStack *s) {
if (s->head == NULL) {
printf("栈空,无法出栈\n");
return -1;
}
Node *temp = s->head;
int x = temp->data;
s->head = temp->next;
free(temp);
return x;
}
int getTop(LinkedStack *s) {
if (s->head == NULL) {
printf("栈空\n");
return -1;
}
return s->head->data;
}
注意push里 node->next = s->head 这个操作,它把新节点插到了当前头部的前面,然后更新head指向新节点。头插法的核心就是这两行,顺序不能反——如果你先改head再改node->next,那原来的链表就找不到了。
3.2 尾插链栈(FIFO):用链表实现"先进先出"的真正形态
这里必须澄清一个命名问题。标题里"尾插链栈(FIFO)"叫栈,其实并不准确——FIFO是队列的行为特征。用链表实现队列时,入队用尾插法,出队从头部删除(或反过来,取决于实现)。我当时的实现维护了front和rear两个指针,入队操作在尾部进行:
c复制typedef struct {
Node *front;
Node *rear;
} LinkedQueue;
void initQueue(LinkedQueue *q) {
q->front = NULL;
q->rear = NULL;
}
void enqueue(LinkedQueue *q, int x) {
Node *node = (Node *)malloc(sizeof(Node));
if (node == NULL) {
printf("内存分配失败\n");
return;
}
node->data = x;
node->next = NULL;
if (q->rear == NULL) {
q->front = q->rear = node;
return;
}
q->rear->next = node;
q->rear = node;
}
int dequeue(LinkedQueue *q) {
if (q->front == NULL) {
printf("队列空,无法出队\n");
return -1;
}
Node *temp = q->front;
int x = temp->data;
q->front = temp->next;
if (q->front == NULL) {
q->rear = NULL;
}
free(temp);
return x;
}
这个实现里最容易被忽略的一行是:当队列从非空变成空时,rear也必须置为NULL。如果不做处理,队列删空后front变成NULL,但rear还指向已经被释放的旧节点。下次入队时,因为rear不是NULL,代码会走到 q->rear->next = node,访问野指针,直接段错误。我初学那会因为这个bug调了一晚上,所以现在每次写到链式队列,都会刻意检查front和rear的同步关系。
3.3 为什么同一套链表能同时实现栈和队列
这个点我当时想明白之后,对数据结构的理解上了一个台阶。对比上面两段代码,你会发现:
- 链栈:头插 + 头删
- 链队:尾插 + 头删
两者出队(出栈)操作完全一样,都是从头部删一个节点;区别只在于插入时是插在头部还是尾部。换句话说,在链表场景下,栈和队列的代码差异只有一行——node->next = s->head 还是 q->rear->next = node。
这也解释了为什么Redis的List既能当栈用又能当队列用。Redis列表底层是双向链表(新版是quicklist),它同时支持LPUSH/LPOP(头插头删,栈)和RPUSH/LPOP(尾插头删,队列),因为双向链表的头和尾都能方便地操作。数据结构的知识不是背一个个孤立的实现,而是理解某个存储结构能支撑哪些操作组合——这个认知在工程选型里比记住某一段代码管用得多。
4. 循环队列:顺序存储下如何绕开"假溢出"
4.1 朴素顺序队的致命缺陷:出队以后空间就浪费了
前面说了链表实现队列很直观,为什么还要用顺序数组实现队列?答案很简单:数组能利用CPU缓存局部性,访问连续内存比到处是malloc的链表更快;而且没有动态分配节点的开销。但数组实现队列有一个绕不开的问题。
假设MAX_SIZE等于5,front指向队头、rear指向队尾。当你入队3个元素、出队3个元素后,front=3、rear=3,此时从逻辑上队列已经空了,但rear已经等于MAX_SIZE,再入队时会直接认为"队满"。数组前面明明空着,却无法使用,这就是假溢出。
解决办法不是用Front回去覆盖前面的空位,而是把数组首尾相接——做成一个环。这就是循环队列。
4.2 循环队列的判空判满:为什么"牺牲一个格子"
循环队列的核心操作是取模运算。指针移动到尾部后,通过 (i + 1) % MAX_SIZE 回到数组头部。但引入循环之后,出现了一个新问题:队空和队满时,front和rear都会相等(都指向同一个位置),没法区分。
常见的解决策略有三类:
| 方案 | 做法 | 判断条件 |
|---|---|---|
| 牺牲一个存储单元 | 约定rear指向最后一个元素的下一个位置,并始终留一个空格 | 队满:(rear + 1) % MAX_SIZE == front |
| 增设size字段 | 记录当前元素个数 | 队满:size == MAX_SIZE;队空:size == 0 |
| 增设tag标志位 | 记录最近一次操作是入队还是出队 | 根据tag区分front==rear时的状态 |
我下面给的实现用的是牺牲一个存储单元方案。它不需要额外字段,判断效率高,也是教材和面试里最常考的。
c复制#define MAX_SIZE 5
typedef struct {
int data[MAX_SIZE];
int front;
int rear;
} SqQueue;
void initQueue(SqQueue *q) {
q->front = 0;
q->rear = 0;
}
int isEmpty(SqQueue *q) {
return q->front == q->rear;
}
int isFull(SqQueue *q) {
return (q->rear + 1) % MAX_SIZE == q->front;
}
void enqueue(SqQueue *q, int x) {
if (isFull(q)) {
printf("队满,无法入队\n");
return;
}
q->data[q->rear] = x;
q->rear = (q->rear + 1) % MAX_SIZE;
}
int dequeue(SqQueue *q) {
if (isEmpty(q)) {
printf("队空,无法出队\n");
return -1;
}
int x = q->data[q->front];
q->front = (q->front + 1) % MAX_SIZE;
return x;
}
int queueLength(SqQueue *q) {
return (q->rear - q->front + MAX_SIZE) % MAX_SIZE;
}
int main() {
SqQueue q;
initQueue(&q);
enqueue(&q, 10);
enqueue(&q, 20);
enqueue(&q, 30);
printf("出队:%d\n", dequeue(&q));
printf("出队:%d\n", dequeue(&q));
enqueue(&q, 40);
enqueue(&q, 50);
printf("当前队长:%d\n", queueLength(&q));
return 0;
}
4.3 队列长度公式和"MAX_SIZE"容量陷阱
上面代码里 queueLength 用了 (rear - front + MAX_SIZE) % MAX_SIZE,而不是简单的 rear - front。原因是入队出队会让rear和front循环回绕,rear 可能小于 front,直接相减会得到负数。加上MAX_SIZE再取模才能得到正确长度。
这个公式不是背的,你自己画一个MAX_SIZE为5的环,模拟几次入队出队,就能悟出来它在做什么——它处理的就是"绕圈"后两个指针不在同一条直线上的情况。
还有一个容量陷阱必须说清楚:这种牺牲一格方案,数组长度为MAX_SIZE时,实际最多只能存MAX_SIZE-1个元素。因为要永远留一个空位来区分空和满。所以如果你真的想存满5个元素,数组得开到6。以前有个同学在实验报告里写"MAX_SIZE=5的队列入队6个元素",这本身就是矛盾的。有这种需求时,应该改用带size字段的方案,而不是强行塞满。
5. 优先级链表队列:从先来先服务到允许"插队"
5.1 优先级队列的数据结构设计
普通队列讲究先来先服务,但现实场景里经常有"任务C不重要可以等,任务A很紧急要立刻处理"。从普通队列升级到优先级队列,核心改变是:入队时不再盲目插到尾部,而是根据优先级找到合适位置插入;出队时仍然从头部取。这样始终保持队头是优先级最高的节点,出队复杂度O(1)。
我这里的优先级约定是数字越小优先级越高(比如优先级1 > 优先级2 > 优先级3),和主流操作系统的进程调度约定一致。如果约定反过来,只需要改插入时的比较符号。
c复制typedef struct PrioNode {
int data;
int priority;
struct PrioNode *next;
} PrioNode;
typedef struct {
PrioNode *head;
} PriorityQueue;
void initPQueue(PriorityQueue *pq) {
pq->head = NULL;
}
void enPQueue(PriorityQueue *pq, int x, int priority) {
PrioNode *node = (PrioNode *)malloc(sizeof(PrioNode));
if (node == NULL) {
printf("内存分配失败\n");
return;
}
node->data = x;
node->priority = priority;
node->next = NULL;
// 特殊情况1:队列为空,或者新节点优先级最高,直接插到头部
if (pq->head == NULL || priority < pq->head->priority) {
node->next = pq->head;
pq->head = node;
return;
}
// 找到第一个优先级 <= 新节点优先级的位置,插到它后面
PrioNode *cur = pq->head;
while (cur->next != NULL && cur->next->priority <= priority) {
cur = cur->next;
}
node->next = cur->next;
cur->next = node;
}
int dePQueue(PriorityQueue *pq) {
if (pq->head == NULL) {
printf("优先级队列空\n");
return -1;
}
PrioNode *temp = pq->head;
int x = temp->data;
pq->head = temp->next;
free(temp);
return x;
}
void printQueue(PriorityQueue *pq) {
PrioNode *cur = pq->head;
while (cur != NULL) {
printf("(%d, pri=%d) ", cur->data, cur->priority);
cur = cur->next;
}
printf("\n");
}
插入逻辑我分了需要注意的两个分支:队列为空或新节点比队头优先级还高时,直接头插;否则遍历链表,找到第一个优先级小于等于新节点优先级的节点,插到它后面。这么写能保证排序稳定性——同样优先级的节点,先入队的排在前面,符合公平原则。
5.2 完整测试与排序过程演示
写个main函数实际跑一下,看看优先级队列怎么维护内部顺序:
c复制int main() {
PriorityQueue pq;
initPQueue(&pq);
enPQueue(&pq, 100, 3);
enPQueue(&pq, 200, 1);
enPQueue(&pq, 300, 2);
enPQueue(&pq, 400, 2);
enPQueue(&pq, 500, 5);
printf("入队后队列内容:");
printQueue(&pq);
printf("依次出队:");
while (pq.head != NULL) {
printf("%d ", dePQueue(&pq));
}
printf("\n");
return 0;
}
输出结果应该是:
code复制入队后队列内容:(200, pri=1) (300, pri=2) (400, pri=2) (100, pri=3) (500, pri=5)
依次出队:200 300 400 100 500
注意同优先级的两个节点300和400,先入队的300排在前面,说明这版插入逻辑保持了稳定性。有的实现里把 <= 写成 <,同样优先级的新节点会插到旧节点前面,破坏公平性。在需要按到达顺序处理同优先级任务时(比如交易系统),这个差异是会直接影响业务正确性的。
5.3 优先级队列的现实关联:线程池的WorkQueue与进程调度
优先级队列在课程实验里可能只是个链表操作练习,但在现实系统里,它就是一堆基础设施的骨架。
Java线程池 ThreadPoolExecutor 的 workQueue 可以传 PriorityBlockingQueue,让任务按优先级执行;操作系统的进程调度里有优先级调度算法,同样维护一个按优先级排列的就绪队列;网络路由里的数据包队列、日志系统的分级处理,也都是"插队"思想的变种。
从这个角度看,写一个优先级链表队列并真正理解它的插入逻辑,比死记"优先级队列的时间复杂度是O(n)"要有价值得多——因为面试官大概率不会考你背复杂度,而是让你现场写一个"支持按优先级有序插入的链表"或者"删除链表中奇数位置的节点"。基本功扎实了,这些题就是换个壳。
6. 五种实现的横向对比与实战选型建议
6.1 五种结构对比:时间、空间与适用场景
把五种实现放到一张表里对比,选型逻辑会清晰很多:
| 实现 | 底层存储 | 入队/入栈 | 出队/出栈 | 容量上限 | 核心优势 | 典型场景 |
|---|---|---|---|---|---|---|
| 顺序栈 | 数组 | O(1) | O(1) | 固定,MAX_SIZE | 实现简单,局部性好 | 表达式求值、进制转换、递归转非递归 |
| 头插链栈(LIFO) | 单链表头插头删 | O(1) | O(1) | 无限(受内存限制) | 无容量上限,动态灵活 | 动态数据不确定的场景 |
| 尾插链栈(FIFO队) | 单链表尾插头删 | O(1) | O(1) | 无限(受内存限制) | 天然FIFO,无假溢出 | 任务队列、BFS |
| 顺序队(循环队列) | 数组+取模 | O(1) | O(1) | MAX_SIZE-1 | 空间利用高效,无搬移 | 固定容量缓冲、资源池 |
| 优先级链表队列 | 有序单链表 | O(n) | O(1) | 无限(受内存限制) | 出队总是最高优先级 | 进程调度、线程池任务排序 |
有一个点单独强调:优先级链表队列的入队是O(n),因为它需要遍历链表寻找插入位置。如果需要频繁插入且数据量很大,应该改用二叉堆实现优先队列,入队和出队都能降到O(logn)。链表版本的优势在于实现直观,几百行代码就能跑通,适合学习阶段理解"有序性维护"的思路。
6.2 从课本题到工程代码:我在实际项目里的借鉴
学完这五种实现,我最大的收获并不是记住了哪个结构用哪个函数,而是形成了一种按需求选型的思维习惯。
举一个实际例子。我之前写一个简易的日志聚合工具,多个线程产生日志,一个消费者线程负责写入文件。最朴素的做法是用普通队列,但某些错误级别的日志需要优先落盘,这时候普通FIFO就不够了。我当时没有立刻上Redis或者RabbitMQ这类重量级中间件,而是基于优先级队列的思想,用两个普通队列实现了一个简化版——高优先级队列为空时再消费低优先级队列。这个方案在日志量只有每秒几千条的规模下运行得很稳定,而且代码量不到两百行。
另一个例子是BFS(广度优先遍历)的实现。很多人背BFS模板时只知道"用队列",但如果你理解链式队列的尾插头删操作,就知道BFS的入队出队实际上就是在维护一个FIFO结构。反过来,如果面试官让你用两个栈实现一个队列,或者用两个队列实现一个栈,你只要理解了本节开头说的"限制产生结构",这类题就不需要背答案,现场推一遍就能写出来。
6.3 三个容易踩的坑和排查思路
从我自己和身边人写数据结构代码的经历来看,有几个坑出现频率极高,我在这里一次性列出来。
第一个坑:链式结构malloc之后不判空。 内存分配失败时,malloc返回NULL,如果直接访问node->data会触发段错误。课程实验里几乎不会遇到内存不足,但养成了不判空的手感,以后写生产代码迟早要还。我建议所有链表代码在malloc之后都加上if (node == NULL)的判断,这其实是C语言工程的基本素养。
第二个坑:链队列删空后rear没有同步置NULL。 前面详细讲过,这个bug的表现是玄学——可能跑几次才崩溃一次,因为野指针指向的内存也许碰巧还在。排查思路是:遇到时好时坏的段错误,优先检查指针的指向关系是否在边界条件下保持同步,特别是front和rear在"空和非空"切换时的状态。
第三个坑:顺序栈和循环队列里判断满的时机。 很多人习惯先操作再判断,但数组实现必须在操作前检查。比如顺序栈已经满了,你还继续data[++top] = x,会覆盖栈外内存;循环队列如果没检查满就入队,新的元素会覆盖队头。我在代码里全部采用了"先判满/空,再操作"的顺序,这个习惯可以避免一大类边界问题。
另外给一个通用的排查建议:如果你发现你的循环队列行为异常,先用笔在纸上画一个环,手动模拟几轮入队出队,把front和rear的移动轨迹画出来。纸笔模拟永远是最快的调试方式,比在编译器里加断点打日志快得多。这也是为什么很多面试官喜欢让人现场画栈和队列的状态变化——画不明白,说明可能只是背了代码,并没有理解结构本身。
写数据结构的代码,动手敲一遍永远比看十遍管用。这五种实现我自己敲了三遍,每敲一遍都能发现上一遍没注意到的细节。如果你现在正在学数据结构,建议不要满足于复制代码跑通,而是试着把栈和队列的互相转换、循环队列的size方案、优先级队列的堆实现这些都推演一遍。数据结构这门课的真正价值,不是那几段经典算法代码,而是让你拥有一套"把真实问题抽象成存取策略"的思维模型。这个东西一旦建立,后面学操作系统、学网络、学各种中间件,都会顺很多。
