Bootstrap

【数据结构】—— 栈

目录

栈的定义

栈的存储结构及实现

 入栈

出栈

两栈共享空间(了解)

栈的应用

递归

逆波兰表达式

逆波兰表达式求值过程

中缀表达式转换成后缀表达式


栈的定义

        在我们软件应用中,栈这种后进先出数据结构的应用是非常普遍的。比如你用浏览器上网时,不管什么浏览器都有一个“后退”键,你点击后可以按访问顺序的逆序加载浏览过的网页。比如你本来在看着小说好好的,突然弹出到一个窗口,具体内容自己想象,你毫不犹豫点击它,跳转进去一看,这都是啥呀,具体内容我也就不说了。此时你还想回去继续看小说,就可以点击左上角的后退键。即使你从一个网页开始,连续点了几十个链接跳转,你点“后退时,还是可以像历史倒退一样,回到之前浏览过的某个页面。

        很多类似的软件,比如Word、画图等文档或图像编辑软件中,都有撤销(undo)的操作,也是用栈这种方式来实现的,当然不同的软件具体实现代码会有很大差异,不过原理其实都是一样的。

        栈(stack)是限定仅在表尾进行插入和删除操作的线性表。我们把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。栈又称为后进先出的线性表,简称 LIFO (Last In First 0ut)结构。

理解栈的时候需要注意:

        首先它是一个线性表,也就是说,栈元素具有线性关系,即前驱后继关系。只不过它是一种特殊的线性表而已。定义中说是在线性表的表尾进行插入和删除操作,这里表尾是指栈顶,而不是栈底。
        它的特殊之处就在于限制了这个线性表的插入和删除位置,它始终只在栈顶进行。这也就使得,栈底是固定的,最先进栈的只能在栈底。栈的主要操作

  • 栈的插入操作,叫作进栈,也称压栈、入栈。类似子弹压入弹夹
  • 栈的删除操作,叫作出栈,也有的叫作弹栈。如同弹夹中的子弹出夹

        现在我要问问大家,这个最先进栈的元素,是不是就只能是最后出栈呢?答案是不一定,要看什么情况。栈对线性表的插入和删除的位置进行了限制,并没有对元素进出的时间进行限制,也就是说,在不是所有元素都进栈的情况下,事先进去的元素也可以出栈,只要保证是栈顶元素出栈就可以。
举例来说,如果我们现在是有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。

        对于栈来讲,理论上线性表的操作特性它都具备,可由于它的特殊性,所以针对它在操作上会有些变化。特别是插入和删除操作,我们改名为pushpop(对齐C++STL),英文直译的话是压和弹,更容易理解。你就把它当成是弹夹的子弹压入和弹出就好记忆了,我们一般叫进栈和出栈。

栈的存储结构及实现

        由于栈本身就是-个线性表,那么上一章我们讨论了线性表的顺序存储链式存储,对于栈来说,也是同样适用的。本文主要利用线性表的顺序存储结构实现栈!!!

        既然栈是线性表的特例,那么栈的顺序存储其实也是线性表顺序存储的简化,我们简称为顺序栈。线性表是用数组来实现的,想想看,对于栈这种只能一头插入删除的线性表来说,用数组哪一端来作为栈顶和栈底比较好?对,没错,下标为0的一端作为栈底比较好,因为首元素都存在栈底,变化最小,所以让它作栈底。
        我们定义一个 top 变量来指示栈顶元素在数组中的位置,这 top 就如同中学物理学过的游标卡尺的游标,它可以来回移动,意味着栈顶的top可以变大变小,但无论如何游标不能超出尺的长度。同理,若存储栈的长度为capacity,则栈顶位置 top必须小于capacity。当栈存在一个元素时,top等于0,因此通常把空栈的判定条件定为 top 等于-1。

来看栈的结构定义:

typedef int StDataType;
typedef struct Stack
{
	StDataType* arr;
	int top;//栈顶
	int capacity;
}Stack;
  • top:代表栈顶所在的位置
  • capacity:代表栈所能存储的最大元素个数

    若现在有一个栈,capacity是5,则栈普通情况、空栈和栈满的情况示意图如图所示。

 入栈

        在对栈进行入栈之前。我们需要将栈顶top 和 容量capacity进行初始化,使top = -1, capacity任意指定即可。代码如下所示:

void StackInit(Stack* st)
{
	st->arr = (StDataType*)malloc(sizeof(StDataType) * 5);
	st->capacity = 5;
	st->top = -1;
}

        对于入栈操作,需要判断当前栈顶位置是否超过capacity -1,即top == capacity -1;如果没有超过栈的capacity,那么就执行下图的操作;

 代码实现如下:

int StackPush(Stack* st, StDataType e)
{
	assert(st);
	if (st->top >= st->capacity -1)
	{
		return -1;
	}
	st->arr[++ st->top] = e;
	return 1;

}

出栈

        出栈的时候要注意当前的栈是否为空栈,即top == -1;如果不是空栈,则将栈顶的元素返回

反之,返回-1;

 代码实现:

int StackPop(Stack* st)
{
	assert(st);
	if (st->top == -1)
		return -1;
	StDataType tmp = st->arr[st->top --];
	return tmp;
}

 栈的两个核心操作,不涉及任何循环所以时间复杂度均为O(1);

两栈共享空间(了解)

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

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

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

        从这里也就可以分析出来,栈1为空时,就是top1等于-1时;而当top2等于n 时,即是栈2为空时,那什么时候栈满呢?想想极端的情况,若栈2是空栈,栈1的top1等于n-1时,就是栈1满了。反之,当栈1为空栈时,top2等于0时,为栈2满了。但更多的情况,其实就是我刚才说的,两个栈见面之时,也就是两个指针之间相差1时,即top1+1==top2 为栈满。

栈的应用

        有的同学可能会觉得,用数组或链表直接实现功能不就行了吗?干吗要引入栈这样的数据结构呢?这个问题问得好。我当时也很疑惑!主要i原因就是当前我们的知识还是积累的太少了,无法认识到栈的重要性。
        其实这和我们明明有两只脚可以走路,干吗还要乘汽车、火车、飞机一样。理论上,陆地上的任何地方,你都是可以靠双脚走到的,可那需要多少时间和精力呢?我们更关注的是到达而不是如何去的过程。
        栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题核心。反之,像数组等,因为要分散精力去考虑数组的下标增减等细节问题,反而掩盖了问题的本质。

接下来我们正式认识一下栈的重要用途:递归、逆波兰表达式...

递归

在大学里面,大家学习的第一门编程语言大部分都是C语言,那么应该对函数递归这个概念多多少少有的印象!函数递归简单理解就是函数本身调用自己!那个入门的经典例题!大家一定都见过或听过、斐波那契数 ,函数表达形式如下:

 所以,很容易写出下述代码:

int fbi(int n)
{
    if(n < 2)
        return n == 0 ? 0 : 1;
    return fbi(n -1) + fbi(n -2);
}

能用递归书写的代码,也能用迭代来实现;不过迭代相较于递归,代码会更加简洁。不过要弄懂它得费点脑!我们来在纸上模拟代码中的 fbi(n)函数当n=5 的执行过程,我们可发现有多次重复计算!如果n稍微大一点,那么就可能需要运行很久,有没有什么优化的方式?提示一下,采用空间换时间的方式,将之前计算的值,记录下来,反复利用。

        当然,写递归程序最怕的就是陷入永不结束的无穷递归中,所以,每个递归定义必须至少有一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。比如刚才的例子,总有一次递归会使得n<2的,这样就可以执行return的语句而不用继续递归了。

        那么我们讲了这么多递归的内容,和栈有什么关系呢?这得从计算机系统的内部说起。
前面我们已经看到递归是如何执行它的前行和退回阶段的。递归过程退回的顺序是它前行顺序的逆序。在退回过程中,可能要执行某些动作,包括恢复在前行过程中存储起来的某些数据。这种存储某些数据,并在后面又以存储的逆序恢复这些数据,以提供之后使用的需求,显然很符合栈这样的数据结构,因此,编译器使用栈实现递归就没什么好惊讶的了。

        简单的说,就是在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态。当然,对于现在的高级语言,这样的递归问题是不需要用户来管理这个栈的,一切都由操作系统代劳了。

逆波兰表达式

        栈的现实应用也很多,我们再来重点讲一个比较常见的应用:数学表达式的求值。我们小学学数学的时候,有一句话是老师反复强调的,“先乘除,后加减,从左算到右,先括号内后括号外"。这个大家都不陌生。对于我们人来说这很好理解!甚至如果数不大的情况下,直接口算就可以了,但是计算机不一样,它是靠指令来执行的,我们要怎么实现呢?

        这里面的困难就在于乘除在加减的后面,却要先运算,而加入了括号后,就变得更加复杂。不知道该如何处理。

        但仔细观察后发现,括号都是成对出现的,有左括号就一定会有右括号,对于多重括号,最终也是完全嵌套匹配的。这用栈结构正好合适,只有碰到左括号,就将此左括号进栈,不管表达式有多少重括号,反正遇到左括号就进栈,而后面出现右括号时,就让栈顶的左括号出栈,期间让数字运算,这样,最终有括号的表达式从左到右巡查一遍,栈应该是由空到有元素,最终再因全部匹配成功后成为空栈的结果。

        但对于四则运算,括号也只是当中的一部分,先乘除后加减使得问题依然复杂如何有效地处理它们呢?我们伟大的科学家想到了好办法。

        20世纪50年代,波兰逻辑学家Jantukasiewicz,当时也和我们现在的同学们一样,困惑于如何才可以搞定这个四则运算,他灵感突现,想到了一种不需要括号的后缀表达法,我们也把它称为逆波兰(ReversePolishNotaton,RPN)表示。这种后缀表示法,是表达式的一种新的显示方式,非常巧妙地解决了程序实现四则运算的难题。

我们先来看看,对于“9+(3-1)x3+10÷2",如果要用后缀表示法应该是什么样子:“931-3*+102/+”,这样的表达式称为后缀表达式。后缀的原因在于有的符号都是在要运算数字的后面出现。显然,这里没有了括号。对于从来没有接触过后缀表达式的同学来讲,这样的表述是很难受的。

逆波兰表达式求值过程

        为了解释后缀表达式的好处,我们先来看看,计算机如何应用后缀表达式计算出最终的结果 20 的。后缀表达式:931-3*+102/+
规则:

  1. 从左到右遍历表达式的每个数字和符号,
  2. 遇到是数字就进栈,
  3. 遇到是符号,就将处于栈顶两个数字出栈,进行运算,运算结果进栈,一直到最终获得结果。

1、初始化一个空栈。此栈用来对要运算的数字进出使用。如左图所示·
2、后缀表达式中前三个都是数字,所以 9、3、1 进栈,如右图所示

3、接下来是“-”,所以将栈中的1出栈作为减数,3 出栈作为被减数,并运算 3-1得到2,再将2进栈,如左图所示。(注意减数、被减数的顺序)
4、接着是数字3进栈,如右图所示。

5、后面是“*”,也就意味着栈中3和2出栈,2与3相乘,得到6,并将6进栈,如左图所示。
6、下面是“+”,所以栈中6和9出栈,9与6相加,得到15,将15 进栈,如右图所示。

7、接着是10与2两数字进栈,如图4-9-4的左图所示。
8、接下来是符号“/”,因此,栈顶的2与10出栈,10与2相除(除数、被除数的顺序!!!一定要注意),得到5,将5进栈,如右图所示。

 9、最后一个是符号“+”,所以15与5出栈并相加,得到20,将20进栈,如左图所示。
10、结果是 20 出栈,栈变为空,如右图所示。

         果然,后缀表达法可以很顺利解决计算的问题。那么现在问题就是这个后缀表达式“931-3*+102/+”是怎么出来的?这个问题不搞清楚,等于没有解决。所以下面,我们就来推导如何让“9+(3-1)x3+10+2”转化为“931-3*+102/+”。

中缀表达式转换成后缀表达式

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

  1. 从左到右遍历中缀表达式的每个数字和符号,
  2. 若是数字就输出,即成为后缀表达式的一部分;
  3. 若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于等于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
                                                    运算符优先级
(、[+、-*、/)、]

我们默认左括号优先级最低,只要是遇到左括号直接入栈、右括号优先级最高,直到它匹配到对于 左括号之前。

1、初始化一空栈,用来对符号进出栈使用。如左图所示。

2、第一个字符是数字 9,输出 9,后面是符号“+”,进栈。如右图所示


3、第三个字符是“(”,依然是符号,因其只是左括号,还未配对,故进栈。如左图所示。
4、第四个字符是数字 3,输出,总表达式为93,接着是“-”,进栈。如右图所示。

5、接下来是数字1,输出1,总表达式为931,如左图所示;后面是符号“)”,此时,我们需要去匹配此前的“(”,所以栈顶依次出栈,并输出,直到“(”出栈为止。此时左括号上方只有“-”,因此输出“-”。总的输出表达式为931-。如右图所示。


6、接着是数字3,输出3,总的表达式为931-3。紧接着是符号“x”,因为此时的栈顶符号为“+”号,优先级低于“x",因此不输出,“*”进栈。如右图所示。

 7、之后是符号“+”,此时当前栈顶元素“*”比这个“+”的优先级高,因此栈顶元素出栈并输出(没有比“+”号更低的优先级,所以全部出栈),总输出表达式为931-3*+。然后将当前这个符号“+”进栈。

8、紧接着数字10,输出10,总表达式变为931-3*+10。后是符号“÷”,所以“/”进栈。如右图所示

9、最后一个数字2,输出,总的表达式为931-3*+10 2。如左图所示。

10、因已经到最后,所以将栈中符号全部出栈并输出。最终输出的后缀表达式结果为931-3*+10 2 / +。如右图所示。

到此就将一个中缀表达式转化成一个后缀表达式;无论是利用后缀表达式求值、转化成后缀表达式都是利用栈的特性来实现。理解了栈的这两个应用就理解了栈这个数据结构!!

;