1. 栈
1.1 栈的基本概念
- 线性表 (linear list) 是相同类型的 n (n>=0) 个数据元素的有限序列,若用 L 命名为线性表,则一般表示为 \(L = (a_1,a_2,...,a_i,a_{i+1},...,a_n)\)
- 栈 (stack) 是限定仅在一端进行插入或删除操作的线性表。因此,对栈来说进行插入删除数据的这一端称为栈顶 (top),另一端称为栈底 (bottom),不含元素的空表称为空栈。栈中的数据元素遵循后进先出 (LIFO Last In First Out) 的特性。
- 栈插入和删除数据都在栈顶端,插入数据一般叫做入栈 / 进栈,删除数据一般叫做出栈。

这张图可以很好的帮助我们了解栈的进出方式,既然栈是线性表那么就可以分为顺序存储和链式存储。
1.2 入栈和出栈顺序
假设入栈顺序为 a1,a2,a3,a4,a5,那么出栈顺序是什么呢?大家最容易想到的是所有数据都入栈以后再出栈就是 a5,a4,a3,a2,a1;实际上有多种出栈顺序,比如每个数据入栈以后马上出栈,所以另一种出栈顺序也可以是 a1,a2,a3,a4,a5;还可以是 a1,a2,a3 入栈,出 a3,a2,再入栈 a4,a5,再出栈 a5,a4,a1,也就是说出栈顺序为 a3,a2,a5,a4,a1;通过分析我们可以看到因为出栈时机的不同,可以有很多种出栈顺序。

一句话:一种入栈顺序可以有多种出栈顺序。
至于有多少种出栈顺序,出栈顺序组合数是⼀个卡特兰数:

具体的推导过程我这里就不详细讲述了,你们可以自己去搜索资料了解一下,这个公式我们校招并没有要求,但是我们考408的同学就要注意一下它的推导过程了。
1.3 栈的基本操作
栈是⼀种只允许在⼀端进⾏插⼊和删除的线性表,栈插⼊和删除数据都在栈顶端,插⼊数据⼀般叫 做⼊栈,删除数据⼀般叫做出栈,所栈最核⼼的接⼝函数就是⼊栈和出栈函数。
// 栈的初始化
void StackInit(Stack& s);
// 栈的销毁
void StackDestroy(Stack& s);
// x元素入栈(进栈)
void StackPush(Stack& s, STDataType x);
// 将栈顶元素出栈,并用返回栈顶元素
STDataType StackPop(Stack& s);
// 获取栈顶元素并返回
STDataType StackTop(Stack& s);
// 获取栈中有效元素个数
int StackSize(Stack& s);
// 检测栈是否为空,如果是空返回真,否则返回假
bool StackEmpty(Stack& s);
这些关于栈的相关接口,你们自己一定要尝试一下能不能自主完成。
1.3.1 栈的结构定义
跟顺序表类似,栈的顺序存储实现可以使⽤静态数组,我们称为静态顺序栈,也可以使⽤堆上动态申 请数组实现,我们称为动态顺序栈。静态顺序栈的缺陷跟静态顺序表类似,只适⽤于确定知道最多需 要多少空间的场景,那么我们后续重点讲解动态数组实现的版本。
静态栈结构定义:
#define MAX_SIZE 10
typedef int STDataType;
typedef struct{
STDataType arr[MAX_SIZE];
int top; // 标记栈顶
}Stack;
动态栈结构定义:
typedef int STDataType;
typedef struct{
STDataType* arr; // 指向栈数组空间的指针
int top; // 栈顶位置
int capacity; // 容量
}Stack;
1.3.2 动态顺序栈实现
有了前面学习顺序表的基础,这里我们实现动态顺序栈简直小菜一碟。相比而言这里的入栈和出栈操作还要更简单一些。
需要注意的是栈结构中初始化时,top=-1 还是 top=0,top=-1 代表 top 指向栈顶元素,那么入栈时要先++top,再把入栈数据放到 top 位置。top=0 代表 top 指向栈顶元素的下一个位置 ,那么入栈时要先把入栈数据放到 top 位置,再++top。其他接口函数处理细节也要对应调整。这两种方式没有优劣之分,大家任选一种即可!
为何初始化时top=0,不能代表 top 指向栈顶元素呢?那我们要思考栈为空和栈只有一个数据时,top 都是 0,如何区分呢?

这两种定义方式也就决定了,我们再入栈和出栈的时候是先top++,还是先存数据。
栈接口的实现:
#define _CRT_SECURE_NO_WARNINGS
#include"SeqStack.h"
//栈的初始化
void StackInit(Stack* s)
{
assert(s);
s->arr = (STDataType*)malloc(4 * sizeof(STDataType));
if (s->arr == NULL)
{
perror("StackInit()::malloc()");
return;
}
s->top = 0;
s->capacity = 4;
}
// 栈的销毁
void StackDestroy(Stack* s)
{
assert(s);
if (s->arr == NULL)
{
return;
}
free(s->arr);
s->arr = NULL;
s->capacity = 0;
s->top = 0;
}
// x元素⼊栈(进栈)
void StackPush(Stack* s, STDataType x)
{
assert(s);
assert(StackEmpty != NULL);
//满了
if (s->top == s->capacity)
{
STDataType* tmp = (STDataType*)realloc(s->arr, sizeof(STDataType) * s->capacity * 2);
if (tmp == NULL)
{
perror("StackPush()::realloc()");
return;
}
s->arr = tmp;
s->capacity *= 2;
}
//未满
s->arr[s->top] = x;
s->top++;
}
// 将栈顶元素出栈,并⽤返回栈顶元素
STDataType StackPop(Stack* s)
{
assert(s);
assert(StackEmpty != NULL);
STDataType x = s->arr[s->top - 1];
s->top--;
return x;
}
// 获取栈顶元素并返回
STDataType StackTop(Stack* s)
{
assert(s);
assert(StackEmpty != NULL);
return s->arr[s->top - 1];
}
//获取栈中有效元素个数
int StackSize(Stack* s)
{
assert(s);
return s->top;
}
//检测栈是否为空,如果是空返回真,否则返回假
bool StackEmpty(Stack* s)
{
assert(s);
if (s->top = 0)
{
return false;
}
return true;
}
这个是顺序存储不是链式存储,这是动态顺序存储,后续我们还要实现动态链式存储。
1.4 栈的链式存储
我们学习了这么多链式存储方式,这里我们可以使用那种方式来存储呢?
-
栈的链式存储,我们可以选用单链表结构,也可以选用双向链表结构。但是需要注意的是,如果选用单链表必须用链表头这一端做栈顶,入栈出栈就是对应头插头删,时间复杂度为\(O(1)\)。否则用链表尾做栈顶,出栈对应尾删代价太大了,时间复杂度为O(N)。
-
如果选用双向链表,则用表头表尾做栈顶都可以,因为双向链表头尾插入删除效率都是\(O(1)\)。当然我们完全没必要选择双向链表,因为单链表就可以高效实现,还省空间一些。双向链表没有优势,每个结点还要多存储一个前驱指针,使用它纯粹浪费了。
-
其次我们选择单链表实现,可以带头结点,也可以不带头结点;但是这里入栈出栈就是对应头插头删,单链表带头结点时,头插头删并不会带来什么便利,所以一般我们选择不带头结点实现即可。
#define _CRT_SECURE_NO_WARNINGS
#include"LinkStack.h"
// 初始化链式栈s
void LinkStackInit(LinkStack* s)
{
assert(s);
s->topHead = NULL;
s->size = 0;
}// 销毁链式栈s
void LinkStackDestroy(LinkStack* s)
{
LNode* cur = s->topHead;
while (cur)
{
LNode* next = cur->next;
free(cur);
cur = next;
}
s->size = 0;
s->topHead = NULL;
}// x入栈
void LinkStackPush(LinkStack* s, STDataType x)
{
assert(s);
LNode* newNode = (LNode*)malloc(sizeof(LNode));
if (newNode == NULL)
{
perror("LinkStackPush()::malloc()");
return;
}
newNode->data = x;newNode->next = s->topHead; s->topHead = newNode; s->size++;}
// 出栈,并返回栈顶元素
STDataType LinkStackPop(LinkStack* s)
{
assert(s);
assert(!LinkStackEmpty(s));
LNode* delNode = s->topHead;
LNode* topE = delNode->data;
s->topHead = delNode->next;
free(delNode);
delNode = NULL;
s->size--;
return topE;
}// 获取栈顶元素
STDataType LinkStackTop(LinkStack* s)
{
return s->topHead->data;
}// 获取栈中有效元素个数
int LinkStackSize(LinkStack* s)
{
return s->size;
}// 检测栈是否为空,如果是空返回真,否则返回假
bool LinkStackEmpty(LinkStack* s)
{
return (s->size == 0);
}
1.4.1 入栈
我们无论是入栈还是出栈的时候都一定要考虑,临界情况的问题,入栈时链表为空,我们该怎入?是不是和正常情况是一样的,还是说要分情况讨论。
我们这里的入栈时链表为空时的逻辑和正常情况下的是一样的。
// x入栈
void LinkStackPush(LinkStack* s, STDataType x) {
assert(s);
// 1. 申请新结点
LSNode* newNode = (LSNode*)malloc(sizeof(LSNode));
if (NULL == newNode) {
printf("LinkStackPush:申请节点失败");
exit(-1);
}
newNode->data = x;
newNode->next = s->topHead;
s->topHead = newNode;
s->size++;
}

1.4.2 出栈
出栈时链表中还有一个链表和多个链表的情况是一样的。
// 出栈,并返回栈顶元素
STDataType LinkStackPop(LinkStack* s)
{
assert(s);
assert(!LinkStackEmpty(s));
LNode* delNode = s->topHead;
LNode* topE = delNode->data;
s->topHead = delNode->next;
free(delNode);
delNode = NULL;
s->size--;
return topE;
}

1.5 栈的链式存储和顺序存储的区别
- 栈的顺序存储和链式存储都可以做到入栈和出栈时间复杂度为\(O(1)\),效率都很高。
- 栈的顺序存储的唯一问题是入栈时空间不够,扩容有一定的消耗,但是这个消耗几乎可以忽略不计,因为插入一定量数据才会扩容。
- 栈的顺序存储相比链式存储 CPU 高速缓存命中率较高,且没有内存碎片。所以栈的实现一般我们使用顺序存储。
前两点我觉得还是很好理解的,但是第三点,这个cpu缓存命中率是什么呢?
我们的cpu在读取数据的时候,是在缓存中读取的,缓存中的数据又是在内存中缓存的,我们的缓存区就是为了可以不用每次读取数据的时候cpu直接去内存中读取,我们缓存区先从内存中读取一块连续的内存,cpu再在缓存区中读取想要的数据,如果没有读取到,缓存区会继续去内存中读取另一块内存,再给cpu读取,我们将cpu读取数据时,读取到数据叫为cpu的命中 ,因为缓存区在读取数据的时候时一块一块读取的,我们的链式存储是零碎的,顺序存储是一块的,这就是为什么说顺序存储的命中率较高的原因。
2. 队列
- 线性表 (linear list) 是相同类型的n(n>=0)个数据元素的有限序列,若用 L 命名为线性表,则一般表示为 \(L = (a_1,a_2,...,a_i,a_{i+1},...,a_n)\)
- 队列 (queue) 是限定在一端插入另一端删除的线性表。因此,对队列来说,插入那一端叫做队尾,插入叫入队,删除那一端叫队头,删除叫做出队。队列中的数据元素遵循先进先出 (FIFO First In First Out) 的特性。
- 队列的入队出队顺序就简单多了,无论如何操作,一种入队顺序只对应一种出队顺序。
这个队列就跟我们平常排队一样,只能一头进一头出。
2.1 队列的基本操作
队列在队尾⼊数据,在对头出数据,主要⽀持的核⼼接⼝就是⼊队和出队。
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int QDataType; // 队列中存储数据元素的类型
// 采用单链表实现队列,链表结点的定义
typedef struct LinkNode {
QDataType data;
struct LinkNode* next;
}QNode;
typedef struct {
QNode* front; //队头指针
QNode* rear; //队尾指针
int size; //队内元素个数
}LinkQueue;
// 初始化队列
void QueueInit(LinkQueue* q);
// 销毁队列
void QueueDestroy(LinkQueue* q);
// 检测队列是否为空,空返回真,否则返回假
bool QueueEmpty(LinkQueue* q);
// 获取队列中有效元素个数
int QueueSize(LinkQueue* q);
// 获取队头元素
QDataType QueueFront(LinkQueue* q);
// 将x入队列
void EnQueue(LinkQueue* q, QDataType x);
// 出队并返回队头数据
QDataType DeQueue(LinkQueue* q);
你们可以自己尝试一下完成这些接口的实现。
既可以采用顺序存储又可以采用链式存储,后续的代码我采用的是链式存储的方式
2.2 入队
入队就是在表尾插⼊⼀个结点,队列设计中我们增加了尾指针,所以要实现尾插是很容易的。
我们是带头结点的结构,队列空和⾮空的情况⼀样的代码就尾插处理了。
// 将x入队列
void EnQueue(LinkQueue* q, QDataType x)
{
assert(q);
QNode* newNode = (QNode*)malloc(sizeof(QNode));
if (newNode == NULL)
{
perror("EnQueue()::malloc()");
return;
}
newNode->data = x;
newNode->next = NULL;
q->rear->next = newNode;
q->rear = newNode;
q->size++;
}
2.3 出队
我们是带头结点的结构,出队就是删除头结点后的第⼀个结点,需要区分多个结点和最后⼀个结点 的情况,最后⼀个结点删除后,rear需要指向哨兵位的头结点了。
// 出队并返回队头数据
QDataType DeQueue(LinkQueue* q)
{
assert(q);
assert(!QueueEmpty(q));
QNode* delNode = q->front->next;
QDataType val = delNode->data;
q->front->next = delNode->next;
//如果除头结点之外只有一个结点
if (q->rear == delNode)
{
q->rear = q->front;
}
free(delNode);
delNode = NULL;
q->size--;
return val;
}
2.4 接口实现
#define _CRT_SECURE_NO_WARNINGS
#include"LinkQueue.h"
// 初始化队列
void QueueInit(LinkQueue* q)
{
assert(q);
q->front = q->rear = (QNode*)malloc(sizeof(QNode)); //申请哨兵位,不存数据,这时候头指针和尾指针都是同一块区域
if (q->front == NULL)
{
perror("QueueInit()::malloc()");
return;
}
q->front->next = NULL;
q->size = 0;
}
// 将x入队列
void EnQueue(LinkQueue* q, QDataType x)
{
assert(q);
QNode* newNode = (QNode*)malloc(sizeof(QNode));
if (newNode == NULL)
{
perror("EnQueue()::malloc()");
return;
}
newNode->data = x;
newNode->next = NULL;
q->rear->next = newNode;
q->rear = newNode;
q->size++;
}
// 出队并返回队头数据
QDataType DeQueue(LinkQueue* q)
{
assert(q);
assert(!QueueEmpty(q));
QNode* delNode = q->front->next;
QDataType val = delNode->data;
q->front->next = delNode->next;
//如果除头结点之外只有一个结点
if (q->rear == delNode)
{
q->rear = q->front;
}
free(delNode);
delNode = NULL;
q->size--;
return val;
}
// 获取队头元素
QDataType QueueFront(LinkQueue* q)
{
assert(q);
assert(!QueueEmpty(q));
return q->front->next->data;
}
// 检测队列是否为空,空返回真,否则返回假
bool QueueEmpty(LinkQueue* q)
{
assert(q);
return q->size == 0;
}
// 获取队列中有效元素个数
int QueueSize(LinkQueue* q)
{
assert(q);
return q->size;
}
// 销毁队列
void QueueDestroy(LinkQueue* q)
{
assert(q);
QNode* cur = q->front;
while (cur)
{
QNode* next = cur->next;
free(cur);
cur = next;
}
q->rear = q->front = NULL;
q->size = 0;
}
2.4 队列的存储方式
2.4.1 链式队列的存储
- 队列的链式存储,我们可以选用单链表结构,也可以选用双向链表结构。他们入队对应着在表尾插入,出队对应着在表头删除。当然我们完全没必要选择双向链表,因为单链表就可以高效实现,还省空间一些。双向链表没有优势,每个结点还要多存储一个前驱指针,使用它纯粹浪费了。
- 其次这里我们选择单链表实现,入队就是尾插,出队就是头删。可以带头结点,也可以不带头结点,带头结点的结构略简单一点
2.4.2 顺序队列的存储
我们顺序存储的方式,在尾插时,可以做到O(1),但是我们在头插时需要挪动数据,时间复杂度为O(n),如果实现顺序存储的效率太低了话,我们就完全没有实现的必要了,所以我们这里可以调整思路,我们头删时,不挪动数据,而是直接挪动指针或者对头的下标。

2.4.3 顺序队列的问题
如果我们使用上面的那种方式来实现顺序队列,这里有一个顺序队列的经典问题"假溢出问题",队列的存储空间(数组)中明明还有空闲位置,但由于 队尾指针rear已经移动到了数组的末尾,导致⽆法再插⼊新元素,系统错误地判断为"队列已满" 的现象,这种情况之所以被称为"假"溢出,是因为它并⾮真正的空间耗尽,⽽是⼀种由于数据结 构设计缺陷导致的空间浪费。
解决假溢出问题的经典方法是使用循环队列。
- 入队后,rear 指针的更新方式为:
rear = (rear + 1) % 数组容量 - 出队后,front 指针的更新方式为:
front = (front + 1) % 数组容量
顺序队列的第二个问题是他更适合于静态顺序结构,不太适合动态顺序结构,因为满了扩容时相对复杂而且效率低下。既然这么麻烦,不确定有多少数据时,直接使用链式队列岂不是更好!所以封装实现队列一般都用链式结构,顺序存储适宜于确定最多使用多少空间的局限场景。

指针移动导致,前面的下标有空格,系统会认为队列已满,我们如果使用循环队列的话就没有这种情况了。

这就是循环队列的逻辑图了,但是在空间上并不是这样存储的,但是这种情况我们该怎么判断队列是否满了呢?
一种方式是我们可以牺牲一个空间,当rear==front时就说明已满。

另一种方式就是我们创建一个计数变量,或者使用标记的方式。

2.5 双端队列
这个双端队列我们只需要对其有所了解即可。
- 双端队列也是一种特殊线性结构,其允许两端都可以进行数据元素入队和出队操作。将队列的两端分别称为前端和后端,两端都可以进行数据入队和出队。
- 双端队列入队时,前端进的元素排列在队列中后端进的元素的前面,后端进的元素排列在队列中前端进的元素的后面。在双端队列出队列时,无论是前端还是后端出队列,先出的元素排列在后出的元素的前面。

3. GitHub
完整代码已经上传GitHub,欢迎Star
https://github.com/PeterYuanCoder/ds-c-programs.git
后续我会出一期关于GitHub的使用指南,尽情期待!