文章目录
四、栈与队列
栈是限定仅在表尾进行插入和删除操作的线性表。
队列是只允许在一端进行插入操作、而在另一端进行删除操作的线性表。
(一)栈
1、基本介绍
栈( s t a c k stack stack )是限定仅在表尾进行插入和删除操作的线性表。
- 允许插入和删除的一端称为栈顶( t o p top top), 另一端称为栈底( b o t t o m bottom bottom), 含任何数据元素的栈称为空栈。栈又称为后进先出( L a s t I n F i r s t O u LastIn First Ou LastInFirstOut)的线性表,简称 L I F O LIFO LIFO 结构
- 首先它是一个线性表,也就是说,栈元素具有线性关系,即前驱后继关系。只不过它是一种特殊的线性表而已。定义中说是在线性表的表尾进行插入和删除操作,这里表尾是指栈顶,而不是栈底。
- 它的特殊之处就在于限制了这个线性表的插入和删除位置,它始终只在栈顶进行。这也就使得:栈底是固定的,最先进栈的只能在栈底。
- 栈的插入操作,叫作进栈,也称压栈、入栈。
- 栈的删除操作,叫作出栈,也有的叫作弹栈。
- 栈对线性表的插入和删除的位置进行了限制,并没有对元素进出的时间进行限制,也就是说,在不是所有元素都进栈的情况下,事先进去的元素也可以出栈,只要保证是栈顶元素出栈就可以。
2、栈的基本操作
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
InitStack ( *S)
:初始化操作. 建立一个空栈 S S S。DestroyStack ( *S )
:若栈存在,则销毁它。ClearStack ( *S)
:将栈清空。StackEmpty ( S )
:若栈为空,返回 t r u e true true, 否则返回 f a l s e false false。GetTop(S,*e)
:若栈存在且非空,用 e e e 返回 S S S 的枝顶元素。Push(*S,e)
:若栈S 存在,插入新元素 e e e 到栈 S S S 中并成为枝项元素。Pop(*S,*e )
:删除栈 S S S 中栈顶元素,井用 e e e 返回其值。StackLength (S)
:返回栈 S S S 的元素个数。
3、栈的顺序存储结构及实现
-
顺序存储:既然栈是线性表的特例,那么栈的顺序存储其实也是线性表顺序存储的简化,我们简称为顺序栈。线性表是用数组来实现的,可以用数组下标为 0 0 0 的一端作为栈底比较好,因为首元素都存在栈底,变化最小,所以让它作栈底。
-
栈的长度:若存储栈的长度为 S t a c k S i z e StackSize StackSize, 则栈顶位置 t o p top top 必须小于 S t a c k S i z e StackSize StackSize。当栈存在一个元素时, t o p top top 等于 0 0 0, 因此通常把空栈的判定条件定为 t o p top top 等于 − 1 -1 −1。
-
栈的结构定义:
typedef int SElemType; /* SElemType 类型根据实际情况而定,这里假设为 int */
typedef struct
{
SElemType data[MAXSIZE];
int top; /* 用于枝顶指针 */
}SqStack;
(1)进栈操作
Status Push ( SqStack *S,SElemType e)
{
if(S->top==MAXSIZE-1) /*栈满*/
{
return ERROR;
}
S->top++;/*栈顶指针增加一*/
S->data[S->top]=e;/*将新插入元素赋值给栈顶空间*/
return OK;
}
(2)出栈操作
Status Pop( SqStack *S, SElemType *e)
{
if(S->top == -1)
.return ERROR;
*e=S->data[S->top]; /* 将要删除的栈顶元素赋值给 e */
S->top--; /* 栈顶指针减一 */
return OK;
}
4、两栈共享空间
- 优点:顺序存储还是很方便的,因为它只准栈顶进出元素,所以不存在线性表插入和删除时需要移动元素的问题。
- 缺点:必须事先确定数组存储空间大小,万一不够用了,就需要编程手段来扩展数组的容量,非常麻烦。
- 特殊情况解决方法:如果我们有两个相同类型的栈,我们为它们各自开辟了数组空间,极有可能是第一个栈已经满了,再进栈就溢出了,而另一个栈还有很多存储空间空闲。————可以用一个数组来存储两个栈。
-
数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为 0 0 0 处;另一个栈的栈底为数组的末端,即下标为数组长度 n − 1 n-1 n−1 处。这样,两个栈如果增加元素,就是两端点向中间延伸。
-
们是在数组的两端,向中间靠拢。 t o p 1 top1 top1 和 t o p 2 top2 top2 是栈1 和栈 2的栈顶指针,可以想象,只要它们俩不见面,两个栈就可以一直使用。两个栈见面之时,也就是两个指针之间相差 1 时,即 t o p 1 + 1 = = t o p 2 top1 + 1 == top2 top1+1==top2 为栈满。
-
/* 两栈共享空间结构 */ typedef struct { SElemType data[MAXSIZE]; int top1; /* 栈 1 栈顶指针 */ int top2; /* 栈 2 栈顶指针 */ }SqDoubleStack; /* 插入元素 e 为新的栈顶元素 */ Status Push ( SqDoubleStack *S, SElemType e, int stackNumber) { if (S->top1+1==S->top2)/* 栈已满,不能再 push 断元素了 */ return ERROR; if ( stackNumber==l ) /* 栈1 有元素进栈 */ S->data [++S->topl] = e; /* 若栈1 则先 topi+1 后给数组元素鼠值 */ else if ( stackNumber==2 ) /* 栈 2 有元素进枝 */ S->data [--S->top2]=e; /* 若栈2 则先 top2-1 后给数组元素赋值 */ return OK; } /* 若栈不空,则删除 S 的栈顶元素,用 e 返回其值,并返回 0K; 否则返回 ERROR */ Status Pop ( SqDoubleStack *S, SElemType *e, int stackNumber ) { if ( stackNumber==l ) { if ( S->toplsaas-1) return ERROR; /* 说明栈1 已经是空栈,溢出 */ *e=S->data[S->top1--]; /* 将栈1 的栈顶元素出栈*/ } else if ( stackNumber==2 ) { if ( S->top2=MAXSIZE ) return ERROR; /* 说明栈 2 已经是空栈,溢出 */ *e=S->data [S->top2++]; /* 将栈 2 的栈顶元素出栈 */ } return OK; }
-
使用这样的数据结构,通常都是当两个栈的空间需求有相反关系时,也就是一个栈增长时另一个栈在缩短的情况。
-
- 特殊情况解决方法:如果我们有两个相同类型的栈,我们为它们各自开辟了数组空间,极有可能是第一个栈已经满了,再进栈就溢出了,而另一个栈还有很多存储空间空闲。————可以用一个数组来存储两个栈。
5、栈的链式存储结构及实现
- 栈的链式存储结构,简称为链栈。把栈顶放在单链表的头部,对于链栈来说,基本不存在栈满的情况
-
typedef struct StackNode { SElemType data; struct StackNode *next; }StackNode,*LinkStackPtr; typedef struct LinkStack { LinkStackPtr top; int count; }LinkStack;
(1)进栈操作
假设元素值为 e e e 的新结点是 s s s, t o p top top 为栈顶指针
-
/* 插入元素 e 为新的栈顶元素 */ 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; }
(2)出栈操作
变量 p p p 用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放 p p p 即可
-
/* 若栈不空,则删除 S 的栈顶元素,用 e 返回其值,并返回0K; 否则返回 ERROR */ Status Pop ( LinkStack *S,SElemType *e) { LinkStackPtr p; if(StackEmpty(*S)) return ERROR; *e=S->top->data; p=S->top; /* 将栈顶结点鼠值给 p, 如图③ */ S->top=S->top->next; /* 使得楼顶指针下移一位,指向后一结点,如图④*/ free ( p ); /* 释放给点 p */ S->count--; return OK; }
-
如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些
6、栈的作用
栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题核心。反之,像数组等,因为要分散精力去考虑数组的下标增减等细节问题,反而掩盖了问题的本质。
7、栈的应用——递归
(1)斐波那契数列
前面相邻两项之和,构成了后一项
- 打印
-
/*常规迭代法*/ int main() { int i; int a[40]; a[0]=0; printf("%d ",a[0]); printf("%d ",a[1]); for(i = 2;i < 40;i++) { a[i] =a[i-1] + a[i-2]; printf( "%d" ,a[i]); } return 0; }
-
/*递归法*/ int Fbi( int i) { if( i < 2 ) return i == 0 ? 0 : 1; //三元运算符的使用。如果 i 等于 0,表达式的值将是 0,否则将是 1。 return Fbi(i-1)+ Fbi(i-2); /*这里 Fbi 就是函数自己. 它在调用自己 */ } int main ( ) { int i; for(int i = 0;i < 40;i++) printf( “%d ”, Fbi(i)); return 0; }
- 迭代使用的是循环结构,递归使用的是选择结构。递归能使程序的结构更清晰、更简洁、更容易让人理解,从而减少读懂代码的时间。但是大量的递归调用会建立函数的副本,会耗费大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。
-
(2)递归定义
一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称做递归函数。
和栈的关系:在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态。
8、栈的应用——四则运算表达式求值
(1)后缀( 逆波兰 )表示法定义
- 括号运算和栈:括号都是成对出现的,有左括号就一定会有右括号,对于多重括号,最终也是完全嵌套匹配的。这用栈结构正好合适,只有碰到左括号,就将此左括号进栈,不管表达式有多少重括号,反正遇到左括号就进栈,而后面出现右括号时,就让栈顶的左括号出栈,期间让数字运算,这样,最终有括号的表达式从左到右巡查一遍,栈应该是由空到有元素,最终再因全部匹配成功后成为空栈的结果。
- 不需要括号的后缀表达法:(逆波兰)
- 后缀表达式:
9 + (3-1) × 3 + 10 + 2
——>9 3 1-3 * + 10 2/+
- 所有的符号都是在要运算数字的后面出现
- 从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行运算,运算结果进栈,一直到最终获得结果
- 后缀表达式:
(2)中缀表达式转后缀表达式
- 标准四则运算表达式:
“
9
+
(
3
−
1
)
×
3
+
10
+
2
”
“9+ (3-1) \times3+10+2”
“9+(3−1)×3+10+2” 叫做中缀表达式。
- 从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于栈顶符号 (乘除优先加减) 则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
- 想让计算机具有处理我们通常的标准(中缀)表达式的能力,最重要的就是两步:
-
- 将中缀表达式转化为后缀表达式(栈用来进出运算的符号)
-
- 将后缀表达式进行运算得出结果(栈用来进出运算的数字)
-
(二)队列
1、基本介绍
队列 ( q u e u e queue queue) 是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
- 队列是一种先进先出 ( F i r s t I n F i r s t O u t FirstIn First Out FirstInFirstOut) 的线性表,简称 F I F O FIFO FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。
2、队列操作
InitQueue ( *Q )
:初始化操作,建立一个空队列 Q Q QDestroyQueue( *Q)
:若队列 Q Q Q 存在,则销毁它ClearQueue( *Q )
:将队列 Q Q Q 清空QueueEmpty ( Q)
:若队列 Q Q Q 为空,返回 t r u e true true, 否则返回 f a l s e false false。GetHead ( Q,*e )
:若队列 Q Q Q 存在且非空,用 e e e 返回队列 Q Q Q 的队头元素。EnQueue ( *Q,e)
:若队列 Q Q Q 存在,插入新元素 e e e 到队列 Q Q Q 中并成为队尾元素。DeQueue( *Q,*e )
:删除队列 Q Q Q 中队头元素,并用 e e e 返回其值。QueueLength ( Q )
:返回队列 Q Q Q 的元素个数
3、循环队列
(1)队列顺序存储的不足
一个队列有 n n n 个元素,则顺序存储的队列需建立一个大于 n n n 的数组,并把队列的所有元素存储在数组的前 n n n 个单元,数组下标为 0 0 0 的一端即是队头。
-
入队列操作,其实就是在队尾追加一个元素,不需要移动任何元素
-
出列是在队头,即下标为 0 0 0 的位置,那也就意味着,队列中的所有元素都得向前移动
-
若出列不移动,不去限制队列的元素必须存储在数组的前 n n n 个单元这一条件,出队的性能就会大大增加
-
为了避免当只有一个元素时,队头和队尾重合使处理变得麻烦,所以引入两个指针, f r o n t front front 指针指向队头元素, r e a r rear rear 指针指向队尾元素的下一个位置,这样当 f r o n t front front 等于 r e a r rear rear 时,此队列不是还剩一个元素,而是空队列。
-
数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在前面删除过的地方还是空闲的。我们把这种现象叫做“假溢出”。
-
解决假溢出的办法就是后面满了,就再从头开始,也就是头尾相接的循环。
(2)循环队列介绍
队列的头尾相接的顺序存储结构称为循环队列。
- 问题又出来了,刚才说,空队列时, f r o n t front front 等于 r e a r rear rear, 现在当队列满时,也是 f r o n t front front 等于 r e a r rear rear, 那么如何判断此时的队列究竟是空还是满呢?
- 办法一是设置一个标志变量 f l a g flag flag, 当 f r o n t = = r e a r front == rear front==rear, 且 f l a g = 0 flag = 0 flag=0 时为队列空,当 f r o n t = = r e a r front == rear front==rear, 且 f l a g = 1 flag = 1 flag=1 时为队列满。
- 办法二是当队列空时,条件就是
f
r
o
n
t
=
=
r
e
a
r
front == rear
front==rear, 当队列满时,我们修改其条件,保留一个元素空间。也就是说,队列满时,数组中还有一个空闲单元。
-
由于 r e a r rear rear 可能比 f r o n t front front 大,也可能比 f r o n t front front 小,所以尽管它们只相差一个位置时就是满的情况,但也可能是相差整整一圈。队列的最大尺寸为 Q u e u e S i z e QueueSize QueueSize, 那么队列满的条件是
(rear+1)%QueueSize == front
(取模 “%” 的目的就是为了整合 r e a r rear rear 与 f r o n t front front 大小为一个问题)
代码
typedef int QElemType; /* QElemType 类型根据实际情况而定,这里假设为 int */
/* 据环队列的顺序存储结构 */
typedef struct
{
QElemType data[MAXSIZE];
int front; /* 头指针 */
int rear; /* 尾指针,若队列不空,指向队列尾元素的下一个位置 */
}SqQueue;
/* 初始化一个空队列 Q */
Status InitQueue( SqQueue *Q)
{
Q->front=0;
Q->rear=0;
return OK;
}
/* 返回 Q 的元素个数,也就是队列的当前长度 */
int QueueLength ( SqQueue Q)
{
return ( Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}
/* 若队列未满,则通入元素 e 为 Q 新的队尾元素 */
Status EnQueue ( SqQueue *Q,QElemType e)
{
if ( ( Q->rear+l)%MAXSIZE == Q->front)/* 队列满的判断 */
return ERROR;
Q->data[Q->rear]=e; /* 将元素 e 赋值给队尾 */
Q->rear= ( Q->rear+l)%MAXSIZE; /*rear 指针向后移一位置*/
/*若到最后则转到数组头部 */
return OK;
}
/* 若队列不空,则列除 Q 中队头元去. 用 e 返回其值 */
Status DeQueue ( SqQueue *Q,QElemType *e)
{
if ( Q->front== Q->rear) /* 队列空的判斯 */
return ERROR;
*e=Q->data[Q->front]; /* 将队头元素赋值给 e */
Q->front=(Q->front+l)%MAXSIZE; /* front 指针向后移一位五. */
/*若到最后则转到数组头部 */
return OK;
}
4、队列的链式存储结构及实现
队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。
-
队头指针指向链队列的头结点,而队尾指针指向终端结点
-
空队列时, f r o n t front front 和 r e a r rear rear 都指向头结点
typedef int QElemType; /* QElemType 类型根据实际情况而定,这里假设为 int */
typedef struct QNode /* 结点结构 */
{
QElemType data;
struct QNode *next;
}QNode,*QueuePtr;
typedef struct /* 队列的链表结构 */
{
QueuePtr front,rear; /* 队头、队尾指针 */
}LinkQueue;
(1)队列的链式存储结构一入队操作
入队操作时,其实就是在链表尾部插入结点
/* 插入元素 e 为 Q 的新的队尾元素 */
Status EnQueue ( LinkQueue *Q, QElemType e )
{
QueuePtr s= ( QueuePtr ) malloc ( sizeof ( QNode ) ) ;
if ( !s) /* 存储分配失败 */
exit ( OVERFLOW ) ;
s->data=e;
s->next=NULL;
Q->rear->nextss=s; /* 把拥有元素 e 新站点 s 赋值给原队尾站点的后继. */
/*见上图中① */
Q->rear=s; /* 把当前的 s 设置为队尾结点,rear 指向 s, 见上图中② */
return OK;
}
(2)队列的链式存储结构一出队操作
出队操作时,就是头结点的后继结点出队,将头结点的后继改为它后面的结点,若链表除头结点外只剩一个元素时,则需将
r
e
a
r
rear
rear 指向头结点
/* 若队列不空,热除 Q 的队头元素,用 e 返回其值,并返回 0K, 否则返回 ERROR */
Status DeQueue(LinkQueue *Q, QElemType *e)
{
QueuePtr p;
if(Q->front==Q->rear)
return ERROR;
p=Q->front->next; /* 将欲都除的队头结点暂存给 p, 见上图中① */
*e=p->data; /* 将欲翻除的队头结点的值k值给 e */
Q->front->next=p->next; /*将原队头结点后继 p->next 赋值给头结点后继,*/
/*见上图中*/
if ( Q->rear=-p) /*若队头是队尾,则删除后将 rear 指向头结点, 见上图中③ */
Q->rear=Q->front;
free ( p );
return OK;
}
5、对比
循环队列与链队列的比较,可以从两方面来考虑
- 从时间上,其实它们的基本操作都是常数时间,即都为 O ( 1 ) O(1) O(1)的。不过循环队列是事先申请好空间,使用期间不释放,而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队出队频繁,则两者还是有细微差异。
- 对于空间上来说,循环队列必须有一个固定的长度,所以就有了存储元素个数和空间浪费的问题。而链队列不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但也可以接受。所以在空间上,链队列更加灵活。
总的来说,在可以确定队列长度最失值的情况下,建议用循环队列,如果你无法预估队列的长度时,则用链队列。