一、栈的定义
定义:栈(stack):栈是限定仅在表的一端进行插入或删除操作的线性表。
我们把允许插入和删除操作的一端称为栈顶(top),另一端称为栈底(bottom)。不含任何数据元素的栈称为空栈。栈又称为“后进先出(Last In First Out,简称LIFO)的线性表”,简称为LIFO结构。
栈的插入操作,称为进栈/入栈/压栈。
栈的删除操作,称为出栈/弹栈。
不过要注意的是,最先进栈的元素不代表最后出栈。栈对线性表的插入删除位置做了限制,但并没有对出栈和入栈的时间做限制。也就是说,在不是所有元素都入栈的情况下,事先入栈的元素也可以在任意时间出栈,只要保证每次出栈的元素都是栈顶元素就可以。
示例:有3个元素:1、2、3按顺序依次入栈,则我们可能得到以下出栈结果:
⒈1、2、3进,3、2、1出。得到结果为321
⒉1进、1出、2进、2出、3进、3出。得到结果为123
⒊1进、2进、2出、1出、3进、3出。得到结果为213
⒋1进、1出、2进、3进、3出、2出。得到结果为132
⒌1进、2进、2出、3进、3出、1出。得到结果为231
思考:能否得到结果为312的出栈序列?
答案:不可能,若3先出栈,则意味着1和2已入栈,此时2的出栈一定在1之前。
练习:已知一个栈的入栈顺序是a,b,c,d,e则不可能得到的出栈顺序是:
A、abcde
B、edcba
C、dceab
D、decba
二、栈的顺序存储结构及实现
1、顺序栈的结构定义
typedef int data_t;
typedef struct
{
data_t data[MAXSIZE];
int top;//栈顶元素位置
}SqStack;
当top=-1时,该栈为空栈。当top=MAXSIZE-1时,该栈为满栈。
2、进栈操作Push
//代码见附录
3、出栈操作Pop
//代码见附录
对于进栈和出栈操作来说,二者都没有用到循环语句,因此时间复杂度为O(1)。
4、清空栈操作
清空一个栈的操作可以用以下方法实现:不断弹栈,直至栈内没有元素为止。
但是这样做实际上是没有必要的。若要清空一个栈,则意味着该栈内的元素已经“无用”了,这时不用再每个元素进行弹栈操作,而直接将栈顶的位置拉下至栈底即可,即:
top=-1;
这样,当新的元素再次压栈时,会覆盖掉原始的“无用”数据。
//代码见附录
三、栈的链式存储结构及实现
对于顺序栈来说,主要的缺点就是栈的大小已经固定,若有超过栈长的元素个数,则此时栈会发生“溢出”。这时我们可以采用链式栈的存储结构,这样就不用再考虑栈的空间是否足够大的问题。
1、链栈的结构定义
栈的链式存储结构,简称为链栈。
思考:对于栈的链式存储结构来说,栈顶指针是在链表头结点位置更好,还是在链表尾节点位置更好?
答:头结点位置更好
链表有头指针,而栈的主要操作也是在栈顶进行,那么我们就可以将二者合一,将单链表的头指针作为栈顶指针,即栈的链式存储结构的栈顶指针为单链表的头指针。
typedef struct StackNode
{
data_t data;
struct StackNode *next;
}LinkStack;
2、判定链式栈是否为空
对于链栈来说,基本不存在栈满的情况,除非内存中已没有可用空间。因此不考虑判定链式栈满的操作。
对于链栈来说,栈空的情况实际上就是判断top==NULL的情况。
//代码见附录
3、进栈操作Push
//代码见附录
4、出栈操作Pop
//代码见附录
对于进栈和出栈操作来说,二者都没有用到循环语句,因此时间复杂度为O(1)。
5、清空栈操作
//代码见附录
对比顺序栈与链栈,我们发现它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要事先确定长度,可能会存在内存空间浪费问题。但它的优势是存取时定位方便。而且链栈要求每个元素都有指针域,这同时也增加了内存开销,但对于栈的长度无限制。
综上所述,如果栈的使用过程中元素变化不可预料,有时数据量少有时却很大,则我们推荐使用链栈。反之,如果数据变化在可控范围内,则我们推荐使用顺序栈。
四、栈的应用
1、递归
递归:函数在自身的函数体内直接或间接地调用自身。
示例:递归法求斐波那契数列
int Fbi(int i)
{
if(i<2)
return i==0?0:1;
return Fbi(i-1)+Fbi(i-2);
}
int main()
{
int i;
for(i=0;i<40;i++)
printf("%d\t",Fbi(i));
printf("\n");
return 0;
}
要实现递归,必要的两个条件是递归出口和递归逻辑。在示例程序中,if(i<2)就是递归出口,而Fbi(i-1)+Fbi(i-2)就是递归逻辑。
//斐波那契数列的非递归解法略
对比递归代码和非递归(迭代)代码,我们可以看出递归和迭代的区别:迭代使用循环结构,而递归使用分支结构。
在某些程序中,递归能使得程序结构简洁清晰,容易理解。但是大量的调用递归函数会建立许多该函数的副本,需要大量的内存存储空间。而迭代法则无需大量的存储空间。
要想实现递归,我们需要明白递归的过程本质上是函数返回顺序是其调用顺序的逆序,即:先行调用的函数会在后面获得返回值。这种先行存储数据,并在之后逆序恢复得到数据的过程,显然很符合栈这种数据结构。因此,编译器使用栈来实现函数的递归。
在调用阶段,对于每层递归,函数的局部变量、参数、返回地址都被压入栈中,再去调用下次递归。在返回阶段,依次弹出位于栈顶的函数,获得计算结果。这也是为什么需要“递归出口”的原因,递归出口可以看做是从压栈到弹栈的状态转变因素。
2、后缀(逆波兰)表示法
对于数学运算来说,确定运算符的优先级是十分重要的,直接决定了该算式是否计算正确。在实际生活中,我们书写的算式都是中缀表达式,即运算符(此处特指算数运算符)在操作数中间。例如:
9+(3-1)*3+10/2
我们把这种平时使用的四则运算表达式的写法称为中缀表达式。但是对于计算机而言,中缀表达式并不方便。计算机计算都是从左到右顺序计算,在该算式中,*在+之后,但是却要先于+进行运算,而加入括号后,运算则会变得更加复杂。
对于四则运算,20世纪50年代,波兰逻辑学家Jan Lukasiewicz发明了一种不需要括号的表达式方法,称为后缀表示法,也称为逆波兰(Reverse Polish Notation,简称RPN)表示法。
对于上文的算式,使用后缀表示法为:
9 3 1 - 3 * + 10 2 / +
即运算符在两个操作数之后出现。
那么对于后缀表达法来说,计算机是怎样计算的呢?
后缀表达式的算法规则:从左到右遍历表达式,若遇到数字则进栈,遇到运算符则弹出栈顶两个元素进行运算,计算结果再次压栈,最后计算得到的结果就是最终结果。
我们以9 3 1 - 3 * + 10 2 / +进行讲解
⒈初始化一个空栈,此栈用于对要计算的操作数的进出及存储。
⒉9、3、1都是数字,因此依次入栈
⒊接下来是-,是符号,弹出栈顶两个元素作为操作数,注意先弹出的元素在符号右侧,后弹出的元素在符号左侧,即3 - 1,得到计算结果2,将2压栈。
⒋数字3进栈
⒌后面是*,栈顶两个元素弹栈进行运算 2 * 3,得到结果6,再压入栈
⒍后面是+,栈顶两个元素弹栈进行运算 9 + 6,得到结果15,再压入栈
⒎数字10和2进栈
⒏后面是/,栈顶两个元素弹栈进行运算 10 / 2,得到结果5,再压入栈
⒐最后一个符号是+,栈顶两个元素弹栈进行运算 15 + 5,得到结果20
⒑最终结果是20,栈变为空,结束运算。
那么,如何把中缀表达式转化为后缀表达式呢?
中缀表达式转化为后缀表达式的规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号则判断其与栈顶符号的优先级,是右括号或优先级低于或等于栈顶符号的则栈顶元素依次出栈并输出,直至遇到一个比其优先级低的运算符为止,并将当前符号进栈,一直到最终输出后缀表达式为止。
我们以9+(3-1)*3+10/2------>9 3 1 - 3 * + 10 2 / +进行讲解
1.初始化一个空栈,用于对符号进出栈使用。
2.第一个数字是9,输出9。后面的符号+入栈。
3.第三个字符是(,依然是符号,因其是左括号还未配对,故进栈。
4.第四个字符是数字3,输出,此时表达式为9 3,接着符号-进栈。
5.接下来是数字1,输出,此时表达式为9 3 1,后面是符号),此时我们需要把(之前的所有元素都出栈,直至输出(为止。此时总的表达式是9 3 1 -。
6.紧接着是符号*,因为此时的栈顶符号是+,优先级低于*,因此不输出,*进栈。紧接着是数字3,输出,总表达式为9 3 1 – 3.
7.之后是符号+,此时栈顶元素是*,比+优先级高,因此栈中元素出栈并输出(因为没有比+更低优先级的符号,所以全部出栈),总输出表达式为9 3 1 – 3 * +。然后将这个符号+进栈。
8.紧接着输出数字10,总表达式为9 3 1 – 3 * + 10。之后是符号/,所以/进栈。
9.最后一个数字为2,此时总表达式为9 3 1 – 3 * + 10 2。
10.因已到最后,所以将栈中符号全部出栈。最终获得的后缀表达式为9 3 1 – 3 * + 10 2 / +。
所以,对于计算机来说,输入中缀表达式,获得计算结果,最重要的有两步:
⒈将中缀表达式转化为后缀表达式
⒉计算后缀表达式得到计算结果
而这两步的整个过程都使用到了栈这种数据结构。
五、队列的定义
定义:队列(queue):队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
队列是一种先进先出(First In First Out)的线性表,简称FIFO。允许插入操作的一端称为队尾,允许删除操作的一端称为队头。
队列与现实生活中的排队机制很像,排在队头的出队,而想入队则只能从队尾开始。
六、循环队列
线性表有顺序存储和链式存储两种结构,队列作为特殊的线性表,自然也有顺序队列和链式队列两种形式。
但是,队列的顺序存储却有很大的缺陷。如果我们要建立元素个数为n的队列,则需要建立一个数组长度不小于n的数组,数组下标为0的为队头,当最大下标的为队尾。若有元素要入队,则只需将其存储在第n+1个位置即可。
而若想出队,则删除了下标为0的元素后,所有在其后的元素都需要向前移动一格,即保持下标为0的元素为队头。但这样做显然浪费了大量时间。
解决该问题的方法就是不再限制下标为0的元素为队头,每次出队后,队头自动变成当前数组下标最小的元素即可。这样就无需所有元素向前移动。
但是,若如此做,则会造成大量的已出队的元素的存储空间浪费。而且,若此时入队元素已经大于n,则我们需要更大的存储空间才行,但队头位置有大量空间未利用,空间浪费严重。
解决以上问题的方法就是如果后面满了,则我们就从头开始,也就是将队列做成头尾相接的循环。我们把这种头尾相接的顺序存储结构的队列称为循环队列。
这样,我们就需要两个指示其队头(front)和队尾(rear)的下标变量。
typedef int data_t;
typedef struct
{
data_t data[MAXSIZE];
int front;//队头位置
int rear;//队尾位置
}SqQueue;
当front==rear时,此时队头和队尾重合,则该队列为空队列;当(rear+1)%QueueSize==front时,此时队尾的下个位置就是队头,则该队列为满队列。注意rear的位置不是队尾元素的位置,而是队尾元素的下一个位置,即当队列满时,队列中还有一个空闲存储空间,但我们规定该状态下就是满队列。
那么,定义好队列的的队头和队尾位置,我们来考虑怎样计算队列长度。
当rear>front时,表示队尾在队头右边,此时队列长度为rear-front;当rear<front时,表示队尾在队友左边,此时计算队列长度应分成两部分,即rear一部分,QueueSize-front一部分,总体长度为rear-front+QueueSize。
通用计算队列长度的公式是
length=(rear-front+QueueSize)%QueueSize
1、判断队是否为空EmptyQueue
//代码见附录
2、判断队是否为满FullQueue
//代码见附录
3、入队操作EnQueue
//代码见附录
4、出队操作DeQueue
//代码见附录
从循环队列的特性我们可以看出,循环队列虽然操作较为简单,但是由于队列定长,因此会出现数据溢出问题。这时我们需要考虑使用队列的链式存储结构。
七、队列的链式存储结构及实现
队列的链式存储结构本质上是从单链表演化而来的。将单链表改造成链式队列,如果将头结点做为队头,最后一个节点做为队尾,则该队列的出队操作方便,而入队操作较慢;反之,如果将头结点做为队尾,最后一个节点做为队头,则该队列的入队操作方便,而出队操作较慢。
那么,能否将单链表稍加改进,使得该链式队列的入队操作和出队操作一样方便呢?
答案是可以的,只需要改进头结点。将“头结点存储一个next指针”改为“头结点存储两个指针front和rear”,front指针指向队头,rear指针指向队尾。这样我们进行出队/入队操作时,只需要访问这两个指针就能快速地找到队头/队尾。
1、队列的链式存储结构定义
将单链表的头结点稍加改造
typedef int data_t;
typedef struct node_t//定义单链表
{
data_t data;
struct node_t *next;
} linknode_t, *linklist_t;
typedef struct//定义链式队列
{
linklist_t front, rear;
} linkqueue_t;
2、判定链式队列是否为空
由于单链表的属性,链式队列几乎不会出现“队列已满”的情况,因此不考虑判定链式队列是否已满的操作。
判定链式队列是否为空,只需要判定队列的front指针是否为空即可。
//代码见附录
3、队列的链式存储结构——入队操作
入队操作其实就是在链表尾部插入节点。新来的数据节点附在当前rear节点之后,并将rear节点指向该节点即可。
//代码见附录
4、队列的链式存储结构——出队操作
出队操作就是将链表的头结点的后继节点出队,并将其之后的节点设置为头结点的后继节点。若链表除头结点外仅剩一个元素,则需将rear指向头结点。
//代码见附录
对于循环队列和链式队列的比较,二者从时间复杂度上来说都是O(1),不过链式队列需要花费额外的申请节点的时间,而且链式队列删除节点也需要一些时间。从空间上来说,循环队列有固定长度,就意味着循环队列有其存储上限,因此也就存在元素溢出以及空间浪费等问题,而链式队列则不存在这些问题。
总体来说,若可以大致确定元素个数的情况下,推荐使用循环队列;若无法事先预知元素个数,则应使用链式队列。
练习(
可选):球钟问题球钟是一个利用球的移动来记录时间的简单装置。它有三个可以容纳若干个球的指示器:分钟指示器,五分钟指示器,小时指示器。若分钟指示器中有2个球,五分钟指示器中有6个球,小时指示器中有5个球,则时间为5:32。
每过一分钟,球钟就会从球队列的队首取出一个球放入分钟指示器,分钟指示器最多可容纳4个球。当放入第五个球时,在分钟指示器的4个球就会按照他们被放入时的相反顺序加入球队列的队尾。而第五个球就会进入五分钟指示器。按此类推,五分钟指示器最多可放11个球,小时指示器最多可放11个球。
当小时指示器放入第12个球时,原来的11个球按照他们被放入时的相反顺序加入球队列的队尾,然后第12个球也回到队尾。这时,三个指示器均为空,回到初始状态,从而形成一个循环。因此,该球钟表示时间的范围是从0:00到11:59。
思考:球钟需要多少个球,才能实现计时范围为0:00到11:59?
提示:使用3个栈来分别表示1min指示器、5min指示器和1h指示器,使用一个队列来存储小球义