栈与队列
1、栈
1.1栈的定义
栈是限定仅在表的一端进行插入和删除操作的线性表
将允许插入删除的一段称为栈顶(top),另一端称为栈底(buttom),不含任何元素的栈称为空栈。栈又称为后进先出的线性表,简称LIFO结构。
栈的插入操作,叫作进栈。删除操作,叫作出栈。
如图,分别表示进栈与入栈操作。
1.2栈的存储结构及实现
1.2.1栈的顺序存储结构
既然栈是线性表的特例,那么栈的顺序存储其实也是线性表顺序存储的简化,简称顺序栈。
我们定义一个top变量来指示栈顶元素在数组中的位置,它可以移动,意味着栈顶的可以变大可以变小。若存储栈的长度为StackSize,则栈顶的位置top必须小于StackSize。当栈中存在一个元素时,top = 0 ,因此通常把空栈的判定条件定为 top= -1 。
栈的结构定义如下:
typedef int SElemType; //这个类型依据实际情况而定,这里假设为int
typedef struct{
SElemType data[MAXSIZE];//MAXSIZE表示栈的容量
int top; //用于栈顶指针
}SqStack;
进栈操作示意:
对于栈的插入,即为进栈操作,如图所示:
进栈的push具体操作:
/*
插入元素e为新的栈顶元素
*/
#define ERROR -1
#define OK 1
#define MAXSIZE 100
Status Push(SqStack * S,ElemType e){
if(S->top == MAXSIZE-1){
return ERROR; //栈满
}
S->top++; //栈顶指针+1
S->data[S->top] = e;//将新插入的元素插入栈顶
return OK;
}
出栈的pop具体操作:
讲解了进展操作,理解出栈操作视乎非常简单。
/*
若栈不为空,用e返回栈顶元素,并将栈顶标记减1,返回OK
若栈为空,直接返回ERROR
*/
Status Pop(SqStack *S,SElemType *e){
if(S->top == -1)
return ERROR; //栈为空
*e = S->data[S->top];
S->top--;
return OK;
}
1.2.2 两栈共享存储空间
其实栈的顺序存储还是很方便的,因为它只准在栈顶进出元素,所以不存在线性表插入和删除时需要移动元素的问题。不过最大的一个缺点也就是必须事先确定数组存储空间的大小,万一不够用了,就需要扩容解决问题(在实际中,扩容是产生一个新的数组,容量为之前的2倍,然后将之前的数组元素拷贝到新的数组去)。
例如,我们有两个相同类型的栈,各自开辟了数组空间,极有可能其中一个栈已经满了,再进栈就溢出了,而另外一个栈还有很多存储空间没有使用,这就导致存储空间的浪费。
如图所示,数组有两个端点,两个栈有两个底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为数组的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,就是两端点向中间延伸。
关键的思路是:它们是在数组的两端,向中间靠拢。top1和top2是栈1和栈2的栈顶指针,可以想象,只要它们不见面,就可以一直使用。
栈1为空时,就是 top1 = -1,当 top2 = n时,即栈2为空。在极端情况下,如果top1 = n-1时,表明栈1满了,栈2为空;top2 = 0时,表明栈2满了,栈1为空。但是更多的情况是,没有达到极限,而是top1、top2都存在移动,即判断栈满的情况则为:top1 + 1 = top2
两栈共享空间的结构代码:
/*
两站共享存储空间结构
*/
typedef struct{
SElemType data[MAXSIZE];
int top1//栈1的栈顶指针
int top2//栈2的栈顶指针
}SqDoubleStack;
对于两栈的共享空间的push方法,我们除了要插入元素值参数外,还需要有一个判断是栈1还是栈2的参数stackNumber。插入的代码如下:
/*
插入元素e到新的栈顶元素
*/
Status Push(SqDoubleStack *s,SElemType e,int stackNumber){
if(s->top1 +1 == top2){
return ERROR;//栈已满,不能在push元素
}
if(stackNumber == 1){
s->data[++s->top1] = e; //栈1有元素进栈
}
if(stackNumber == 2){
s->data[--s->top2] = e;//栈2有元素进栈
}
return OK;
}
两栈共享的pop方法:
Status Pop(SqDoubleStack *s,SElemType *e,int stackNumber){
if(stackNumber == 1){
if(s->top1 == -1)
return ERROR;
*e = s->data[s->top1--];//栈1元素出栈
}else if(stackNumber == 2){
if(s->top2 == MAXSIZE)
return ERROR;
*e = s->data[s->top2++];//将栈2的元素出栈
}
return OK;
}
不过也应该注意:这个共享栈是针对于相同数据类型的栈,如果不是相同数据类型的栈不仅不能提高效率,反而更麻烦!
1.2.3 栈的链式存储结构
说完栈的顺序存储,接着学习一下栈的链式存储,简称链栈。
链栈的结构如下:
将链栈的栈顶放在了单链表的头部 ,且已经有了栈顶在头部,所以单链表不需要头节点。对于链栈来说,不存在栈满的情况,除非内存空间已经完全使用完毕,无法再进行分配空间,这个时候机器也会死机状态。
链栈的结构代码:
typedef struct StackNode{
SElemType data;
struct StackNode * next;
}StackNode, *LinkStackPtr;
typedef struct LinkStack{
LinkStackPtr top;//栈顶指针
int count;//存放当前栈的元素个数
}LinkStack;
栈的链式操作:进栈Push
对于进栈Push操作,假设元素e的新节点为s,top为栈顶指针,示意图为:
具体实现:
Status Push(LinkStack *S,SElemtype e){
LinkStackPtr s = (LinkStackPtr) malloc(sizeof(StackNode));//在插入之前应该先申请一个节点
s->data = e;
s->next = S->top;//将当前的栈顶元素赋值给新的直接后继
S->top = s;//将新的栈顶元素赋值给s
S->count++;
return OK;
}
栈的链式操作:出栈Pop:
出栈的操作其实和进栈的操作相反,没什么很大的难度。出栈操作也是简单的步骤:假设变量p用来存储要删除的栈顶节点,将栈顶元素下移一位,最后释放p元素的节点空间。
示意图为:
/*
链栈的pop实现
*/
Status Pop(LinkStack *S,SElemType *e){
LinkStackPtr p;
if(StackEmpty(*S))
return ERROR;//栈为空
*e = S->top->data;
p = S->top;
S->top = S->top->next;//将指针下移
free(p);
S->count--;//并将栈的元素个数减1
return OK;
}
对比一下顺序栈和链栈,它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要提前确定一个固定的长度,可能会存在空间浪费的问题,但它的优势在于存储时定位方便,而链栈要求每个元素都有指针域,同时增加了内存消耗,但是存储的个数没有限制。所以,如果栈的使用过程当中元素变化不可预料,有时很小,有时很大,那么最好是用链栈,反之,如果变化在可控范围内,建议使用顺序栈更好。
1.3 栈的应用(四则运算表达式求值)
如:标准的中缀表达式为9+(3-1)x3+10÷2,不管是转换成前缀表达式还是后缀表达式,这个缀是对于符号而言的 ,一般的步骤如下:
1、先将中缀表达式 按照四则运算法的规则进行 加括号处理 ,如上面的表达式加括号后表示为:
((9+((3-1)x3))+(10÷2))
2、如果是求前缀表达式,则每一次去括号之前,符号应该在两个数的前面,如:
- 第1次:- 3 1
- 第2次:x - 3 1 3
- 第3次:+ 9 x - 3 1 3
- 第4次:(+ 9 x - 3 1 3)+( / 10 2)
- 第5次:+ + 9 x - 3 1 3 / 10 2
- 即最终的前缀表达式为:+ + 9 x - 3 1 3 / 10 2
3、如果求后缀(逆波兰)表示法:
- 第1次:3 1 -
- 第2次:3 1 - 3 x
- 第3次:9 3 1 - 3 x +
- 第4次:(9 3 1 - 3 x +)+(10 2)/
- 第5次:9 3 1 - 3 x +10 2 / +
即最终的后缀表达式为:9 3 1 - 3 x +10 2 / +
每一次如果都是按照这个步骤求前缀和后缀的话可以很简单的计算出来。
2、队列
2.1 队列的定义
队列是只允许在表的一端进行插入操作,而在另一端进行删除操作的线性表。队列是一种先进先出的线性表,简称FIFO。允许插入的一端是队尾,允许删除的一端是队头。假设队列是q=(a1,a2,…,an),那么a1是队头,an是队尾。如图所示:
2.2循环队列
线性表有顺序存储和链式存储,栈是线性表,所以也有这两种存储方式。同样,队列是一种特殊的线性表,也同样存在着这两种存储方式。
2.2.1 队列顺序存储空间的不足
假设一个队列有n个元素,用顺寻存储的队列需要建立一个大于n的数组,并把队列所有的元素存储在数组的前n个单元,数组下标为0的一端是队头。所谓的入队操作就是在队尾追加一个元素,不需要移动任何元素。
与栈不同的是,出队是在队头,即下标为0的位置,那也意味着队列中所有的元素都得向前移动,以保证队列的队头,也就是下标为0的位置不为空。
所以这里就会产生一个性能问题,每一次的出队列操作,都会将后面的元素一个个的向前移动,导致时间复杂度很高,如果不去限制前面的元素的移动,是不是会很节省资源?
为了避免当只有一个元素时,队头和队尾重合使得处理变得麻烦,所以引入了队头指针front和队尾指针rear,这样,当 front=rear时,此时队列不是还剩下一个元素,而是空队列。
假设长度为5的数组,初始状态,空队列如图所示,front和rear指针均指向下标为0的位置,让后依次a1,a2,a3,a4入队。front指针依然指向下标为0的位置,而rear指针指向下标为4.
如果此时将a1,a2出队操作,则front指针指向下标为2的位置,rear不变,如图所示,再入队a5,此时front指针不变,rear指针会移动到数组之外。
假设这个队列的总个数不超过5个,但目前如果接着入队的话,因数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在小标0和1的地方还是空闲的。我们将这种现象称为 “假溢出” 。此时我们就要用到循环队列了。
2.2.2 循环队列的定义
所以循环队列的目的就是为了解决 “假溢出”。当后面满了,就再从头开始,也就是头尾相接的循环。我们把这种头尾相接的顺序存储结构称为循环队列。
所以,像上面的那张图,如果将插入a5之后的rear指针指向下标为0的位置就可以解决这个问题,如图所示。
接着入队a6,将它放置在小标为0处,rear指针指向下标为1处,如左图所示,若再入队a7,rear和front指针就重合,同时指向下标为2的位置,如图所示。
此时问题又来了:
1、之前我们说,当队列为空时, front = rear,现在当队满时,也是 front = rear, 那么如何判断
队列是否为空还是满呢?
2、办法一是设置一个标志量flag,当 front = rear,且flag = 0时队列为空 ,当 front = rear,
且 flag = 1时为队列满。
3、办法二是当队列空时,条件是 front = rear ,当队列满时,修改其条件,保留一个元素空间。也就
是说当队列满时,数组中还有一个空闲单元,如左图所示,所以,我们就认为此队列是满了,不允许右
图出现。
我们来重点讨论第二种方法,由于rear可能比较大,也可能比较小,所以尽管它们只相差一个位置时就是满的情况,但也可能是相差整整一圈.
所以若队列的最大尺寸为QueueSize,那么队列满的条件是 :
(rear+1) % QueueSize = front
另外,当rear > front时,此时队列的长度为 rear- front。
当rear < front时,此时队列的长度为 rear- front + QueueSize。
因此,通用计算队列的长度公式为:
(rear - front + QueueSize) % QueueSize
循环队列的顺序存储结构:
typedef int QElemType;
typedef struct{
QElemType data[MAXSIZE];
int front;//头指针
int rear;//尾指针,若队列不为空,指向队列尾元素的下一个位置
}SqQueue;
循环队列的初始化:
/*
初始化队列
*/
Status InitQueue(SqQueue *Q){
Q->front = 0;
Q->rear = 0;
return OK;
}
循环队列求队列长度:
/*
求队列的长度
*/
int QueueLength(SqQueue Q){
return (Q.rear - Q.front + MAXSIZE) % MAXSIZE;
}
循环队列的入队操作:
Status EnQueue(SqQueue *Q,QElemType e){
if((Q->rear + 1) % MAXSIZE == Q->front){
return ERROR;//队列满判断
}
Q->data[Q->rear] = e; //将元素e赋值给队尾
Q->rear = (Q->rear + 1) % MAXSIZE; //rear指针向后移一位,若到达了尾部,则转到头部
return OK;
}
循环队列的出队操作:
/*
若队列不为空,则删除Q中队头元素,用e返回其值
*/
Status DeQueue(SqQueue *Q,QElemType *e){
if(Q->rear == Q->front){
return ERROR;//队列为空
}
*e = Q->data[Q->front];
Q->front = (Q->front + 1) % MAXSIZE;//将front指针向后移动一位
return OK;
}
2.3队列的链式存储结构及实现
队列的链式存储,其实就是线性表的单链表,只不过它只能尾进头出而已,我们将它简称为链队列。
为了操作方便,我们将队头指针指向链队列的头节点,而尾指针指向终点节点,如图所示:
空队列时,front和rear都指向头节点,如图所示:
链队列的结构:
typedef int QElemType;
typedef struct QNode{/* 节点结构*/
QElemType data;
struct QNode *next;
}QNode,*QueuePtr;
typedef struct{//队列的链表结构
QueuePtr front,rear;//队头,队尾指针
}LinkQueue;
2.3.1 队列的链式存储结构—入队操作
入队操作时,就是再链表尾部插入节点,如图所示:
代码如下:
Status EnQueue(LinkQueue *Q,QElemType e){
QueuePtr s = (QueuePtr)malloc(sizeof(QNode));
if(!s){
exit(OVERFLOW);// 存储分配失败
}
s->data = e;
s->next = NULL;
Q->rear->next = s;/*把拥有元素e新节点s赋值给原队尾节点的后继*/
Q->rear = s;
return OK;
}
2.3.2 队列的链式存储结构—出队操作
出队操作时,就是头节点的后继节点出队,将头节点的后继改为它的后面节点,若链表除头节点外只剩一个元素时,则需要rear指向头节点,如图所示:
出队操作:
/*
若队列不为空,删除Q的队头元素,用e返回其值,并返回OK,否则返回ERROR
*/
Status DeQueue(LinkQueue *Q,QElemType *e){
QueuePtr p;
if(Q->front == Q->rear){
return ERROR; //队列为空
}
p = Q->front->next; //将p指向需要删除的节点
*e = p->data;
Q->front->next = p->next;
if(Q->rear == p){ //若队头是队尾,则删除后将rear指向头节点。
Q->rear = Q->front;
}
free(p);
return OK;
}
3、栈与队列的比较
栈(Stack)是限定仅在表尾进行插入和删除操作的线性表。
队列(Queue)是允许在一端进行插入,一端进行删除操作的线性表。
它们都可以用线性表的顺序存储结构来实现,但都存在着顺序存储的一些弊端。
对于栈来说,如果是两个相同数据类型的栈,则可以用数组的两端作栈底的方法来让两个栈共享数据,这就可以最大化利用数组的空间。
对于队列来说,为了避免插入删除需要移动数据,于是就引入了循环队列,使得队头和队尾可以在数组中循环变化。解决了移动数据的时间损耗,使得本来插入和删除是O(n)的时间复杂度变成O(1)。