⭐栈和队列
✨栈
栈是一种特殊的线性表,它只允许数据在固定的一端进行增删操作,而不像顺序表那样可以头插或头删,所以栈只能进行类似于尾插或尾删的操作。进行数据插入和删除操作的一端为栈顶Top,另一端则为栈底Bot。因为栈空间也属于线性表,存储数据占用的是连续的一段物理内存空间,其实现方式可以为顺序表或链表,在本章中采用顺序表的定义方式。
🌠栈的结构定义
栈的结构为先进后出型结构FILO(First in Last out),即先压栈的数据后出来,而后压栈的数据先出来。此处使用顺序表的方式来定义栈,结构体中包含一个用于指向用户动态开辟内存空间的指针arr,数据类型STEtype决定了指针一次移动多少字节的内存空间;一个用于标识栈中元素个有效个数top(根据顺序表特性,top下标值代表的是栈顶元素个下一个位置);一个标识栈容量空间大小capacity用于给栈空间扩容。
typedef int STEtype; //栈中数据以int为例
typedef struct Stack
{
STEtype* arr; //栈空间指针
int top; //栈中元素个数
int capacity; //栈容量
}ST;
🌠栈的初始化
因为栈使用顺序表结构进行定义,对于在主函数体中定义的栈结构体指针,需要使其指向初始化函数中在堆空间动态开辟的结构体用以存储栈的各种信息,初始化函数定义如下所示:
ST* StackInit()
{
ST* NewStack = (ST*)malloc(sizeof(ST)); //实参为栈结构体指针,指向动态开辟的栈结构体
assert(NewStack);
NewStack->arr = NULL; //将栈中各内置类型成员变量初始化为空
NewStack->capacity = NewStack->top = 0;
return NewStack;
}
🎃函数采用无参数传入并带返回值返回的形式,在函数内部显性动态申请一个栈的内存空间并将处于堆的该地址赋值给临时结构体形参指针NewStack,之后将其中处于栈上的指针arr和数据capacity, top置空后返回,是一种较为安全的初始化形式。初始化方式还可以参照顺序表一章,将在外界定义的结构体变量取地址传入,函数内部不带返回值,直接在函数内部置空数据和指针即可,两种方式皆可,本例采取前一种思路。
🌈函数测试
ST* StackBot = StackInit();
🌈调试观察
可看到栈空间初始化成功(地址为00E588B0,由结构体指针StackBot存储),栈中存储数据的指针arr和数据均初始化为NULL。
🌠压栈
压栈就是给栈空间内部压入数据,因为栈空间的特殊性,先存入的数据越接近栈底,而后入的数据越接近栈顶。使用顺序表的结构进行栈定义需要考虑数组空间的扩容,所以需要用到realloc函数在压栈的过程中不断调整顺序表空间(同时将数据整体迁移到新空间),而在数组中存数据则需要通过下标来将数据存入对应位置,这个下标就需要栈顶标识数据top(即顺序表中的size)来控制。
原理图如下
🎀压栈函数
void StackPush(ST* Bot, STEtype x)
{
assert(Bot);
if (Bot->top == Bot->capacity) //扩容检查
{
int expand = Bot->capacity == 0 ? 4 : Bot->capacity * 2;
STEtype* NewSpace = (STEtype*)realloc(Bot->arr, expand * sizeof(STEtype)); //数据迁移
assert(NewSpace);
Bot->capacity = expand;
Bot->arr = NewSpace;
}
Bot->arr[Bot->top] = x; //压栈
Bot->top++;
}
-
Bot(Bottom)为将定义好栈结构的栈底结构体指针传入,因为栈数据的插入只有压栈一种方式,不像顺序表那样具有头插,中间插入等多种方式,所以扩容检测不需要单独封装为函数,而只需定义在压栈函数中即可。
-
记住top标识的是待压入的栈顶位置,即栈顶元素的下一个位置,而不是当前栈顶元素的下标。
🌠出栈
出栈操作类似于顺序表的尾删,且仅能在栈顶将数据弹出,后进入栈的数据先被弹出,而最先进栈的数据越靠后才被弹出,原理图如下:
🎀出栈函数
void StackPop(ST* Bot)
{
assert(Bot);
if (StackEmpty(Bot)) //删除前,先判断栈内是否为空
{
printf("栈为空\n"); //如果栈为空,则不进行任何出栈操作
return;
}
else
{
Bot->top--;
}
}
弹栈函数中引用了栈判空函数StackEmpty(),用于判断栈是否已经为空,如果为空则不执行弹栈操作,如果不为空,可以多次调用弹栈函数继续将栈顶下标top自减,直到栈内的有效数据完全删除为止(即内存空间的数据无法访问)。
🎀栈判空函数
bool StackEmpty(ST* Bot)
{
assert(Bot);
return Bot->top == 0; //根据栈中有效数据标识个数top下标来判断
}
🎃如果栈顶下标为0表示链表为空,返回真(非0值),不为0则表示找非空,返回假(0)。
🌠取栈顶元素
栈是一种特殊的线性结构,访问栈的数据只能通过一次次取其栈顶元素来访问顺序表的每个元素,如果想遍历栈的所有数据,仅能取一次栈顶元素就弹一次栈,这样才能一层层向下访问到其他更早前压入栈的数据。需要注意的一点是,因为栈顶下标top指向的是栈顶元素的后一个位置,所以对于栈顶元素的访问需要对top-1才可取到栈顶数据,并将该值返回。
🎀取栈顶函数
STEtype StackTop(ST* Bot)
{
assert(Bot);
if (StackEmpty(Bot)) //判断栈内是否为空,若为空,返回无意义的值-1
{
printf("栈为空\n");
return -1;
}
else
{
return Bot->arr[Bot->top - 1];
}
}
-
如果栈为空,返回真进入if字句,打印“栈为空”并返回无意义的“-1”值。如果栈不为空,则访问栈顶下标的前一个下标对应内存空间的元素,并将该值返回。
-
上面提到,对于栈的遍历必须取栈顶再弹栈,才能层层遍历往下访问更底层数据,而遍历访问的数据与压栈顺序刚好相反,因为压栈是从栈底到栈顶,而取栈顶再弹栈则是从栈顶到栈底。
🎀栈遍历函数
void PrintPop(ST* Bot)
{
assert(Bot);
printf("Top-> ");
while (!StackEmpty(Bot))
{
printf("%d ", StackTop(Bot));
StackPop(Bot);
}
printf("<-Bot");
}
🌈测试压栈,取栈顶和弹栈函数
//栈初始化
ST* StackBot = StackInit();
//压栈1,2,3,4,并分别取栈顶元素观察
StackPush(StackBot, 1);
printf("当前栈顶为:%d\n", StackTop(StackBot));
StackPush(StackBot, 2);
printf("当前栈顶为:%d\n", StackTop(StackBot));
StackPush(StackBot, 3);
printf("当前栈顶为:%d\n", StackTop(StackBot));
StackPush(StackBot, 4);
printf("当前栈顶为:%d\n", StackTop(StackBot));
//进行两次弹栈
StackPop(StackBot);
StackPop(StackBot);
printf("当前栈顶为:%d\n", StackTop(StackBot));
//遍历栈再取栈顶
PrintPop(StackBot);
printf("当前栈顶为:%d\n", StackTop(StackBot));
🌈观察结果
当前栈顶为:1
当前栈顶为:2
当前栈顶为:3
当前栈顶为:4 //在此处已经将1,2,3,4压入栈中
当前栈顶为:2 //进行了两次弹栈,此时栈中剩余1,2共两个数据,其中1为栈底元素,2为栈顶元素
Top-> 2 1 <-Bot //遍历栈验证,遍历完之后为空栈
当前栈顶为:-1 //再访问栈顶元素,因为空栈,所以返回无意义的值-1
🌠压栈元素个数函数
int StackSize(ST* Bot) //计算压栈元素个数
{
assert(Bot);
return Bot->top;
}
返回栈顶下标,即当前栈中存储的有效数据个数。
🌠栈销毁
不同于链表销毁,需要将主动开辟结点逐个释放,最后释放头结点。因为栈结构是基于顺序表构建的,所以直接将在内存中扩容开辟的数组内存空间释放,再将栈初始化开辟的包含栈结构体信息的数据释放即可,销毁原理图如下:

🎀栈销毁函数
ST* StackDestroy(ST* Bot)
{
assert(Bot);
free(Bot->arr); //1. 先释放栈开辟的数组空间
Bot->arr = NULL; //2. 栈指针置空
Bot->capacity = Bot->top = 0; //3. 结构体信息置空
free(Bot); //4. 结构体空间释放
Bot = NULL; //5. 结构体指针置空
return Bot; //6. 结构体地址返回给实参
}
🎃需要注意,上述的内存空间释放顺序不能颠倒,因为如果先将结构体空间释放,则栈指针无法找到之前扩容开辟的栈数组空间,从而无法释放,可能会造成内存泄漏,开辟的空间无法及时回收的情况。
🌈销毁测试
//建栈
ST* StackBot = StackInit();
//压栈
StackPush(StackBot, 1);
StackPush(StackBot, 2);
//销毁栈
StackBot = StackDestroy(StackBot);
//打印栈
PrintPop(StackBot);
🌈结果观察

当已经置空的结构体指针传入打印函数时,遇到指针判空则断言报错。
✨队列Queue
相比于栈,队列也是一种特殊的线性结构,它特殊在于数据的先进先出性质(FIFO——First In First Out),这点与栈的数据进出截然相反。队列仅支持数据在一端插入,即队尾插入,而仅能在一端删除,即队头出队。进行插入操作的一端为队尾(入队),进行删除操作的一端为队头(出队)。本章以链表作为队列的结构基础,队列数据的插入和删除就是对链表的每端进行结点的新增和释放。
🌠队列的结构定义
因为队列采用链表为基本结构,所以队列结构包含存储数据的数值域data,以及指向下一结点的指针域next。
typedef int QEtype; //int作为队列数据存储
typedef struct Queue
{
QEtype data;
struct Queue* next;
}QE;
-
本章以整数int作为队里数据的存储类型,将整型类型重命名为QEtype(Queue Element Type),定义队列结点结构体,其中包含整型数值域data和指向队列下一结点的后继指针域next,并将结构体类型重命名为QE。
-
因为链表结点即开即用的性质,所以不需像栈,顺序表空间或带哨兵结点的链表那样需要将空间初始化置空即可开辟新结点存储数据直接使用。
🎀带值队列结点开辟函数
QE* BuyQueueNode(QEtype x)
{
QE* NewNode = (QE*)malloc(sizeof(QE)); //显示开辟新结点
assert(NewNode);
NewNode->data = x; //初始化结点数据
NewNode->next = NULL;
return NewNode;
}
- 开辟新结点并将需要存储的值赋给数值域,初始化后继指针为空,并将该结点地址返回给外部指针以供使用。
🌠入队
队列与栈的特殊之处都在于,数据的插入只能在一端进行(仅尾插),既不能进行头插也不能中间插入,因为是特殊的线性表,所以数据也只能连续存储而不能如同数组那般随机跳跃访问和修改。对于队列链表而言,每入队一个数据,新开辟一个链表并与前面的结点链接起来。
原理图如下:
🎀入队函数
void QueuePush(QE** Head, QEtype x)
{
assert(Head); //二级指针判空
if (QueueEmpty(Head)) //队列判空函数,如果队列为空,则为头指针开辟带值结点
{
*Head = BuyQueueNode(x);
}
else
{
QE* tail = *Head; //如果不为空,则遍历至队列末结点
while (tail->next)
{
tail = tail->next;
}
tail->next = BuyQueueNode(x); //使末节点后继指向新开辟的带值结点
}
}
- 因为采用链表结构,主函数中定义队列结点结构体指针,将其置空后取地址以二级指针的形式传入入队函数,并将待赋值x一同传入。采用二级指针的原因是因为空队列会更改队列实参指针指向,所以为了更改头结点地址则传入实参指针的地址,开辟队列结点作为头结点地址并赋值。
- 如果队列不为空,则定义临时结点遍历找尾指针tail,规定循环结束条件为当找尾指针的后继为空时,结束循环,此时尾指针tail指向的结点刚好为队列的末节点,开辟新结点并与末节点后继指针链接,即完成尾插的入队操作。
- 此处使用了队列判空函数,队列判空根据头结点地址是否为空为依据。
🎀队列判空函数
bool QueueEmpty(QE** Head)
{
assert(Head);
return *Head == NULL; //如果头结点地址为空,返回真,否则返回假
//return QueueSize(Head) == 0; //通过队列结点个数判断的另一种判空方式
}
- 返回值为布尔类型,依据队头结点地址是否为空,为空则返回真,表示队列中无任何结点元素;若队头不为空值,表示存在带值头结点,返回假。除了此种方式,也可以通过判断队列结点元素个数判断队列是否为空。
🎀队列元素个数函数
int QueueSize(QE** Head)
{
assert(Head);
int size = 0;
if (*Head == NULL) //如果头结点地址不为空,表示队列不为空
{
QE* tail = *Head; //边遍历边计数,当遍历指针到达空时停止计数
while (tail)
{
size++;
tail = tail->next;
}
}
return size; //位列为空返回0,非0则返回计数size
}
🌠出队
将存在于队列中的结点释放,队列出队只能从队头出,以链表的角度而言,该队列链表只能进行头删操作,而不能尾删或中间擦除。原理图如下:
🎀出队函数
void QueuePop(QE** Head)
{
assert(Head);
if (QueueEmpty(Head)) //如果队列为空,打印信息提示且不执行删除操作
{
printf("队列为空,无法删除\n");
return;
}
else
{
QE* tmp = (*Head)->next; //如果队列不为空,临时指针备份后继结点地址,等待成为新的头结点
free(*Head);
*Head = tmp;
}
}
队列出队只能从队头出,这也就意味着对于队列数据的遍历,与栈一样,需要层层读取队头元素并将队头结点元素出队后方可访问队列的后续元素,因为队列的元素特征为先进先出,即最先进入的元素最先出队,而最后进入的元素则最后出队。
🌠取队头元素
🎀对于队头元素的访问函数如下所示:
QEtype QueueTop(QE** Head)
{
assert(Head);
if (QueueEmpty(Head))
{
printf("队列为空");
return -1;
}
return (*Head)->data;
}
队头元素的访问读取非常方便,若队列存在,则直接返回头结点的数值域即可;如果队列为空,则返回无意义的值-1并打印提示。
🌈测试用例
//定义空队列
QE* QHead = NULL;
printf("队头元素为:%d\n", QueueTop(&QHead));
//入队
QueuePush(&QHead, 1);
printf("队头元素为:%d\n", QueueTop(&QHead));
QueuePush(&QHead, 2);
printf("队头元素为:%d\n", QueueTop(&QHead));
QueuePush(&QHead, 3);
printf("队头元素为:%d\n", QueueTop(&QHead));
//出队
QueuePop(&QHead);
printf("队头元素为:%d\n", QueueTop(&QHead));
QueuePop(&QHead);
printf("队头元素为:%d\n", QueueTop(&QHead));
QueuePop(&QHead);
printf("队头元素为:%d\n", QueueTop(&QHead));
QueuePop(&QHead);
printf("队头元素为:%d\n", QueueTop(&QHead));
QueuePop(&QHead);
printf("队头元素为:%d\n", QueueTop(&QHead));
🌈观察结果
- 可以看到在入队时,不管尾插了几个数据,队头数据保持不变(用例中均为1)。而每进行一次出队,则队头元素发生变化,这是因为头结点地址的变更使得数值域的值也发生变化,直到队列中元素全部出完后返回了无意义的值-1,表示队列为空。
- 如果要对队列进行遍历,则原理同上,必须取一次队头再出一次队头,这样才能将队列的所有数值遍历完全,当队列中的所有值都遍历后,队列自动为空,与栈的遍历原理相同。队列遍历算法如下:
void QueuePrint(QE** Head)
{
assert(Head);
if (QueueEmpty(Head)) //判断队列是否为空,空则打印提示并退出函数
{
printf("遍历队列为空\n");
}
else
{
printf("遍历出队:Head-> ");
while (*Head) //若非空队列,以头结点地址为空作为循环结束条件
{
printf("%d ", QueueTop(Head)); //每进行一次遍历,打印队头元素,并出队
QueuePop(Head);
}
printf("<-Tail\n");
}
}
🌈测试用例如下:
//定义空队列&打印
QE* QHead = NULL;
QueuePrint(&QHead);
//入队&出队
QueuePush(&QHead, 0);
QueuePush(&QHead, 1);
QueuePop(&QHead);
QueuePush(&QHead, 2);
QueuePush(&QHead, 3);
//打印队列
QueuePrint(&QHead);
//入队&出队
QueuePush(&QHead, 4);
QueuePush(&QHead, 5);
QueuePop(&QHead);
//打印非空和空队列
QueuePrint(&QHead);
QueuePrint(&QHead);
🌈观察结果
🌠队列销毁
队列销毁即链表所有结点的销毁释放,若队列非空,则执行循环出队,待将队列中所有存在结点都头删完全,队头置空即为空队列。
🎀队列销毁函数
void QueueDestroy(QE** Head)
{
assert(Head);
while (!QueueEmpty(Head)) //若队列非空,则循环头删直至队列为空
{
QueuePop(Head);
}
}
🌈测试用例如下:
QE* QHead = NULL;
QueuePush(&QHead, 0);
QueuePush(&QHead, 1);
QueuePush(&QHead, 2);
QueuePush(&QHead, 3);
QueueDestroy(&QHead);
QueueDestroy(&QHead);
QueuePrint(&QHead);
QueueDestroy(&QHead);
🌈观察结果
遍历队列为空
🎃多次调用队列销毁函数不会对队列指针造成影响,因为函数内声明断言的是指向队列头结点指针的指针,而队列头结点指针可以为空,表示队列为无任何结点的空队列;但主函数定义的队列指针取地址后不能为空,否则表示队列不存在。
链表所有结点的销毁释放,若队列非空,则执行循环出队,待将队列中所有存在结点都头删完全,队头置空即为空队列。
🎀队列销毁函数
void QueueDestroy(QE** Head)
{
assert(Head);
while (!QueueEmpty(Head)) //若队列非空,则循环头删直至队列为空
{
QueuePop(Head);
}
}
🌈测试用例如下:
QE* QHead = NULL;
QueuePush(&QHead, 0);
QueuePush(&QHead, 1);
QueuePush(&QHead, 2);
QueuePush(&QHead, 3);
QueueDestroy(&QHead);
QueueDestroy(&QHead);
QueuePrint(&QHead);
QueueDestroy(&QHead);
🌈观察结果
遍历队列为空
🎃多次调用队列销毁函数不会对队列指针造成影响,因为函数内声明断言的是指向队列头结点指针的指针,而队列头结点指针可以为空,表示队列为无任何结点的空队列;但主函数定义的队列指针取地址后不能为空,否则表示队列不存在。