一、栈
(一)、栈的定义
栈是一种遵循后进先出(LIFO,Last In First Out)原则的数据结构。栈的主要操作包括入栈(Push)和出栈(Pop)。入栈操作是将元素添加到栈顶,这一过程中,栈顶指针上移,新元素被放置在栈顶位置;出栈操作则是移除栈顶元素,同时栈顶指针下移。此外,还可以通过获取栈顶元素(Top)操作来查看栈顶元素但不将其移除。
形象的来说,压栈操作就像堆叠盘子,一个盘子放在另一个盘子上。当你想取出盘子时,你必定会从顶部取出。如果从中间取盘子,那就有盘子打烂的风险,这也就是出栈操作。
栈也是一种线性表,在对于表达式求值和符号匹配等方面有很大用途。
如果你已经学完了顺序表,那么栈的实现对你来说轻而易举。
(二)、栈的功能
1、压栈(Push):将一个元素添加到栈顶。比如我们往弹夹里装子弹的动作就相当于入栈操作。
2、出栈(Pop):从栈顶移除一个元素。对应弹夹射击时弹出子弹的过程。
3、获取栈顶元素(Peek):获取栈顶元素,但不将其从栈中移除。这就像是我们查看弹夹最上面的子弹是什么类型,但不把它射出。
4、获取栈中有效元素个数(Size):就像我们查看弹夹中的子弹数目。
5、判断栈是否为空(IsEmpty):检查栈中是否没有元素。当弹夹里没有子弹时,就可以说栈为空。
(三)、栈的实现
对于栈来说,我们可以使用数组或者链表来实现栈。相对而言,使用数组来实现栈比用链表来实现更优。因为进行栈的压栈操作时,数组尾部插入数据的代价更小。而如果使用双向链表又过于麻烦。因此,我们对于栈的结构体定义如下:
typedef struct Stack
{
int* data;//动态数组
int top;//指向栈顶元素,在后续的初始化中,将其初始化为0还是-1,决定着top指向栈顶元素还是指向栈顶元素后一位
int capicity;//容量大小
}Stack;
1.栈的初始化
和顺序表一样,我们需要先进行动态内存申请一定的空间代码如下:
void StackInit(Stack* ps)
{
//动态内存申请4个整形空间,空间申请小一些方便检查后续扩容是否正确
int* ptr = (int*)malloc(sizeof(int) * 4);
if (ptr == NULL)
{
//判断空间是否申请成功,失败则打印错误信息
perror("StackInit::malloc");
return;
}
ps->data = ptr;
ps->capicity = 4;
//我这里是让top指向栈顶元素的后一位,看自己的想法
//这里top的指向如何会影响后续获取栈顶元素功能实现的代码
ps->top = 0;
}
2.动态扩容
这个动态扩容由于我们使用数组来实现栈,因此动态扩容函数与顺序表基本一致,代码如下:
void Expansion(Stack* ps)
{
assert(ps);
if (ps->top == ps->capicity)
{
printf("空间不足\n");
int* ptr = (int*)realloc(ps->data, sizeof(int) * (ps->capicity * 2));
if (ptr == NULL)
{
perror("Expansion::realloc");
return;
}
ps->data = ptr;
capicity*=2;
}
}
3.压栈操作
实质上就是顺序表的尾插。代码如下:
void StackPush(Stack* ps)
{
assert(ps);
//判断是否需要扩容
Expansion(ps);
printf("请输入数字\n");
//如果你的top初始化为-1,那么这里就需要先top++,再赋值
scanf("%d", &ps->data[ps->top]);
printf("入栈成功\n");
ps->top++;
}
4.出栈操作
实质上就是顺序表的尾删,直接使top指针往前移一步,等下次压栈操作后,数据覆盖即可达到出栈作用,代码如下:
void StackPop(Stack* ps)
{
assert(ps);
if (ps->top > 0)
{
ps->top--;
printf("出栈成功\n");
}
else
{
printf("栈中无元素\n");
}
}
5.获取栈顶元素
因为我们这里top初始化为0,所以top一直指向栈顶元素后一位,如果我们想要获取栈顶元素就需要使top减一,代码如下:
int StackTop(Stack* ps)
{
assert(ps);
return ps->data[ps->top-1];
}
6.获取栈顶元素的有效个数
直接返回top即可,代码如下:
int StackSize(Stack* ps)
{
assert(ps);
return ps->top;
}
7.检查栈是否为空
当top与初始化的top数相等时,栈就为空,代码如下:
int StackEmpty(Stack* ps)
{
assert(ps);
if (ps->top == 0)
{
return 0;
}
else
{
return ps->top;
}
}
8.栈的销毁
和顺序表的销毁一致,先释放栈的空间,然后将指针data置空,容量也即capicity和top都置为0。代码如下:
void StackDestroy(Stack* ps)
{
assert(ps);
free(ps->data);
ps->data = NULL;
ps->capicity = 0;
ps->top = 0;
printf("销毁成功\n");
}
至此,一个基础的栈就实现了,完整代码如下。
9.完整代码
stack.h中:
#pragma once
#pragma warning(disable : 4996)
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef struct Stack
{
int* data;
int top;
int capicity;
}Stack;
// 初始化栈
void StackInit(Stack* ps);
//动态扩容
void Expansion(Stack* ps);
// 入栈
void StackPush(Stack* ps);
// 出栈
void StackPop(Stack* ps);
// 获取栈顶元素
int StackTop(Stack* ps);
// 获取栈中有效元素个数
int StackSize(Stack* ps);
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
int StackEmpty(Stack* ps);
// 销毁栈
void StackDestroy(Stack* ps);
Fstack.c中:
#include"stack.h"
void StackInit(Stack* ps)
{
int* ptr = (int*)malloc(sizeof(int) * 4);
if (ptr == NULL)
{
perror("StackInit::malloc");
return;
}
ps->data = ptr;
ps->capicity = 4;
ps->top = 0;
}
void Expansion(Stack* ps)
{
assert(ps);
if (ps->top == ps->capicity)
{
printf("空间不足\n");
int* ptr = (int*)realloc(ps->data, sizeof(int) * (ps->capicity * 2));
if (ptr == NULL)
{
perror("Expansion::realloc");
return;
}
ps->data = ptr;
ps->capicity*=2;
}
}
void StackPush(Stack* ps)
{
assert(ps);
Expansion(ps);
printf("请输入数字\n");
scanf("%d", &ps->data[ps->top]);
printf("入栈成功\n");
ps->top++;
}
void StackPop(Stack* ps)
{
assert(ps);
if (ps->top > 0)
{
ps->top--;
printf("出栈成功\n");
}
else
{
printf("栈中无元素\n");
}
}
int StackTop(Stack* ps)
{
assert(ps);
return ps->data[ps->top-1];
}
int StackSize(Stack* ps)
{
assert(ps);
return ps->top;
}
int StackEmpty(Stack* ps)
{
assert(ps);
if (ps->top == 0)
{
return 0;
}
else
{
return ps->top;
}
}
void StackDestroy(Stack* ps)
{
assert(ps);
free(ps->data);
ps->data = NULL;
ps->capicity = 0;
ps->top = 0;
printf("销毁成功\n");
}
stack.h:
#include"stack.h"
Stack ps;
int main()
{
// 初始化栈
StackInit(&ps);
int a,b;
do
{
printf("请输入数字\n");
scanf("%d", &a);
switch (a)
{
case 1:
// 入栈
StackPush(&ps);
break;
case 2:
// 出栈
StackPop(&ps);
break;
case 3:
// 获取栈顶元素
b = StackTop(&ps);
printf("栈顶元素:%d\n", b);
break;
case 4:
// 获取栈中有效元素个数
printf("%d\n", StackSize(&ps));
break;
case 5:
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
printf("%d\n", StackEmpty(&ps));
break;
case 0:
// 销毁栈
StackDestroy(&ps);
printf("退出\n");
break;
}
} while (a);
return 0;
}
二、队列
(一)、队列的定义
在数据结构的大家庭中,队列是一位独特而重要的成员。想象一下,在超市结账的队伍里,先来的顾客排在前面先结账离开,后来的顾客则依次在队尾加入等待,这就是典型的 “先进先出(FIFO,First In First Out)原则,而队列正是这种原则在计算机领域的完美体现。
队列作为一种特殊的线性表,它的操作被严格限定在两端进行。一端被称为队尾(rear),专门用于插入新元素,就像新顾客加入结账队伍的末尾;另一端是队头(front),负责删除元素,恰似排在队伍最前面的顾客完成结账后离开。这种操作受限的特性,赋予了队列先进先出的独特性质,也使得它在众多算法和实际应用中发挥着关键作用 。无论是操作系统中的任务调度、网络通信中的数据包处理,还是广度优先搜索算法中的节点遍历,队列都扮演着至关重要的角色
和栈一样,它的实现同样可以使用数组和链表来实现。因为上述我们使用了数组来实现栈,故此次队列的实现我们采用链表来实现,也即链式队列。
在链式队列中,每个节点包含数据域和指针域,数据域用于存储队列元素,指针域则指向下一个节点。队列通过两个指针来管理:头指针(front)指向链表的头节点,代表队头;尾指针(rear)指向链表的尾节点,代表队尾 。
入队操作时,创建一个新节点存储新元素,然后将其插入到链表的尾部,同时更新尾指针指向新节点;出队操作则是删除链表头部的节点,返回该节点的数据,并更新头指针指向下一个节点。比如,有一个初始为空的链式队列,当元素 3 入队时,创建一个新节点存储 3,此时头指针和尾指针都指向这个新节点 。接着元素 4 入队,创建新节点并插入到链表尾部,尾指针更新指向新节点。当出队时,删除头指针指向的节点(包含元素 3),头指针移动到下一个节点(包含元素 4) 。
链式队列的优点是不需要预先知道队列的最大容量,因为链表可以动态地分配内存,避免了顺序队列可能出现的溢出问题;而且在进行插入和删除操作时,只需要修改指针,不需要移动大量元素,效率较高。然而,链式队列也有缺点,由于每个节点都需要额外的指针域来指向下一个节点,这会占用更多的内存空间;并且链表不支持随机访问,访问特定位置的元素需要从头开始遍历链表,时间复杂度较高 。
(二)、队列的功能
1、队尾入队列
2、队头出队列
3、获取队列头部元素
4、获取队列尾部元素
5、获取队列有效元素个数
6、检查队列是否为空
(三)、队列的实现
因为链式队列需要头指针和尾指针,因此我们不能只像链表那样只用一个结构体。我们需要再使用一个结构体以使进行函数传参时更方便,故结构体的构造如下:
//节点
typedef struct QueueNode
{
int data;
struct QueueNode* next;
}Qnode;
typedef struct Queue
{
//头指针
Qnode* head;
//尾指针
Qnode* tail;
//计数存储数据个数
int size;
}Queue;
1、队列初始化
void QueueInit(Queue* q)
{
assert(q);
q->head = NULL;
q->tail = NULL;
q->size = 0;
}
因为还未存储数据,故头指针和尾指针都置空,以防止野指针出现。不要忘记将size也初始化为0;
2、队尾入队列
先看图:
如果我们是往队列中插入第一个节点,此时头指针和尾指针都指向空。我们就需要将头指针和尾指针都指向新节点,再将节点的next指针指向NULL;
而如果我们插入新节点时,队列中已有数据存储,也即有节点存在,将上述两图结合起来看,第一张代表插入新节点前,第二张代表插入新节点后。我们需要先使尾指针指向的节点的next指针指向新节点,再让尾指针指向新节点,最后再使新节点置空即可。代码如下:
void QueuePush(Queue* q)
{
assert(q);
//申请新节点
Qnode* ptr = (Qnode*)malloc(sizeof(Qnode));
if (ptr == NULL)
{
perror("QueuePush::malloc");
return;
}
printf("请输入数字\n");
scanf("%d", &ptr->data);
//提前将新节点next指针置空
ptr->next = NULL;
//判断队列是否为空
if (q->head == NULL && q->tail == NULL)
{
q->head = q->tail = ptr;
}
else
{
q->tail->next = ptr;
q->tail = ptr;
}
q->size++;
}
3、队头出队列
先看代码:
void QueuePop(Queue* q)
{
assert(q);
//判断头指针是否为空,为空那还出什么队列
assert(q->head);
//先存储头指针指向的节点的下一个节点的位置
Qnode* headnext = q->head->next;
//释放头指针指向的节点空间
free(q->head);
//再让头指针指向之前存储的节点
q->head = headnext;
//如果队列中只有一个节点,那释放空间后,头指针是空,但
//尾指针没有被置为空,而是处于野指针状态,因此也要将
//尾指针置空
if (q->head == NULL)
{
q->tail = NULL;
}
q->size--;
printf("出队列成功\n");
}
4、获取队列头部元素
我们知道在链式队列中,链表头即是队头,链表尾即是队尾。获取队列头部元素即可以直接通过头指针获取。代码如下:
int QueueFront(Queue* q)
{
assert(q);
if (q->head == NULL)
{
printf("队列无元素\n");
return NULL;
}
return q->head->data;
}
5、获取队列尾部元素
和获取队列头部元素一致,更改指针即可。代码如下:
int QueueBack(Queue* q)
{
assert(q);
if (q->tail == NULL)
{
printf("队列无元素\n");
return NULL;
}
return q->tail->data;
}
6、获取队列有效元素个数
int QueueSize(Queue* q)
{
assert(q);
return q->size;
}
7、检查队列是否为空
为空返回0,不为空返回非零结果。
int QueueEmpty(Queue* q)
{
assert(q);
return q->size;
}
8、队列的销毁
队列的销毁和链表的销毁一致,遍历一遍,在遍历中存储下一个节点的位置,销毁当前节点,更新条件即可。代码如下:
void QueueDestroy(Queue* q)
{
assert(q);
Qnode* ptr = q->head;
if (q->head == NULL)
{
return;
}
while (ptr)
{
Qnode* ptrnext = ptr->next;
free(ptr);
ptr = ptrnext;
}
q->head = q->tail = NULL;
printf("队列销毁成功\n");
q->size = 0;
}
至此,一个基础的队列就完成了,完整代码如下:
9、完整代码
queue.h:
#pragma once
#pragma warning(disable : 4996)
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
//节点
typedef struct QueueNode
{
int data;
struct QueueNode* next;
}Qnode;
typedef struct Queue
{
//头指针
Qnode* head;
//尾指针
Qnode* tail;
//计数存储数据个数
int size;
}Queue;
// 初始化队列
void QueueInit(Queue* q);
// 队尾入队列
void QueuePush(Queue* q);
// 队头出队列
void QueuePop(Queue* q);
// 获取队列头部元素
int QueueFront(Queue* q);
// 获取队列队尾元素
int QueueBack(Queue* q);
// 获取队列中有效元素个数
int QueueSize(Queue* q);
// 检测队列是否为空,如果为空返回非零结果,如果非空返回0
int QueueEmpty(Queue* q);
// 销毁队列
void QueueDestroy(Queue* q);
Fqueue.c:
#include"queue.h"
void QueueInit(Queue* q)
{
assert(q);
q->head = NULL;
q->tail = NULL;
q->size = 0;
}
void QueuePush(Queue* q)
{
assert(q);
//申请新节点
Qnode* ptr = (Qnode*)malloc(sizeof(Qnode));
if (ptr == NULL)
{
perror("QueuePush::malloc");
return;
}
printf("请输入数字\n");
scanf("%d", &ptr->data);
//提前将新节点next指针置空
ptr->next = NULL;
//判断队列是否为空
if (q->head == NULL && q->tail == NULL)
{
q->head = q->tail = ptr;
}
else
{
q->tail->next = ptr;
q->tail = ptr;
}
q->size++;
}
void QueuePop(Queue* q)
{
assert(q);
//判断头指针是否为空,为空那还出什么队列
assert(q->head);
//先存储头指针指向的节点的下一个节点的位置
Qnode* headnext = q->head->next;
//释放头指针指向的节点空间
free(q->head);
//再让头指针指向之前存储的节点
q->head = headnext;
//如果队列中只有一个节点,那释放空间后,头指针是空,但
//尾指针没有被置为空,而是处于野指针状态,因此也要将
//尾指针置空
if (q->head == NULL)
{
q->tail = NULL;
}
q->size--;
printf("出队列成功\n");
}
int QueueFront(Queue* q)
{
assert(q);
if (q->head == NULL)
{
printf("队列无元素\n");
return NULL;
}
return q->head->data;
}
int QueueBack(Queue* q)
{
assert(q);
if (q->tail == NULL)
{
printf("队列无元素\n");
return NULL;
}
return q->tail->data;
}
int QueueSize(Queue* q)
{
assert(q);
return q->size;
}
int QueueEmpty(Queue* q)
{
assert(q);
return q->size;
}
void QueueDestroy(Queue* q)
{
assert(q);
Qnode* ptr = q->head;
if (q->head == NULL)
{
return;
}
while (ptr)
{
Qnode* ptrnext = ptr->next;
free(ptr);
ptr = ptrnext;
}
q->head = q->tail = NULL;
printf("队列销毁成功\n");
q->size = 0;
}
queue.c:
#include"queue.h"
Queue Que;
int main()
{
int a;
// 初始化队列
QueueInit(&Que);
do
{
printf("输入数字\n");
scanf("%d", &a);
switch (a)
{
case 1:
// 队尾入队列
QueuePush(&Que);
break;
case 2:
// 队头出队列
QueuePop(&Que);
break;
case 3:
// 获取队列头部元素
printf("%d\n",QueueFront(&Que));
break;
case 4:
// 获取队列队尾元素
printf("%d\n",QueueBack(&Que));
break;
case 5:
// 获取队列中有效元素个数
printf("%d\n",QueueSize(&Que));
break;
case 6:
// 检测队列是否为空,如果为空返回非零结果,如果非空返回0
printf("%d\n",QueueEmpty(&Que));
break;
case 0:
// 销毁队列
QueueDestroy(&Que);
break;
}
} while (a);
return 0;
}
(四)、循环队列
在了解顺序队列时,我们提到了假溢出问题,而循环队列就是为了解决这个问题而引入的。循环队列是一种特殊的顺序队列,它将顺序队列的首尾相连,把存储队列元素的表从逻辑上看成一个环。
在循环队列中,我们仍然使用front指针指示队头位置,rear指针指示队尾位置。当rear指针到达数组末尾时,如果数组前面还有空闲空间,它可以重新回到数组开头,继续利用前面的空闲空间。例如,假设我们有一个大小为 5 的循环队列数组,初始时front和rear都为 0 。当进行入队操作时,rear指针依次移动到 1、2、3、4 位置。当rear到达 4 时,如果再进行入队操作,rear就会回到 0 位置(通过取模运算实现)。
判断循环队列是否为空和满的条件与普通顺序队列有所不同。在循环队列中,当front等于rear时,表示队列为空;而当(rear + 1) % 队列容量 == front时,表示队列已满。这里的取模运算保证了rear指针在到达数组末尾时能够回到开头,实现循环的效果。
三、栈和队列的比较
栈和队列虽然都是线性数据结构,但它们在很多方面存在差异:
- 进出顺序:栈遵循后进先出(LIFO)原则,最后进入栈的元素最先出栈;而队列遵循先进先出(FIFO)原则,最先进入队列的元素最先出队 。例如,在程序调用栈中,函数调用是按照后进先出的顺序进行的,而在网络请求队列中,请求是按照先进先出的顺序被处理的。
- 插入删除操作:栈的插入(入栈)和删除(出栈)操作都在栈顶进行;队列的插入(入队)操作在队尾进行,删除(出队)操作在队头进行 。比如,往栈中添加元素就像往一摞盘子上放盘子,只能放在最上面,从栈中取出元素也只能从最上面取;而队列中添加元素就像排队买票,新来的人站在队伍末尾,离开的人从队伍最前面离开。
- 遍历数据速度:栈只能从栈顶开始遍历,若要访问栈底元素,需要依次弹出栈顶元素,遍历过程中需要开辟临时空间来保存数据状态,以确保遍历前后数据的一致性;队列基于地址指针进行遍历,可以从队头或队尾开始遍历,但不能同时进行双向遍历,遍历过程中不会改变数据结构,所以无需开辟额外空间,遍历速度相对较快 。例如,在遍历一个包含 100 个元素的栈时,如果要获取栈底元素,需要将栈顶的 99 个元素依次弹出并保存,然后才能访问栈底元素,最后再将弹出的元素依次压回栈中;而遍历一个包含 100 个元素的队列时,从队头开始遍历,直接按照顺序访问每个元素即可。
- 限定条件:栈只允许在一端进行插入和删除操作;队列允许在一端插入,在另一端删除操作 。这是它们最基本的操作限制,决定了它们在不同场景下的适用性。例如,在实现表达式求值时,利用栈的特性可以方便地处理运算符优先级;而在实现任务调度时,利用队列的特性可以保证任务按照提交顺序依次执行。
- 应用场景:栈常用于处理具有后进先出特性的问题,如函数调用、表达式求值、括号匹配等;队列常用于处理具有先进先出特性的问题,如网络请求处理、消息队列、任务调度等 。例如,在编译器中,使用栈来处理函数调用和递归,确保函数的正确返回和局部变量的正确管理;在分布式系统中,使用消息队列来异步处理消息,提高系统的吞吐量和响应速度。
如果你认为你已经对栈和队列掌握完全,那你可以尝试做一下下面的题目看看:
1.有效的括号
2.用队列实现栈
3.用栈实现队列
4.设计循环队列