深入栈与队列的概念和底层结构实现(数组 vs 链表)

在这里插入图片描述

名称:Doubletful的博客
💠数据结构专栏
签名:路漫漫其修远兮,吾将上下而求索

前言

——栈和队列是计算机科学中最基础、最常用的两种抽象数据类型。几乎每个程序员都能随口说出“栈是后进先出(LIFO),队列是先进先出(FIFO)”,但在实际工程中,选择数组还是链表作为底层容器,会显著影响性能、内存占用和代码复杂度。
本期将使用C语言实现栈和队列,其主要内容包括:
1.使用头文件声明、源文件定义的形式呈现源码
2.介绍栈与队列的概念,并使用数组实现栈,链表实现队列
3.提供图例和使用链表实现栈,数组实现队列的对比表格

栈专题

一、概念

——栈是一种操作受限的线性表,只允许在固定一端进行插入和删除数据操作,进行插入和删除操作的一端称为栈顶,另一端称为栈底,栈中的数据遵循后进先出原则(LIFO:List In First Out)
栈的插入操作被称为:进栈,入栈,压栈,在栈顶插入
栈的删除操作被称为:出栈,在栈顶删除
生活中的实例:空羽毛球桶,第一个被放入的羽毛球最后一个被取出,最后一个被放入的羽毛球第一个被取出(在不暴力拆除桶底的情况下( ̄▽ ̄)")
注:说了这么多,但总结来看数据结构栈的本质还是数组,只是操作受限
数据结构栈的物理结构和逻辑结构示例图:
在这里插入图片描述

二、代码实现

准备

前置知识:

  • assert()函数介绍
    C语言标准库中的调试宏,用于在程序运行时检查条件是否成立。若条件为假(0),则输出错误信息(文件、行号、表达式)并调用 abort() 终止程序,若条件为真(非0),则无动作。常用于捕捉“不可能发生”的逻辑错误、验证函数前置条件
  • perror()函数介绍
    C 语言标准库函数,用于打印错误信息。调用格式:perror(“前缀字符串”),输出格式为“前缀字符串:错误原因\n”,常用于系统调用或库函数失败后,快速定位错误原因
  • exit()函数介绍
    C 语言标准库函数,用于正常终止程序。刷新所有输出缓冲区、关闭已打开的流。将退出状态码返回给操作系统(0 or EXIT_SUCCESS 表示成功-1 or EXIT_FAILURE 表示失败
  • 布尔值
    C语言并不自带布尔值作为内置数据类型,使用需引入标准库<stdbool.h>

头文件内容总览

注:代码部分如果直接复制不能成功运行,请将所有中文前的#替换为//

#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int STDataType;

#栈
typedef struct Stack
{
	STDataType* arr;
	int top;
	int capacity;
}ST;

#初始化栈
void STInit(ST* pst);

#添加:入栈
void STPush(ST* pst, STDataType x);

#删除:出栈
void STPop(ST* pst);

#获取栈顶元素
STDataType STTop(ST* pst);

#判断是否为空
bool STEmpty(ST* pst);

#获取元素个数
int STSize(ST* pst);

#销毁栈
void STDestroy(ST* pst);

栈的属性有使用动态开辟的数组 arr,用于指向栈顶的指针 top 和用于存储数组当前容量的 capacity。栈从逻辑上操作受限,因此除基础的初始化和销毁外只能从栈顶入栈和出栈,不能在指定位置插入或删除元素,也只能获取当前栈顶的元素。除此之外还有两个判断栈信息的函数用于确定栈是否为空以及返回当前栈内的元素个数

初始化

void STInit(ST* pst)
{
	assert(pst);
	pst->arr = NULL;
	pst->top = 0;
	pst->capacity = 0;
}

传入栈,并断言传入的指针不为 NULL
关于 top 的初始化有一个重要的点涉及到后续操作的逻辑控制,如果 top 指向栈顶,则需将 top 初始化为 -1,如果初始化为零会出现 top 为零时无法判断当前栈中是否还有元素的问题。如果 top 指向栈顶元素的下一个位置,就将其初始化为零,这种写法便于判满、入栈和返回元素个数

判断空间容量

void STCheckCapacity(ST* pst)
{
	if (pst->top == pst->capacity)
	{
		int newcapacity = (pst->capacity == 0 ? 4 : pst->capacity * 2);
		STDataType* tmp = (STDataType*)realloc(pst->arr, newcapacity * sizeof(STDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(1);
		}
		pst->arr = tmp;
		pst->capacity = newcapacity;
	}
}

当 top 等于 capacity 时需动态扩容,当第一次扩容时初始化容量为4,否则扩容为当前容量的二倍,扩容后需判断是否扩容成功,失败返回提示信息后退出程序,成功时再执行更新操作

添加:入栈

void STPush(ST* pst, STDataType x)
{
	assert(pst);
	STCheckCapacity(pst);
	#入栈
	pst->arr[pst->top++] = x;
}

在这里插入图片描述
传入栈与要添加的元素,断言传入指针不为 NULL,判断容量
与顺序表相同,在数组尾插数据并让 top++

删除:出栈

void STPop(ST* pst)
{
	assert(pst && pst->top);
	#出栈
	pst->top--;
}

在这里插入图片描述
传入栈,断言传入指针不为 NULL 且栈中元素个数不为0
与顺序表相同,使 top-- 就能达到逻辑删除

获取栈顶元素

STDataType STTop(ST* pst)
{
	assert(pst && pst->top);
	#获取栈顶元素
	return pst->arr[pst->top - 1];
}

传入栈,断言传入指针不为 NULL 且栈中元素个数不为0
top 指向栈顶的下一个位置,可以利用 top - 1 找到并返回栈顶元素

判断是否为空

bool STEmpty(ST* pst)
{
	assert(pst);
	#判断是否为空
	return pst->top == 0;
}

传入栈,断言传入指针不为 NULL
在数组中,能根据每个元素的下标位置确定在这个元素前还有几个元素,例如下标为3的元素,它的前面就刚好有3个值,所以,我们同样可以根据 top 是否指向栈底判断栈是否为空,为空返回 true,否则返回 false

获取元素个数

int STSize(ST* pst)
{
	assert(pst);
	#获取元素个数
	return pst->top;
}

传入栈,断言传入指针不为 NULL
同上,top 指向栈顶元素的下一个位置,因此就为当前栈中的元素个数

销毁栈

void STDestroy(ST* pst)
{
	assert(pst);
	free(pst->arr);
	pst->arr = NULL;
	pst->top = pst->capacity = 0;
}

传入栈,断言传入指针不为 NULL
释放栈在堆区开辟的动态空间并将指针初始化,初始化 top 和容量

一份测试代码

void Test_Stack()
{
	ST s;
	#初始化测试
	STInit(&s);
	#入栈测试
	STPush(&s, 4);
	STPush(&s, 3);
	STPush(&s, 2);
	STPush(&s, 1);
	#出栈测试
	STPop(&s);
	STPop(&s);
	#获取栈顶元素测试
	printf("%d\n", STTop(&s));
	#判断是否为空测试
	printf("%d\n", STEmpty(&s));
	#获取元素个数测试
	printf("%d\n", STSize(&s));
	#销毁测试
	STDestroy(&s);
}

总结

数组实现栈的优缺点:

维度分析
时间复杂度所有操作均为O(1)
空间复杂度O(N),但存在一定的容量预留,可能导致空间浪费
缓存命中率极好,连续内存,CPU 预加载效率高
扩容成本动态扩容时需复制整个数组,单次操作 O(N),但均摊后很低
适用场景频繁压栈弹栈、对速度要求极高、元素数量可预估的场景

队列专题

一、概念

——队列也是一种操作受限的线性表,只允许在固定一端进行插入数据操作,另一端进行删除数据操作,插入数据的一端称为队尾,删除数据的一端称为队头,队列中的数据遵循先进先出的原则(FIFO:First In First Out)
队列的插入操作被称为:入队,在队尾插入
队列的删除操作被称为:出队,在队头删除
生活中的实例:排队购买,先来的人先买到票后从队头离开,后来的人排在队尾等待,整个流程有序进行(没有人插队的情况)
数据结构队列的物理结构和逻辑结构示例图:
在这里插入图片描述

二、代码实现

头文件内容总览

注:代码部分如果直接复制不能成功运行,请将所有中文前的#替换为//

#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int QDataType;

#定义队列的节点结构
typedef struct QueueNode
{
	QDataType data; #存储值
	struct QueueNode* next; #指向下一个节点的指针
}QNode;

#定义存储队列信息的结构
typedef struct Queue
{
	QNode* phead; #指向队头的指针
	QNode* ptail; #指向队尾的指针
	int size; #当前的元素个数
}Queue;

#初始化队列
void QueueInit(Queue* pq);

#添加:入队
void QNodePush(Queue* pq, QDataType x);

#删除:出队
void QNodePop(Queue* pq);

#获取队头数据
QDataType QueueFront(Queue* pq);

#获取队尾数据
QDataType QueueBack(Queue* pq);

#判断是否为空
bool QueueEmpty(Queue* pq);

#获取元素个数
int QSize(Queue* pq);

#销毁队列
void QueueDestroy(Queue* pq);

从代码看,队列无疑多用了一个结构体用于实现完整结构,该结构的作用在于明确队头和队尾在物理存储结构中的位置,使我们无需考虑操作物理结构链表时执行的额外操作,只需将重点放在对队列的操作逻辑上。与此同时,我们注意到队列中有一个名为 QSize 的函数用于获取当前队列中的元素个数,我们知道,统计整条链表的元素个数需要完整遍历获得,单次操作的时间复杂度为O(N),为高效可以在 Queue 结构体中额外维护一个变量 size 用于记录队列中的元素个数,使 QSize 函数的时间复杂度优化为O(1)

初始化

void QueueInit(Queue* pq)
{
	assert(pq);
	pq->phead = pq->ptail = NULL;
	pq->size = 0;
}

传入队列,并断言传入的指针不为 NULL
当前队列中无节点,因此将队头指针和队尾指针都置空,并使 size 初始化为0

添加:入队

void QNodePush(Queue* pq, QDataType x)
{
	QNode* newnode = (QNode*)malloc(sizeof(QNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		return;
	}
	newnode->data = x;
	newnode->next = NULL;
	#入队
	if (pq->ptail == NULL)
	{
		pq->phead = pq->ptail = newnode;
	}
	else
	{
		pq->ptail->next = newnode;
		pq->ptail = newnode;
	}
	pq->size++;
}

在这里插入图片描述
传入队列与要添加的元素,断言传入指针不为 NULL,创建新节点
分为两种情况,当队列中无任何元素时,应该将队头指针与队尾指针同时指向入队节点,否则就使当前队尾元素的 next 指向入队节点后,修改队尾指针

删除:出队

void QNodePop(Queue* pq)
{
	assert(pq && pq->size);
	#出队
	#队列元素为1时删除导致队尾指针为野指针
	QNode* next = pq->phead->next;
	free(pq->phead);
	if (next == NULL)
		pq->phead = pq->ptail = next;
	else
		pq->phead = next;
	pq->size--;
}

在这里插入图片描述
传入队列,断言传入指针不为 NULL 且队列元素个数不为0
分为队列元素个数为1和不为1两种情况,为1时在销毁队头指针指向的节点后需同时修改头尾指针指向为 NULL,不为1时只修改队头指针指向

获取队头数据

QDataType QueueFront(Queue* pq)
{
	assert(pq && pq->size);
	#获取队头数据
	return pq->phead->data;
}

传入队列,断言传入指针不为 NULL 且队列元素个数不为0
直接通过队头指针返回第一个元素

获取队尾数据

QDataType QueueBack(Queue* pq)
{
	assert(pq && pq->size);
	#获取队尾数据
	return pq->ptail->data;
}

传入队列,断言传入指针不为 NULL 且队列元素个数不为0
通过队尾指针返回最后一个元素

判断是否为空

bool QueueEmpty(Queue* pq)
{
	assert(pq);
	#判断是否为空
	return pq->size == 0;
}

传入队列,断言传入指针不为 NULL
通过维护的变量 size 判断,为空返回 true,非空返回 false

获取元素个数

int QSize(Queue* pq)
{
	assert(pq);
	#获取元素个数
	return pq->size;
}

传入队列,断言传入指针不为 NULL
pass

销毁队列

void QueueDestroy(Queue* pq)
{
	assert(pq);
	QNode* cur = pq->phead;
	while (cur)
	{
		QNode* next = cur->next;
		free(cur);
		cur = next;
	}
	pq->phead = pq->ptail = NULL;
	pq->size = 0;
}

传入队列,断言传入指针不为 NULL
与销毁链表的流程大致相同,注意需初始化头尾指针和 size 变量

一份测试代码

void Test_Queue()
{
	Queue q;
	#初始化测试
	QueueInit(&q);
	#入队测试
	QNodePush(&q, 1);
	QNodePush(&q, 2);
	QNodePush(&q, 3);
	QNodePush(&q, 4);
	#出队测试
	QNodePop(&q);
	QNodePop(&q);
	#获取队头数据测试
	printf("%d\n", QueueFront(&q));
	#获取队尾数据测试
	printf("%d\n", QueueBack(&q));
	#判断是否为空测试
	printf("%d\n", QueueEmpty(&q));
	#获取元素个数测试
	printf("%d\n", QSize(&q));
	#销毁队列测试
	QueueDestroy(&q);
}

总结

链表实现队列的优缺点:

维度分析
时间复杂度所有操作均为 O(1)
空间复杂度O(N),每个元素额外存储一个指针变量(64 位系统下 8 字节)
缓存命中率较差,节点在堆中分散分配,跳跃访问导致缓存未命中
扩容成本无需扩容,动态增长,无一次性复制开销
适用场景元素数量动态变化大、无法预估容量、频繁入队出队且对内存分配不敏感的场景

三、四种实现的全面对比

方向选择

我们换位思考,先看链表实现栈的场景
如果用表头做栈底,表尾做栈顶,则每次返回栈顶数据都需遍历链表,操作的时间复杂度为O(N)。如果用表头做栈顶,表尾做栈底,则时间复杂度不变
再看数组实现队列的场景
使用高下标位的方向作为队头,低下标位的方向作为队尾时,执行入队操作时需整体右移元素,时间复杂度为O(N)。使用高下标位的方向作为队尾,低下标位的方向作为队头时,执行出队操作时需整体左移元素,时间复杂度为O(N)。如果两种操作均不移动元素,在使用数组实现队列时会出现假溢出问题,导致浪费空间越来越多
由此,关于栈顶、栈底、队头、队尾方向的选择问题便有了大致的概念

对比表格

为了更清晰地看到 数组 vs 链表 在栈和队列上的差异,下表从多个维度做了详细对比

对比维度数组实现栈链表实现栈链表实现队列数组实现队列
底层结构连续内存数组单向链表(头插/头删)单向链表(头删/尾插)连续内存数组 + 头尾指针
核心操作入栈、出栈在数组末尾入栈、出栈在链表头部入队在尾部,出队在头部入队在尾部,出队在头部
最坏时间复杂度入栈扩容时 O(N)始终 O(1)始终 O(1)入队扩容时 O(N),需要搬移元素 O(N)
空间占用容量可能大于实际元素数,浪费空间每个元素多一个指针,额外空间开销大每个元素多一个指针,额外空间开销大容量可能浪费,但无指针开销
缓存命中率极好(顺序访问)差(节点分散)差(节点分散)极好(顺序访问)
是否需要动态扩容需要,有复制成本不需要,动态分配节点不需要,动态分配节点需要,有复制成本
空栈/空队列判断top == 0head == NULLsize == 0front == rear
典型应用场景函数调用栈、表达式求值浏览器历史(可双向)消息队列、BFS 队列任务队列(但实际多用链表)

——数组天生适合栈,因为栈的操作只在一端进行,数组的末尾操作O(1)且缓存命中率高,扩容成本均摊后微乎其微。链表天生适合队列,因为队列要在两端操作,链表的头删尾插均为O(1)且无需搬移元素,动态增长无容量限制。

整体总结

通过本文,你不仅掌握了栈和队列的核心概念与操作,更重要的是,我们从底层存储视角剖析了数组与链表这两种实现方式的内在权衡:
➤数组的优势在于缓存命中率高和随机访问,但扩容和搬移元素是它的代价。因此数组适合操作集中于末尾(比如栈)的场景,而在队列中需要额外设计来避免搬移元素(循环队列)。
➤链表的优势在于动态伸缩和无需搬移元素,但代价是指针占用额外空间和缓存命中率低。链表适合两端操作频繁的队列,而在实现栈中并非最优解。

四、练习

推荐以下LeetCode题目
20. 有效的括号 & 链接:link.
225. 用队列实现栈 & 链接:link.
232. 用栈实现队列 & 链接:link.

⚛️EL PSY CONGROO,十分感谢你的阅读

本期不确定:
推荐的练习题是否充足,是否“高效”

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值