来源:互联网 发布:win10磁盘优化有几遍 编辑:程序博客网 时间:2024/05/20 17:27

一、栈的定义

1.栈的定义

栈(stack)是限定仅在表尾进行插入和删除操作的线性表。

我们把允许插入和删除的一端称为栈顶(top)另一端称为栈底(bottom),不含任何数据元素的称为空栈。栈又称为后进先出(Last In First Out)的线性表,简称LIFO结构

首先它是一个线性表,也就是说,栈元素具有线性关系,即前驱后继关系。只不过它是一种特殊的线性表而已。定义中说在线性表的表尾进行插入和删除操作,这里的表尾是指栈顶,而不是栈底。

它的特殊之处就在于限定了这个线性表的插入和删除位置,它始终只在栈顶进行。也就是说:栈底是固定的,最先进栈的只能在栈底。

栈的插入操作,叫做进栈,也称压栈、入栈。

栈的删除操作,叫做出栈,也有的叫作弹栈。

2.进栈出栈变化形式

最先进栈的元素,是不是就只能最后出栈呢?

答案是不一定。栈对线性表的插入和删除的位置进行了限制,并没有对元素进出的时间进行限制,也就是说,在不是所有元素都进栈的情况下,事先进去的元素也可以出栈,只要保证是栈顶元素就可以。

举例来说,如果我们现在是有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先出栈,就意味着3曾经进栈,既然3都进栈了,1和2也已经进栈了,此时,2一定是在1的上面,就是跟接近栈顶,那么出栈只可能是321。

二、栈的抽象数据类型

对于栈来讲,理论上线性表的操作它都具备,可是由于它的特殊性,所以针对它在操作上回有些变化,特别是插入和删除操作,我们改为push和pop。我们一般叫进栈和出栈。

ADT 栈 (stack)Data    同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。Operation    InitStack(*S):初始化操作,建立一个空栈。    DestroyStack(*s):若栈存在,则销毁它。    ClearStack(*s):将栈清空。    StackEmpty(s):若栈为空,返回true,否则返回false。    GetTop(S,*e):若栈存在且非空,用e返回S的栈顶元素。    Push(*S,e):若栈S存在,插入新元素e到栈S中并称为栈顶元素    Pop(*S,*e):删除栈S中栈顶元素,并用e返回其值。    StackLength(S):返回栈S的元素个数。

由于栈本身就是一个线性表,那么上一章我们讨论了线性表的顺序存储和链式存储,对栈来说,也是同样适用的。

三、栈的顺序存储结构及实现

1.栈的顺序存储结构

既然栈是线性表的特例,那么栈的顺序存储其实也是线性表顺序存储的简化。我们简称为顺序栈。顺序存储的线性表使用数组实现的。对于栈这种只能一头插入删除的线性表来说,用下标为0的一端作为栈底比较好,因为首元素都存在栈底,变化最小,所以让它作为栈底。

定义一个top变量来指示栈顶元素在数组中的位置,若存储栈的长度为StackSize,则栈顶位置top必须小于StackSize。栈顶存在一个元素时,top等于0,因此通常把空栈的判定条件设为top等于-1。

来看栈的结构定义

typedef int SElemType;//类型根据实际情况而定,这里假设为inttypedef struct{    SElemType data[MAXSIZE];    int top;//用于栈顶}SqStack;

假设有一个栈,StackSize是5,则栈普通情况、空栈和栈满的情况。
①栈有两个元素 top = 1
②空栈 top = -1
③栈满 top = 4

2.栈的顺序存储结构——进栈操作

对于栈的插入,即进栈操作。

因此对于进栈操作push,其代码如下:
一些宏定义

#define OK 1#define ERROR 0#define TRUE 1#define FALSE 0typedef int Status;
//插入元素e为新的栈顶元素Status Push(SqStack *S,SElemType e){    if(S->top == MAXSIZE - 1)//栈满    {        return ERROR;    }    S->top++;//栈顶指针增加1    S->data[S->top] = e;//将新插入元素赋给栈顶空间    return OK;}

3.栈的顺序存储结构——出栈操作

出栈操作pop

//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK,否则返回ERRORStatus Pop(SqStack *s , SElemType *e){    if(S->top < 0)//空栈        return ERROR;    *e = S->data[S->top];//将要删除的栈顶元素赋给e    S->top--;//栈顶指针减1    return OK;}

两者没有涉及任何循环语句,因此时间复杂度均是O(1)。

四、两栈共享空间

顺序存储还是很方便的,因为它只准栈顶进出元素,所以不存在线性表的插入和删除时需要移动元素的问题。
不过它由一个很大的缺陷,就是必须事先确定数组存储空间大小,万一不够用了,就需要编程手段来扩展数组的容量,非常麻烦。对于一个栈,我们也只能尽量考虑周全,设计适合大小的数组来处理,但对于同类型的栈,我们却可以做到最大限度地利用其事先开辟的存储空间来进行操作

如果我们有两个相同类型的栈,我们为它们各自开辟了数组空间,极有可能是第一个栈已经满了,再进栈就溢出了,而另一个栈还有很多空闲。我们完全可以用一个数组来存储两个栈,只不过需要点小技巧。

数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0出,另一个栈为数组的末端,即下标为数组长度n - 1出。这样两个栈如果增加元素,就是两端点向中间延伸。

其实关键思路是:它们是在数组的两端,向中间靠拢。top1和top2是栈1和栈2的栈顶指针。可以想象,只要它们两个不见面,两个栈就可以一直使用。

从这里可以分析出来,当栈1为空时候,就是top1等于-1时;而当top2等于n时,即是栈2为空时,那么什么时候栈满呢?

考虑几个极端情况,若栈2是空栈(等于n),栈1的top1等于n-1时,就是栈1满了。反之,当栈1为空栈时(等于-1),栈2等于0时,为栈2满。考虑更多的情况,也就是两个见面之时,也就是两个指针之间相差1时,即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 == S->top2)//栈满,不能在push新元素了        return ERROR;    if(stackNumber == 1)//栈1元素进栈        S->data[++S->top1] = e; //若栈1则先top1+1后赋值    else if(stackNumber == 2)//栈2元素进栈        S->data[--S->top2] = e;//若栈2则先top2-1后赋值    else//出现其他情况,说明出现错误        return ERROR;    return OK;}

因为在开始已经判断了是否有栈满的情况,所以后面的top1 + 1或者 top2 - 1是不担心溢出问题的。

对于两栈共享空间的pop方法,参数就只是判断栈1栈2的参数stackNumber,代码如下:

//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回falseStatus Pop(SqDoubleStack *S,SElemType *e,int stackNumber){    if(stackNumber == 1)    {        if(S->top1 == -1)//说明栈1已经是空栈,溢出            return ERROR;        *e = S->data[S->top1--];//将栈1的栈顶元素出栈    }    else if(stackNumber == 2)    {        if(S->top == MAXSIZE)            return ERROR;//说明栈2是空栈,溢出        *e = S->data[S->top2++];//将栈2的栈顶元素出栈    }    else    {        return ERROR;    }    return OK;}

事实上,使用这样的数据结构,通常是当两个栈的空间需求有相反关系时,也就是一个栈增长时另一个栈在缩短的情况。就像买卖股票一样,你买入时,一定是一个不知道人在卖出。有人赚钱,就一定有人赔钱。这样使用两栈共享空间存储方法才比较有较大意义。否则两个栈都在不停增长,那很快就会因栈满而溢出了。

当然,这只是针对两个具有相同数据类型的栈的一个设计上的技巧,如果不相同数据类型的栈,这种办法不但不能更好地处理问题,反而会使问题更复杂

五、栈的链式存储结构及实现

1.栈的链式存储结构

现在我们来看栈的链式存储结构,简称链栈
栈只是栈顶来做插入和删除操作,栈顶放在链表头还是尾部呢?由于单链表有头指针,而栈顶指针也是必须的,所以把栈顶放在单链表的头部,由于栈顶在头部了,单链表比较常用的头结点也就失去了意义,通常对于链栈来说,是不需要头结点的

对于链栈来说,基本不存在栈满的情况,除非内存已经没有可以使用的空间,如果真的发生,那么此时的计算机已经面临死机崩溃的情况,而不是这个链栈是否溢出的问题。

但对于空栈来说,链表原定义是头指针指向空,那么链栈的空其实就是top = NULL;的时候

链栈的结构代码如下:

typedef struct StackNode{    SElemType data;    struct StackNode *next;}StackNode , *LinkStackPtr;typedef struct LinkStack{    LinkStackPtr top;    int count;}LinkStack;

链栈的操作绝大部分都和单链表类似,只是在插入和删除上,特殊一些。

2.栈的链式存储结构——进栈操作

对于链栈的进栈push操作,假设元素值为e的新结点是s,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->count++;    return OK;}

3.栈的链式存储结构——出栈操作

链栈的出栈操作pop操作,也是很简单的三句操作。假设变量p用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放p即可。

//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK,否则返回ERRORStatus Pop(LinkStack *S,SElemType *e){    LinkStatckPtr 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;}

对于顺序栈与链栈,它们在时间复杂度上是一样的,均为O(1),对于空间性能,顺序栈需要事先确定一个固定的长度,可能会存在内存空间浪费的问题,但它的优势是存取定位很方便而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制

所以它们的区别和线性表中讨论的一样,如果使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果他的变化在可控范围内,建议使用顺序栈会更好些

六、栈的作用

栈的引入简化了程序设计的问题,划分了不同关注层级,使得思考范围缩小,更加聚焦于我们要解决问题核心。反之,像数组等,因为要分散精力去考虑数组下表等增减等细节问题,反而掩盖了问题的本质。

七、栈的应用——递归

1.斐波那契数列实现

斐波那契数列:1 , 1 , 2 , 3 , 5, 8 , 13….
这个数列有一个十分明显的特点,那是:前面相邻之和,构成了后一项。

我们实现这样的数列常用常规的迭代的办法如何实现?比如打印出前40项。
代码如下:

int main(){    int i;    int a[40];    a[0] = 0;    a[1] = 1;    printf("%d",a[0]);    printf("%d",a[1]);    for(i = 2;i < 40;i++)    {        a[i] = a[i - 1] + a[i - 2];        println("%d ",a[i]);    }    return 0;}

如果我们用递归来实现,代码可以更简单:

//斐波那契递归函数int Fbi(int i){    if(i < 2)        return i == 0 ? 0 : 1;    return Fbi(i - 1) + Fbi(i - 2);//这个Fbi就是函数自己}int main(){    int i;    for(i = 0;i < 40;i++)    {        printf("%d ",Fbi(i));    }    return 0;}

相比迭代的代码,递归更简洁。

2.递归定义

在高级语言中,调用自己和其他函数病没有本质不同。
我们把一个直接调用自己或通过一系列的调用语句间接调用自己的函数,称为递归函数

当然,写递归最怕的就是陷入死循环,所以至少有一个条件,满足时递归不再进行,即不再引用自身而返回值退出。比如刚才的例子,总有一次一次递归会使得i < 2的,这样就可以执行return i的语句结束递归。

对比两种实现斐波那契的代码。迭代和递归的区别是:迭代使用的是循环结构,递归使用的是选择结构。递归能使程序结构更清晰、更简洁、更容易让人理解。但是大量的递归调用会建立函数的副本,会耗费大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。

那递归和栈有什么关系呢?

递归过程退回的顺序是它前行的逆序。在退回过程中,可能要执行某些动作,包括恢复在前行中存储起来的某些数据。

这种存储某些数据,并在后面又以存储的逆序恢复这些数据,以提高以后使用的需求,显然很符合栈这样的数据结构,因此编译器使用栈实现递归。

简单来说,就是在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态

当然在现在的高级语言中,这样的递归问题不需要用户来管理这个栈的,一切由系统代劳了。

八、栈的应用——四则运算表达式求值

1.后缀(逆波兰)表示法定义

假设我们要设计一个简单的计算器,如果只是两个数的加减,当然是很简单。
但是如果我们要计算的是一个相对复杂的四则运算,其中还有括号的运算呢?比如 : 9 + (3 - 1) X 3 + 10 ÷ 2。

这里的困难主要在于乘除在加减的后面,却要先运算,而加入了括号,有得先算括号内的。

仔细观察发现,括号都是成对出现的,有左括号则一定有右括号,对于多重括号,最终也是完全嵌套匹配的。这种用栈结构正好合适,只要碰到左括号,就将左括号进栈,不管表达式有多少重括号,反正遇到左括号就进栈,而后面出现右括号时,就让栈顶的左括号出栈,期间让数字运算。当然,括号只是四则运算的一部分,先乘除后加减的问题依然复杂。如何有效地处理呢?

波兰逻辑学家想到了一种不需要括号的后缀表达式法,我们也把它成为逆波兰(Reverse Polish Notation,RPN)表示

2.中缀表达式转后缀表达式

我们把平时所用的标准四则运算表达式,即”9 + (3 - 1) X 3 + 10 ÷ 2”叫做中缀表达式。因为所有的运算符在两数字的中间,现在我们的问题就是中缀表达式到后缀的转化。

规则:从左到右遍历中缀表达式的每个数字和符号,若是数字便输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,若是右括号或者优先级不高于栈顶符号则栈顶元素依次出栈并输出,并将当前符号进栈。知道最后输出表达式为止。

比如上面所说的 9 + (3 - 1) X 3 + 10 ÷ 2
步骤:
①初始化一空栈,用来对符号进出栈使用。

②第一个字符是数字9,输出9 , “+”号是符号,进栈。

位置 栈内元素 3 2 1 0 +

输出:9

③第三个字符时“(”,依然是符号,因其是左括号,进栈。

位置 栈内元素 3 2 1 ( 0 +

输出:9
④第四个字符时数字3,输出,总表达式是9 3 ,接着是”-“符号,进栈。

位置 栈内元素 3 2 - 1 ( 0 +

输出:9 3

⑤接着是数字1,后面是符号”)”。此时我们需要匹配此前的”(“,所以栈顶依次出栈,知道”(“出栈位置,此时括号上方只有”-“,所以输出”-“。总表达式为9 3 1 -。

位置 栈内元素 3 2 1 0 +

输出:9 3 1 -

⑥接着是符号”X”,此时栈顶符号为”+”,优先级低于“X”,因此不输出,”*”进栈,接着是数字3,输出。总表达式是9 3 1 - 3。

位置 栈内元素 3 2 1 X 0 +

输出:9 3 1 - 3

⑦之后是“+”号,此时栈顶元素是 ” * “,比”+”优先级高,因此栈中元素输出(因为没有比”+”号更低优先级,所以全部出栈)。
此时表达式为:9 3 1 - 3 * +。然后9后面的“+”进栈。

位置 栈内元素 3 2 1 0 +

输出:9 3 1 - 3 * +

⑧紧接着是数字10,输出,总表达式为:9 3 1 - 3 * + 10。因为是”÷”,然后”/”进栈。

位置 栈内元素 3 2 1 / 0 +

输出:9 3 1 - 3 * + 10

⑨最后一个数字2,总的表达式为 9 3 1 - 3 * + 10 2。由于到最后,所以将栈中所有符号出栈。

位置 栈内元素 3 2 1 0

输出:9
最终表达式为:9 3 1 - 3 * 2 + 10 2 / +

3.后缀表达式的计算方法

后缀表达式:9 3 1 - 3 * 2 + 10 2 / +

计算规则:从左到右遍历表达式的每个数字和符号,遇到数字就进栈,遇到是符号,就将处于栈顶的两个数字出栈,进行运算,运算结果出栈。

① 9 、3、 1入栈

位置 栈内元素 3 2 1 1 3 0 9

②遇到“-”,将栈顶2个元素出栈,1作为减数,3出栈作为被减数。得到2。

位置 栈内元素 3 2 1 2 0 9

③遇到3进栈

位置 栈内元素 3 2 3 1 2 0 9

④遇到 * 。2 * 3 = 6。6进栈

位置 栈内元素 3 2 1 6 0 9

⑤遇到+。9 + 6 = 15

位置 栈内元素 3 2 1 0 15

⑥遇到10、2

位置 栈内元素 3 2 2 1 10 0 15

⑦遇到“/”。10 ÷ 2 = 5

位置 栈内元素 3 2 1 5 0 15

⑧遇到“+”。15 + 5 = 20。20进栈

位置 栈内元素 3 2 1 0 20

⑨最后20出栈。栈为空。

位置 栈内元素 3 2 1 0
0 0