上一章盘点了线性表,
线性表盘点
线性表本身也十分简单,因此操作上是十分受限的,如果我们要想实现更丰富的操作就需要其他的数据结构
文章目录
知识框架
本章将从线性表推广到三种结构——栈,队列和数组。也是算法题的常考点,掌握不同类型的栈,队列和数组的结构,存储方式,以及其特点和操作,栈和队列的常见应用,以及数组和特殊矩阵的压缩存储。
栈
栈的基本概念
栈的定义
栈是一种只允许在一端进行插入或删除操作的线性表。
一般我们的栈结构是由底部向上的
栈顶(TOP):线性表中允许进行插入删除的那一端,即为栈结构中最顶端的数据空间
栈底(Bottom):栈底部那段,不允许进行插入删除的一端。
空栈:不含任何元素的栈
栈的操作特性是后进先出(LIFO),即越后入栈的元素,越先出栈。从结构上你可以理解栈为一个纸箱子,要把书放进箱子里,那么先放进去的一定是最后拿出来的,因为出口只有最上面那端。
栈的基本操作
InitStack(&S):初始化空栈
StackEmpty(S):判断一个栈是否为空
Push(&S,x):进栈,或者专业一点叫压栈
Pop(&S,&x):出栈,或者弹栈,压栈和弹栈的说法是将栈比喻成弹簧
存数据的时候是在栈顶压入新数据,取数据的时候是从栈顶弹出新数据
GetTop(S,&x):读取栈顶元素并返回给x
DestoryStack(&S):销毁栈,释放其所占空间
栈的顺序存储结构
用顺序存储结构保存的栈叫做顺序栈,因为栈也是线性表,所以顺序栈也是一组连续地址的存储单元,存放了自底到顶的数据元素,同时需要一个指针top来指向栈顶元素的位置。
结构描述:
typedef stuct{
Elemtype data[Maxsize];
int top;
}SqStack;
对于栈S的栈顶指针S.top,初始值设置为-1。代表当前栈顶是-1,在逻辑上看是栈底的下方,也就是当栈为空时。栈顶元素为S.data[S.top]
每当我们入栈一个元素,栈顶指针++,那么这个栈的长度就是Length=S.top+1
当Length=Maxsize,也就是S.top=Maxsize-1时代表栈满了。
顺序栈的基本运算
判断是否栈空
bool StackEmpty(Sqstack S){
if(S.top==-1){
return true;
}
else
return false;
}
进栈
bool Push(Sqstack &S,ElemType x){
if(S.top==Maxsize-1){
return false;//栈满报错
}
else
S.top++;
S.data[S.top]=x;
return true;
}
出栈
bool Pop(Sqstack &S,ElemType &x){
if(S.top==-1){
return false//栈空报错}
else
x=S.data[S.top];
S.top--;
return true;
}
只写了一些基础操作,其他就不细说了。
进栈里的else语句可以简化为S.data[++S.top]=x,出栈也可以简化为x=S.data[S.top–],了解C代码执行过程基础的就应该知道为什么可以这样写,其实就是我们上面写的两次操作的合并。
共享栈
共享栈就是让两个栈共享同一个存储空间,这两个栈分别以存储空间的一段作为栈底,当栈顶abs(S0.top-S1.top)=1时(二者的栈顶序号相邻)则说明栈满
共享栈本身应用场景不多见,操作都是一样的,了解一下结构即可。
栈的链式存储
链式存储的栈其实和上界学习的链表是一样的,我们可以在结构上将他们理解为不同的结构。
typedef struct Linknode{
Elemtype data;
struct Linknode *next;
} *Listack;
操作和链表是一样的,不再赘述
队列
队列(Queue)简称队,这种线性表只允许我们在表的一端进行插入,而在表的一端进行删除,就像排队一样,我们从入口进排成队列再挨个从出口出去。插入元素称为入队,删除元素称为出队。其特性是FIFO先进先出。
队头(Front):指允许进行删除的一端,又称队首
队尾(Real):指允许插入的一端。
队列的常见操作
InitQueue(&Q):初始化队列,构造一个空队列Q
QueueEmpty(Q):判队是否为空
EnQueue(&Q,x):入队,若Q未满则加入x
DeQueue(&Q,&x):若非空,将队头元素出队到x中
队列的顺序存储结构
我们可以设置两个指针来分别指向队的队头和队尾
typedef struct{
Elemtype data[Maxsize];
int front,rear;
}SqQueue
队空:当Q.front==Q.rear==0时为空,注意这个判断条件也是为什么我们在循环队列里需要牺牲一个存储单元存放队尾指针的原因,因为若不这样做那么经过一圈遍历之和Q.frone就会等于Q.rear,则会误判为空
进队:在队不满时,先将值赋给队尾指针所在位置,再使得队尾++
出队:在队不空时,先将队头值出队,再使得队头++
从上述的结构我们可以看到,实际上队是个两头操作的线性表,入队使得队尾指针不断下移(地址上是下移,图片上是上移),然后出队使得队头不断下移。
循环队列
循环队列简单来说就是将队伍首尾相连,在逻辑上就构成了一个环,若这个环的长度为n,那么所存储的元素有n-1个,其中一个存储空间将被用来存放队满情况下的队尾指针。此外如果遍历这个结构我们可以实现数据的循环读取,若从位置1出发读取第n个元素,就会回到位置1,此处n即为空间大小Maxsize-1。
初始时(判断队空):Q.front==Q.rear==0
队首指针前进1:Q.front=(Q.front+1)%Maxsize(考虑到队尾元素的下一位是队头,将经历循环则需要取余)
队尾指针前进1:Q.rear=(Q.rear+1)%Maxsize
一般指针是按图中的顺时针方向前进的
队满条件:(Q.rear+1)%Maxsize==Q.front
队中元素个数(Q.rear-Q.front+Maxsize)%Maxsize(为什么要加Maxsize,不是会被余掉吗,这是因为在循环中队尾序号可能会小于队头序号,此时rear-front就不代表二者之间相差的元素数量了,得到的将是负数,这种情况是因为尾指针已经比头指针大一圈了,所以要加上一圈数量Maxsize)
操作就不细写了
队列的链式存储
链队实际上就是在单链表的结构上加上我们定义的头尾指针,就像单链表一样,我们最好空出一个空间来存放头结点作为链队的头部,这样如果要构成循环链表也正好多出一个结点。
链式结构描述
typedef srtruct LinkNode{
Elemtype data;
Struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear;
}LinkQueue;
当Q.front==NULL或当Q.rear==NULL时链队为空
我们也总结过,链式结构好处在于操作简单,结构可以动态改变,不会出现存储分配不合理和溢出的情况。
链队的操作
链式结构的操作可以稍微写一下,其要点无非就是数据和指针都要操作
初始化
void initQueue(LinkQueue &Q){
Q.front=Q.rear=(LinkNode *)malloc(sizeof(LinkNode));
Q.front->next=NULL;
}
入队
void EnQueue(LinkQueue &Q,ElemType x){
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));//因为是动态分配的不用判断队满
s->data=x;s->next=NULL;
Q.rear->next=s;
Q.rear=s;
}
出队
void DeQueue(LinkQueue &Q,Elemtype &x){
if(Q.front==Q.rear)
return false;
else{
LinkNode *p=Q->front->next;//因为头结点为空,所以队头数据在头结点的next里
x=p.front->data;
Q.front->next=p->next;
if(Q.rear==p){ //假如要删除的元素p正好为队尾,即p为队列中唯一元素
Q.rear=Q.front;//删除完队列空了,要使得Q.rear==Q.front
}
free(p);//释放p所指的空间
return ture;
}
双端队列
双端队列就是两端都允许输入和输出的队列,我们将这两端称为前端和后端。其元素的逻辑结构依然是线性结构。两端都可以入队和出队。
由于双端队列两端都可以出入,因此同一组数据可以以不同的进入顺序存储,也可以以不同的输出顺序输出。具体可以自行打草稿分析。
栈和队列的应用
栈在括号匹配中的应用
括号匹配是一个经典的题型
我们在计算机中要进行括号的匹配,其本质就是用栈来实现的。
( 【 { 三种括号各不相同,我们是怎么找到与之相匹配的右括号的呢?
比如例题图中的几组括号,为什么34是一对,56是一对,而36不是一对,我们发现越近的组合会组成一对,换言之左括号需要匹配的是与其距离最近的右括号。而栈可以完美实现我们的要求。其实结构上是括号是层层嵌套并且成对出现的,我们的目的就是每次都把最内部的嵌套拆出来。
由于栈是后进先出的结构,我们只需把左括号入栈,由于括号总是成对的,因此一对括号的入栈顺序都是相邻的,比如上图中我们入栈1234,发现栈顶元素4是与3匹配的括号,就将他们俩个出队
如果说上图中没有3,入栈顺序是124就出现错误了,因为14是相隔的,这就代表中间的嵌套是不成对的,因此这样的括号输入是错误的,是不正确的格式,就要返回错误信息。
栈在表达式求值中的应用
表达式求值也是一个基本问题,
(在后续的树章节中我们会了解到,前缀中缀和后缀可以对应前序中序和后序结构的生成树)
这里了解后缀表达式是怎么用栈求值的,ABCD-*+EF/-,后缀表达式满足的顺序是:第一个计算数,第二个计算数,操作符。然后一步计算之后我们又可以把这三个为一组看作一个新的数。我们试着来解读一下这串字符,从右往左来看:
首先第一个符号是-,代表这是最后一个操作符,前面的ABCD-*+EF/可以拆分为两个计算数,其中/为操作符,EF为其两个计算数,所以EF/为E/F。
然后ABCD-*+看作一组,先处理+,ABCD-*可以看作两个数字组合,再看其内部的*,ABCD-可以看作两个数字组合,然后看其内部-,则CD-为一组,即为C-D。外面那组是ABCD-*,即为B*(C-D),然后是A+B*(C-D)最后放上EF/-,就是A+B*(C-D)-E/F。不难发现这样的一个过程其实也包含了组合和嵌套的概念,本质上是和前面的括号匹配那题是一样的,因此同样是栈的结构来解决。
那么从栈的结构上来看,我们以后缀的顺序将上述字符串从左至右依次入栈,一旦入栈了操作符,那么操作符与它前面两位数就是一个后缀计算组合,我们将这三个一同出栈,并且计算出数1操作符数2的结果,然后将其结果再入栈。这样往复操作,最后栈为空时出栈的结果就是我们这条表达式的答案.
栈在递归中的应用
敲代码的时候经常会出现栈满,堆栈空间不足等问题。其本质就在于实际上我们的语句就是以栈的形式来进行操作的。
为什么我们的语句会是自上而下顺序操作呢,因为运行时会逐行将操作语句入栈,然后挨个处理
int F(int n){
if(n==0)
return 0;
else
F(n-1);
在上述递归代码中我们的语句是怎样运行的呢?简化地来看我们用栈来保存函数(因为函数中的语句总是顺序相同的,所以简化概念之后两者是相同的结构)
比如要运行F(5),我们知道最后结果是0,而在栈里的操作顺序是这样的:
运行F(5),所以F(5)入栈,F(5)里面运行了F(4),此时F(5)依旧在执行,所以并不出栈,再执行F(4),然后F(3),F(2),F(1),F(0)。到了F(0)return结果0,F(0)运行完毕出栈,回到F(1),再出栈回到F(2)…以此类推直到F(5)出栈栈空才算运行完毕。
数组和特殊矩阵
数组是线性表的推广。一维数组可以看作一个线性表,二维数组可以看作其内元素是线性表的线性表,依次类推。除了初始化和销毁外,数组只有存取元素和修改元素的操作。
这里只是简单介绍一下数组,有关数组存储的内容后续还会拓展
数组的存储结构
数组的结构我们在逻辑上可以把他理解为线性代数中的矩阵,不过在存储结构上依旧是连续存储的,以行优先或者列优先的形式在内存中连续存储。
特殊矩阵的压缩存储
压缩指的是对我们的数组(矩阵)的存储,我们可以不必存储所有元素,而是用更少的元素实现整个数组的存储。
比如:
压缩存储:对多个相同值的元素只分配一个存储空间,对0元素不分配存储空间
特殊矩阵:例如上三角下三角矩阵,对称矩阵,对角矩阵等特殊矩阵,由于其存在某种性质,可以不存储所有数组元素。而想要获得完整的矩阵可以通过某些规律或运算得出。
稀疏矩阵
虽然压缩存储占用空间小,但仅仅只存储元素值是不够我们再现完整的矩阵的。
稀疏矩阵的存储就是用一个三元组来存储对应元素值的横坐标,纵坐标和元素值这三个属性,其余的0元素则不存储,这样做的好处就是能比较方便的再现矩阵