
🎬 博主名称 :键盘敲碎了雾霭
🔥 个人专栏 : 《C语言》《数据结构》
⛺️指尖敲代码,雾霭皆可破

文章目录
一、引言
栈(Stack)和队列(Queue)是两种最基本、最常用的线性数据结构,广泛应用于各种算法和系统底层实现中。它们的主要区别在于元素的存取顺序:栈是后进先出(LIFO,Last In First Out) ,而队列是先进先出(FIFO,First In First Out)。
本文将结合一份完整的C语言实现代码,详细讲解栈和队列的结构定义、接口设计、核心函数的实现细节,并通过测试用例演示它们的使用方法。同时,我们也会分析两种实现方式的优缺点,并指出代码中需要注意的地方。
二、栈的实现与分析
栈可以用数组或链表实现,这里采用的是动态数组的方式,优点是数据在内存中连续,缓存友好,且入栈出栈的时间复杂度均为O(1)。缺点是扩容时需要重新分配内存,可能造成空间浪费或拷贝开销。
1. 栈的结构定义(Stack.h)
c
#pragma once
#include <stdio.h>
#include <stdbool.h>
#include <assert.h>
#include <stdlib.h>
typedef int Datatype;
typedef struct Stack
{
Datatype* arr; // 动态数组指针
int top; // 栈顶位置(指向栈顶元素的下一个位置)
int capacity; // 当前数组容量
} ST;
// 函数声明
void STInit(ST* pst);
void STPush(ST* pst, Datatype x);
void STPop(ST* pst);
Datatype STTop(ST* pst);
bool STEmpty(ST* pst);
int STSize(ST* pst);
void STDestroy(ST* pst);
top的含义:这里约定top指向栈顶元素的下一个位置 ,即栈中元素个数正好等于top。初始时top = 0。capacity为数组当前能容纳的最大元素个数,当top == capacity时表示栈满,需要扩容。
2. 核心函数实现(Stack.c)
初始化与销毁
c
void STInit(ST* pst)
{
pst->arr = NULL;
pst->capacity = pst->top = 0;
}
void STDestroy(ST* pst)
{
if (pst->arr)
{
free(pst->arr);
}
pst->capacity = pst->top = 0;
}
初始化将指针置空,容量和大小置零。销毁时释放动态数组内存,并将指针置空,防止野指针。
入栈(Push)
c
void STPush(ST* pst, Datatype x)
{
assert(pst);
// 检查是否需要扩容
if (pst->capacity == pst->top)
{
int newcapacity = pst->capacity == 0 ? 4 : 2 * pst->capacity;
Datatype* ptr = (Datatype*)realloc(pst->arr, sizeof(Datatype) * newcapacity);
if (ptr == NULL)
{
perror("realloc");
return;
}
pst->arr = ptr;
pst->capacity = newcapacity;
}
pst->arr[pst->top++] = x;
}
- 扩容策略:当容量为0时,先分配4个元素的空间;之后每次扩容为原来的2倍。
- 使用
realloc重新分配内存,若失败则打印错误并返回。注意 :realloc失败时原内存依然有效,但此处直接返回会导致入栈失败,且原数据仍然存在,可以接受;但更严谨的做法是保留原数组不变,等后续操作。在教学中可以提醒读者根据实际需求处理。 - 入栈操作直接在当前
top位置存入x,然后top自增。
出栈(Pop)
c
void STPop(ST* pst)
{
assert(pst);
assert(pst->top > 0); // 栈不能为空
pst->top--;
}
出栈非常简单,只需将 top 减1,逻辑上删除了栈顶元素。实际内存并未释放,后续入栈会覆盖该位置。
获取栈顶元素
c
Datatype STTop(ST* pst)
{
assert(pst);
return pst->arr[pst->top - 1];
}
注意 top 指向栈顶下一个位置,所以栈顶元素下标为 top - 1。
判空与大小
c
bool STEmpty(ST* pst)
{
return pst->top == 0;
}
int STSize(ST* pst)
{
return pst->top;
}
3. 栈的测试用例
在 test.c 中有一段被注释的栈测试代码:
c
//int main()
//{
// ST st;
// STInit(&st);
// STPush(&st, 1);
// STPush(&st, 2);
// STPush(&st, 3);
// while (!STEmpty(&st))
// {
// printf("%d\n", st.arr[st.top - 1]);
// STPop(&st);
// }
// STDestroy(&st);
// return 0;
//}
这段代码演示了栈的基本使用流程:
- 初始化栈。
- 连续压入三个元素:1, 2, 3。
- 循环弹出并打印栈顶元素,直到栈空。由于栈的LIFO特性,打印顺序为:3, 2, 1。
- 最后销毁栈释放内存。
三、队列的实现与分析
队列通常使用链表实现,因为数组实现的队列在出队时需要移动大量元素(除非使用循环队列)。这里采用单链表结构,包含头指针和尾指针,使得入队(尾插)和出队(头删)的时间复杂度均为O(1)。
1. 队列的结构定义(Queue.h)
c
#pragma once
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int QDatatype;
typedef struct QueueNode
{
QDatatype a;
struct QueueNode* next;
} QNode;
typedef struct Queue
{
QNode* phead; // 队头指针
QNode* ptail; // 队尾指针
int size; // 队列中元素个数
} Queue;
// 函数声明
void QueueInit(Queue* q);
void QueuePush(Queue* q, QDatatype x);
void QueuePop(Queue* q);
QDatatype QueueBack(Queue* q);
QDatatype QueueFront(Queue* q);
bool QEmpty(Queue* q);
int QSize(Queue* q);
void QueueDestroy(Queue* q);
- 队列节点
QNode包含数据域a和指向下一个节点的指针next。 - 队列结构
Queue持有头指针和尾指针,并维护一个size记录节点个数,方便判空和获取大小。
2. 核心函数实现(Queue.c)
初始化与销毁
c
void QueueInit(Queue* q)
{
assert(q);
q->phead = q->ptail = NULL;
q->size = 0;
}
void QueueDestroy(Queue* q)
{
assert(q);
while (q->phead != NULL)
{
QNode* next = q->phead->next;
free(q->phead);
q->phead = next;
}
q->phead = q->ptail = NULL;
}
初始化时头尾指针置空,size为0。销毁时遍历链表释放每个节点,最后将头尾指针置空。
入队(Push)
c
void QueuePush(Queue* q, QDatatype x)
{
assert(q);
QNode *ptr = (QNode*)malloc(sizeof(QNode));
// 注意:此处应先检查malloc是否成功,再给ptr->a赋值
ptr->a = x;
ptr->next = NULL;
if (ptr == NULL)
{
perror("malloc");
return;
}
if (q->phead == NULL)
{
q->phead = q->ptail = ptr;
}
else
{
q->ptail->next = ptr;
q->ptail = ptr;
}
q->size++;
}
-
代码中的一个小问题 :在检查
ptr == NULL之前就使用了ptr->a和ptr->next,这可能导致对空指针的解引用。正确的做法是先将malloc的结果赋给ptr,然后立即判断ptr是否为空,若不为空再进行赋值。应该调整顺序:cQNode *ptr = (QNode*)malloc(sizeof(QNode)); if (ptr == NULL) { perror("malloc"); return; } ptr->a = x; ptr->next = NULL;读者在实际编码中务必注意此细节。
-
入队时,若队列为空(
phead == NULL),则新节点既是队头也是队尾;否则将新节点链接到尾节点之后,并更新尾指针。最后size加1。
出队(Pop)
c
void QueuePop(Queue* q)
{
assert(q);
assert(q->size > 0);
if (q->phead->next == NULL)
{
free(q->phead);
q->phead = q->ptail = NULL;
}
else
{
QNode* next = q->phead->next;
free(q->phead);
q->phead = next;
}
q->size--;
}
- 出队时先断言队列非空(
size > 0)。 - 如果队列中只有一个节点(
phead->next == NULL),则释放该节点后头尾指针都置空。 - 否则,保存头节点的下一个节点,释放当前头节点,然后头指针指向下一个节点。
- 最后
size减1。
获取队首和队尾元素
c
QDatatype QueueFront(Queue* q)
{
assert(q);
assert(q->phead);
return q->phead->a;
}
QDatatype QueueBack(Queue* q)
{
assert(q);
assert(q->ptail);
return q->ptail->a;
}
直接返回头节点或尾节点的数据,需要确保指针非空。
判空与大小
c
bool QEmpty(Queue* q)
{
assert(q);
return q->size == 0;
}
int QSize(Queue* q)
{
assert(q);
return q->size;
}
利用维护的 size 直接返回。
3. 队列的测试用例
在 test.c 中当前启用的 main 函数是队列测试:
c
int main()
{
Queue q;
QueueInit(&q);
QueuePush(&q, 1);
QueuePush(&q, 2);
printf("%d ", QueueFront(&q)); // 输出队首元素 1
QueuePop(&q); // 出队
QueuePush(&q, 3);
while (!QEmpty(&q))
{
printf("%d ", QueueFront(&q));
QueuePop(&q);
}
QueueDestroy(&q);
}
运行过程分析:
- 初始化队列。
- 入队1、2,此时队列为 [1, 2]。
- 打印队首元素 1,然后出队,队列变为 [2]。
- 入队3,队列变为 [2, 3]。
- 循环打印并出队,输出 2 3,队列变空。
- 销毁队列。
最终输出结果为:1 2 3,符合队列的先进先出特性。
四、总结与对比
| 特性 | 栈 (Stack) | 队列 (Queue) |
|---|---|---|
| 存储结构 | 动态数组 | 单链表 |
| 存取顺序 | 后进先出 (LIFO) | 先进先出 (FIFO) |
| 核心操作 | Push / Pop / Top | Push / Pop / Front / Back |
| 时间复杂度 | 均为 O(1)(Pop 仅逻辑删除) | 均为 O(1)(需维护头尾指针) |
| 适用场景 | 函数调用、括号匹配、深度优先等 | 广度优先搜索、任务调度、缓冲区等 |
优缺点分析
- 动态数组实现的栈:内存连续,访问效率高;扩容时有拷贝开销,但总体入栈均摊O(1)。适合元素个数可预测或频繁访问的场景。
- 链表实现的队列:无需扩容,元素个数不受限制;每个节点需要额外存储指针,内存开销稍大。适合频繁入队出队、元素数量变化大的场景。
五、结语
栈和队列是数据结构学习的基石,理解它们的实现原理对于后续学习更复杂的结构(如树、图)以及算法设计至关重要。本文通过完整的C语言代码,详细剖析了两种结构的内部细节和接口设计,希望能帮助读者不仅会用,更能自己实现。读者也可以尝试用数组实现循环队列,或用链表实现栈,进一步加深理解。
(完)