【数据结构与算法】数据结构基础——栈和队列


博客: 鱼子星_ 数据结构系列专栏: 【数据结构与算法】

栈和队列

1. 栈

1.1 栈的概念

栈是一个特殊的线性表。栈只能在一端进行插入元素和删除元素的操作,其中能进行操作的一端称为栈顶 另一端称作栈底 。其具有先进后出 LIFO(last int first out)的性质


栈的示意图 栈的示意图 栈的示意图

特殊术语

  • 入栈(压栈):在栈顶插入元素称为入栈
  • 出栈:在栈顶删除元素称为出栈

1.2 栈的实现方式分析

【分析】我们根据栈的特殊性质需要选一个更适合的线性结构来实现它

对比维度 顺序表 链表
访问元素 随机访问,时间复杂度 O(1) 遍历链表,时间复杂度 O(n)
插入/删除(头部) 需要移动后面的所有元素,O(n) 只需修改指针,O(1)
插入/删除(尾部) 若空间足够,O(1) 需要遍历到尾部,O(n)(单链表)
插入/删除(中间) 需要移动元素,O(n) 找到位置后修改指针,O(1)
应用场景 需要频繁随机访问、尾部操作密集、数据量相对固定 需要频繁在任意位置插入删除、数据量变化大

经过顺序表和链表的对比我们发现,因为栈只能在栈顶(尾部)进行插入和删除操作,顺序表在尾部的操作的时间复杂度是O(1)而链表为O(n)所以我们更适合使用顺序表来实现栈

------>【动态顺序表详解】<------

1.3 栈的实现

实现方式:顺序表实现,动态分配内存

cpp 复制代码
typedef int STDataType;
typedef struct Stack
{
	STDataType *data;
	int size;      //顺序表有效元素个数
	int capacity;  //顺序表的容量
}Stack;

这里与动态顺序表的定义方式一样,就不过多赘述了......


1.3.1 栈的初始化与销毁

栈的初始化 时间复杂度:O(1)

cpp 复制代码
void StackInit(Stack* st)
{
	assert(st);
	st->data = NULL;
	st->size = st->capacity = 0;
};

这里关于assert(断言)防止空指针的解引用是一个好习惯,可以防止很多莫名其妙的BUG

栈的销毁 时间复杂度:O(1)

cpp 复制代码
void StackDestory(Stack* st)
{
	free(st->data);
	st->data = NULL;
	st->size = st->capacity = 0;
}

1.3.2 入栈与出栈

入栈 时间复杂度:O(1)

cpp 复制代码
void push(Stack* st, STDataType x)
{
	assert(st);
	//判断容量是否足够 不够就扩容
	if(st->size == st->capacity)
	{
		int newcapacity = st->capacity == 0? 4 : st->capacity * 2;
		STDataType* tmp = (STDataType*)realloc(st->data, newcapacity * sizeof(STDataType));
		if(tmp == NULL) exit(-1);
		st->data = tmp;
		st->capacity = newcapacity;
	}
	//插入元素
	st->data[st->size] = x;
	st->size++;
}

出栈 时间复杂度:O(1)

cpp 复制代码
void pop(Stack* st)
{
	assert(st);
	assert(!empty(st));   //判断栈非空
	st->size--;
}

1.3.3 栈的判空与有效元素个数

判空 时间复杂度:O(1)

cpp 复制代码
bool empty(Stack* st)
{
	assert(st);
	return st->size == 0;
}

有效元素个数 时间复杂度:O(1)

cpp 复制代码
int size(Stack* st)
{
	return st->size;
}

1.3.4 栈顶元素

获取栈顶元素 时间复杂度:O(1)

cpp 复制代码
STDataType top(Stack* st)
{
	return st->data[st->size - 1];   //从下标0开始存储元素
}

小结:以上就是栈的基本的实现,操作起来并不难,不过再提醒一句,因为实现数据结构用了大量指针,所以在操作时一定要给指针判空将没有用的指针及时置为NULL

1.4 栈的扩展

1.4.1 两栈共享空间

根据栈的特性,我们使用顺序表来实现栈。可是当栈的存储空间满了的时候,我们需要去为这个栈扩容,每次一满就要扩容这会有很多时间上的消耗。

如果此时有两个相同类型的栈,一个栈的存储空间快溢出了,另外一个确还是有很多空闲的空间,那我们何不根据栈的特性让两个栈合并,使得空间的使用率更高

两个栈合并实际上就是让两个栈共同使用同一个数组(顺序表),栈1 的栈顶指针top1从-1开始,栈2 的栈顶指针top2从capacity(数组的最大容量)开始,如下图

当栈1需要入栈的时候top1指针就像右移,栈2需要入栈时top2向左移

那么要如何判断栈是否满了呢,我们先看两个特殊情况【1】top1直接走到了最右边,即两栈共享的空间全部都是栈1的元素,此时情况如下

此时如果栈满了就会满足top2 - top1 == 1【2】与第一种情况相反top2直接走到了最左边,同理栈满时有top2 - top1 == 1。此时还有一种一般情况如下

也是当top2 - top1 == 1时栈满了。所以得出结论:当top2 - top1 == 1时栈就满了

应用场景

两栈共享空间一般在两个栈满足此消彼长的条件时使用,即栈1元素增加时栈2的元素就要减少,就像买股票,当你买入了一份股票之后那一定有人持有的股票减少了

相反的,如果两个栈都是一直在插入元素的话空间很快就会满,而设计这样的结构也就没意义了。两栈共享空间只是一个技巧,适合两个栈存储的是相同的数据类型,如果是不同的数据类型使用这种存储方式只会使操作更复杂

2. 队列

2.1 队列的概念

队列是一种只能在一端插入数据另一端删除数据FIFO(first in first out)的特殊的线性表。其中插入数据的一端称为队尾 ,删除数据的一端称作队头


队列结构示意图 队列结构示意图 队列结构示意图


关于队列的特殊术语

  • 队头:队列出队一端的第一个元素
  • 队尾:队列入队一端的第一个元素
  • 入队:在队尾插入元素称为入队
  • 出队:在队头删除元素称为出队

2.2 队列的实现方式分析

分析】根据队列的特殊性质,找一种最适合的数据结构来实现它,这里再次拿到上面的对比表格

对比维度 顺序表 链表
访问元素 随机访问,时间复杂度 O(1) 遍历链表,时间复杂度 O(n)
插入/删除(头部) 需要移动后面的所有元素,O(n) 只需修改指针,O(1)
插入/删除(尾部) 若空间足够,O(1) 需要遍历到尾部,O(n)(单链表)
插入/删除(中间) 需要移动元素,O(n) 找到位置后修改指针,O(1)
应用场景 需要频繁随机访问、尾部操作密集、数据量相对固定 需要频繁在任意位置插入删除、数据量变化大

队列与栈不同,栈只能在栈顶的一端进行插入删除等操作。而队列是在队头和队尾两端都进行频繁的操作,所以考虑两种线性表两端的操作

经过对比发现,顺序表和链表在头部操作和尾部操作都各自有优势,所以我们此时就要考虑使用哪个可以优化或者更方便优化


优化 】考虑到链表在尾部的操作是 O(n) 的原因是每次都需要遍历一遍链表才可以找到尾节点,那何不干脆就直接将尾节点保存下来。将链表的尾节点保存下来之后就可以将链表尾部的操作优化到 O(1)


结论】使用链表来实现队列


2.3 队列的实现

队列的结构

cpp 复制代码
typedef int QDataType;
//链表节点的结构
typedef struct QueueNode
{
	QDataType data;
	struct QueueNode* next;
}QueueNode;
//队列的结构
typedef struct Queue
{
	QueueNode* phead;   //指向队列的头节点
	QueueNode* ptail;   //指向队列的尾节点
}Queue;
2.3.1 队列的初始化与销毁

队列的初始化

cpp 复制代码
void QueueInit(Queue* pq)
{
	assert(pq);
	pq->phead = p->ptail = NULL;
}

队列的销毁

cpp 复制代码
void QueueDestory(Queue* pq)
{
	assert(pq);
	QueueNode* pcur = pq->phead;
	while(pcur)
	{
		QueueNode* pnext = pcur->next;
		free(pcur)
		pcur = pnext;
	}
	//最后将头指针和尾指针置为NULL 防止野指针
	pq->phead = pq->ptail = NULL;
}

【注意】销毁队列后要记得将pheadptail置为NULL防止野指针

2.3.2 入队与出队

入队

cpp 复制代码
void push(Queue* pq, QDataType x)
{
	assert(pq);
	//动态申请节点
	QueueNode* newNode = (QueueNode*)malloc(sizeof(QueueNode));
	if(newNode == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
	newNode->data = x;
	newNode->next = NULL;
	//如果队列不为空
	if(pq->phead)
	{
		pq->ptail->next = newNode;
		pq->ptail = newNode;
	}else{
		//队列为空
		pq->phead = pq->ptail = newNode;
	}
}

出队

cpp 复制代码
void pop(Queue* pq)
{
	assert(pq);
	assert(!empty(pq));
	//当队列只有一个节点时
	if(pq->phead == pq->ptail)
	{
		free(pq->phead);
		pq->phead = pq->ptail = NULL;
	}else{
		QueueNode* pnext = pq->phead->next;
		free(pq->phead);
		pq->phead = pnext;
	}
}

这里要注意出队是要分两种情况!

【1】当队列只有一个节点时【2】当队列有多个节点时

2.3.3 队列的判空与队头队尾
cpp 复制代码
//判空
bool empty(Queue* pq)
{
	return pq->phead = NULL;
}
//返回队头元素
QDataType QueueFront(Queue* pq)
{
	assert(pq);
	assert(!empty(pq));
	return pq->phead->data;
}
//返回队尾元素
QDataType Queueback(Queue* pq)
{
	assert(pq);
	assert(!empty(pq));
	return pq->ptail->data;
}
2.3.4 队列的有效元素个数
cpp 复制代码
int size(Queue* pq)
{
	assert(pq);
	QueueNode* pcur = phead;
	int size = 0;
	while(pcur)
	{
		size++;
		pcur = pcur->next;
	}
	return size;
}

读到这里可能就会有读者有疑惑,前面所有的操作时间复杂度都是O(1)怎么到这里时间复杂度就变成O(n)了,或许有的人会觉得这就是链表的缺陷是不可更改的

但是优化的方法其实很简单。既然前面队列的结构都已经维护头指针和尾指针,那干脆就再维护一个队列的长度

但是维护一个size就意味着代码会更复杂,所以也需要分情况来定义和维护

【1】如果在不需要频繁的获取队列的长度的情况下就继续使用之前的方法【2】如过需要频繁获取队列的长度,就再维护一个队列长度size

cpp 复制代码
typedef struct Queue
{
	QueueNode* phead;   //指向队列的头节点
	QueueNode* ptail;   //指向队列的尾节点
	int size;
}Queue;

维护 s i z e 的版本 维护size的版本 维护size的版本

2.4 循环队列

引入:上文说到,实现队列这个数据结构时使用链表实现会更好。原因在于使用顺序表实现队列的优化没有链表好,但是使用顺序表来实现队列其实也是有它自己的优化方式的

顺序表示实现普通队列

如下,是一个使用顺序表实现队列的结构,初始时定义队头指针front和队尾指针rear指向顺序表的开头

上文中提到使用顺序表实现队列最大的问题就在于顺序表头部操作的时间复杂度为O(n),也就是出队需要花费很多时间。但是其实是因为顺序表每次进行头部操作时要移动后面的元素才使得时间效率低,所以就在这里对头部操作的优化,对于每次出队,只需将front指针向后移动即可

顺序表实现循环队列

到这里用顺序表实现队列在时间上的问题解决了,但是此时又发现,当rear指针走到顺序表的最后时,队列就算满了,而队列的前面还有很多的空闲的位置没有使用这样就导致了很多的空间浪费

此时,就应该想一个方法使得顺序表前面的空间也可以被使用。将队列的整体看成是一个环,当rearfront要越界的时候,再让它们跳回到顺序表的起始位置,就跟一个环一样

实现方式:

  • 每当入队完成时(push):rear = (rear + 1) % size
  • 每当出队完成时(pop):front = (front + 1) % size

【注】size是顺序表的长度


循环队列初始状态 循环队列初始状态 循环队列初始状态

由上述可知,当队列为空时front == rear,再看当队列满了的时候

此时的判断条件还是front == rear这样的话,当front指针等于rear指针时,根本就不知道队列此时是空的还是满的,所以还需要修改。在顺序表中一直保留一个位置不使用,此时判断队列为满的条件就变成了front - rear = 1

特别的如果需要队列的有效元素的个数的话:int length = (rear - front + size) % size

循环队列的代码实现

c 复制代码
typedef struct {
    int* data;
    int capacity;
    int front;
    int rear;
} MyCircularQueue;

//初始化
MyCircularQueue* myCircularQueueCreate(int k) {
    //创建循环队列
    MyCircularQueue* cq = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));
    cq->data = NULL;
    cq->front = cq->rear = 0;
    //分配内存
    int* tmp = (int*)malloc((k + 1) * sizeof(int));
    if(!tmp) exit(-1);
    cq->data = tmp;
    cq->capacity = k + 1;

    return cq;
}
//入队 如果入队成功返回true反之返回false
bool myCircularQueueEnQueue(MyCircularQueue* obj, int value) {
    assert(obj);
    int size = obj->capacity;
    //如果队列满了 就返回false
    if((obj->rear + 1) % size == obj->front)
    {
        return false;
    }else{
        obj->data[obj->rear] = value;
        obj->rear = (obj->rear + 1) % size; //更新rear的值
        return true;
    }
}
//出队 如果出队成功返回true反之返回false
bool myCircularQueueDeQueue(MyCircularQueue* obj) {
    assert(obj);
    int size = obj->capacity;
    if(obj->rear == obj->front)
    {
        return false;
    }else{
        obj->front = (obj->front + 1) % size;
        return true;
    }
}
//返回队列头部元素
int myCircularQueueFront(MyCircularQueue* obj) {
    assert(obj);
    if(obj->rear == obj->front)
    {
        return -1;
    }else{
        return obj->data[obj->front];
    }
}
//返回队列队尾元素
int myCircularQueueRear(MyCircularQueue* obj) {
    assert(obj);
    int size = obj->capacity;
    if(obj->rear == obj->front)
    {
        return -1;
    }else{
        return obj->data[(obj->rear - 1 + size) % size];
    }
}
//判断是否为空队列
bool myCircularQueueIsEmpty(MyCircularQueue* obj) {
    assert(obj);
    return obj->front == obj->rear;
}
//判断队列是否满了
bool myCircularQueueIsFull(MyCircularQueue* obj) {
    assert(obj);
    int size = obj->capacity;
    return (obj->rear + 1) % size == obj->front;
}
//销毁队列
void myCircularQueueFree(MyCircularQueue* obj) {
    assert(obj);
    free(obj->data);
    obj->data = NULL;
    obj->rear = obj->front = obj->capacity = 0;
    free(obj);
}
相关推荐
一枝小雨3 小时前
什么是标准C函数:以RISC-V架构下的C函数为例
c语言·risc-v·内核原理
承渊政道3 小时前
【贪心算法】(经典实战应用解析(三):K次取反后最⼤化的数组和、按⾝⾼排序、优势洗牌、最⻓回⽂串、增减字符串匹配)
数据结构·c++·学习·算法·贪心算法·线性回归·哈希算法
三品吉他手会点灯3 小时前
C语言学习笔记 - 34.数据类型 - 编程规范与高效学习方法
c语言·开发语言·笔记·学习
Lucky_ldy3 小时前
C语言学习:动态内存管理(数据结构关键)
c语言·数据结构·学习
JackSparrow4144 小时前
彻底理解Java NIO(二)C语言实现 I/O多路复用+Reactor模式 服务器详解
java·linux·c语言·后端·nio·reactor模式
三品吉他手会点灯4 小时前
C语言学习笔记 - 37.数据类型 - scanf函数的基本用法
c语言·开发语言·笔记·学习
草莓熊Lotso4 小时前
【Linux系统加餐】从原理到实战:System V消息队列全解析 + 基于责任链模式的工业级封装
linux·运维·服务器·c语言·c++·人工智能·责任链模式
邪修king4 小时前
C++ 二叉搜索树 (BST) 超全详解:核心原理、完整实现、性能分析与使用场景
数据结构·c++·bst·二叉树搜索树
诙_4 小时前
C++数据结构学习总结
数据结构·c++·学习