栈在表达式求值中的应用
导读
大家好,很高兴又和大家见面啦!!! 在前面的内容中我们详细介绍了栈的第一种应用——在括号匹配问题中的应用,如果还没有阅读过的朋友可以回看前面两篇文章。在今天的内容中我们将介绍栈的另一种应用——在表达式求值中的应用。
表达式相信大家应该不陌生了,在学习C语言的过程中我们会经常性的提到表达式,尤其是在学习操作符的时候,我们就有学习过表达式的求值规则——根据操作符的优先级与结合性来进行:
- 优先级高的操作符优先运算;
- 操作符优先级相同则按照结合性进行运算;
但是仅仅根据优先级和结合性来看的话,在求值的过程中我们还是会写出一些形如a*b c*d e*f
这样的根据运算顺序的不同而得出不同结果的问题表达式,所以为了确保我们的表达式能以正确的运算顺序进行运算,我们则可以通过"()"这个操作符来将优先运算的部分给分隔开,通过这个方式来确保我们在表达式求值的过程中不会出现任何歧义。
在今天的内容中,我们将会介绍如何通过栈在不需要考虑操作符的优先级的情况下来完成无歧义的表达式求值。这时可能有朋友就有疑问了,这个栈还能再表达式求值中使用?并且不需要考虑操作符优先级?当你有这个疑问时,我要恭喜你,你现在已经开始思考栈如何在表达式求值中进行应用了。那么接下来,就让咱们一起来探讨一下这个问题……
一、表达式的形式
对于表达式而言,它本身也是有多种形式的。我们在学习C语言接触到的表达式是像(a b)*c
这种满足操作数 操作符 操作数这种形式的表达式。对于这种将操作符放在操作数中间的表达式形式我们将其称为中缀表达式。
中缀表达式在进行求值时需要遵循的运算规则就是我们前面学习的根据操作符的优先级与结合性来进行运算求值,但是这个运算规则还是会存在一些问题,从而导致一些问题表达式的产生。为了减少对操作符优先级的依赖,达到减少问题表达式的目的,波兰的一位数学家就提出了一种新的表达式形式——波兰表达式与逆波兰表达式。
波兰表达式又称为前缀表达式,它的表达式形式为:操作符 操作数 操作数。
逆波兰表达式又称为后缀表达式,它的表达式形式为:操作数 操作数 操作符。
从这两种表达式形式我们可以看到,相对于中缀表达式,它们仅仅是改变了操作符的位置,这样做真的能够不依赖操作符的优先级吗?为了解答这个疑问,我们需要进一步的了解这两种表达式与中缀表达式的区别;
二、波兰表达式与逆波兰表达式
不管是波兰表达式还是逆波兰表达式,从表达式形式上观察,我们很容易发现它们与中缀表达式的区别是操作符位置的不同。在波兰表达式中操作符是位于操作数的前面因此波兰表达式又称为前缀表达式,而逆波兰表达式中操作符位于操作数的后面,因此逆波兰表达式又称为后缀表达式。
波兰表达式与逆波兰表达式它们在运算时并不是遵守的中缀表达式的运算规则,相比于中缀表达式而言它们的运算规则更加的简单——根据操作符的先后顺序进行运算。
如果仅仅通过文字,我相信这样的表述并不清晰,因此我们还是通过图来进行理解:
从图中我们可以到,对于波兰表达式而言操作符的顺序越靠后,运算时则越先运算,这个操作特性大家有没有想到什么?
没错,就是栈,在波兰表达式中,操作符出现的顺序与运算的顺序刚好是满足后入先出的操作特性。如果是这样的话那逆波兰表达式不就正好相反吗?那具体是不是这样呢?下面我们接着往下看:
从图中可以看到,对于逆波兰表达式而言,操作符顺序越靠前,运算时则越先运行,而对于操作数而言,则是操作数越靠后,则越先进行运算,因此我们可以得到结论,波兰表达式与逆波兰表达式的运算规则是相反的。
因此如果我们想要通过栈来实现这两种表达式的话,栈中入栈的对象肯定是有区别的。那有没有什么方式能够保证不管我使用的是波兰表达式还是逆波兰表达式,栈中存放的内容都是一致的呢?
答案是有的,我们可以通过改变扫描的方向来保证栈中存放的内容。就比如对于波兰表达式而言,操作符都是放在操作数前面的,因此我想要栈中存放的是操作符的话,那我则可以从左往右进行扫描;而对于逆波兰表达式而言,操作符都是放在操作数后面的,因此我想要栈中存放的是操作符的话,那我则可以从右往左进行扫描。同理,如果我想要栈中存放的是操作数的话,那就正好相反。所以对于这两种表达式而言,我们可以根据自己具体的需求来选择扫描的方向。
我们现在回到前面的问题,这两种形式的表达式是否依赖操作符的优先级?
从前面的演示看来,通过波兰表达式和逆波兰表达式来进行计算时,不管我们是从前往后扫描还是从后往前扫描,实际上它的运算顺序都与操作符的优先级无关,表达式运算的先后顺序只与操作符的先后顺序有关。
现在我们对这两种表达式有了一个大致初步的了解,但是我还是会有一个疑问——我们熟知的中缀表达式可不可以转换成波兰表达式与逆波兰表达式呢?如果可以转换,那又应该如何来进行转换呢?那么接下来我们就来探讨一下这三种表达式的相互转换;
三、表达式之间的相互转换
我们如果要是实现表达式的相互转换,我们首先就需要了解表达式的组成部分。在前面的介绍中,我们对三种表达式各自的形式都有所了解了,它们的形式如下所示:
- 前缀表达式(波兰表达式):操作符 操作数 操作数;
- 中缀表达式(普通表达式):操作数 操作符 操作数;
- 后缀表达式(逆波兰表达式):操作数 操作数 操作符;
那现在问题来了,这个操作数是什么?有朋友很快就想到了,是整数;还有朋友说是小数。有这些想法的朋友,是真的有在认真思考问题,而且确实是这样,在表达式中,操作数既可以是整数,也可以是小数,当然,操作数还可以是表达式、函数、字符……因此我想说明的是,我们在看待表达式的组成形式时,不能局限自己的思维。
在明确了操作数的内容后,我们再来看一下表达式的形式,不管是前缀、中缀还是后缀,这三种表达式实际上都是在对两个对象进行操作,因此我们在进行表达式之间的转换时就需要抓住他的核心——操作对象。就比如这个例子:"(a b)*c-d e*(f-g)"
,这个表达式是我们熟悉的中缀表达式,接下来我们根据操作符的优先级与结合性将其进行拆分可以得到以下几个部分:
接下来我们再按照表达式的组成形式对各个部分的内容进行进一步的划分,如下所示:
接下来清楚了各个分部的组成部分后,我们就可以对其进行不同形式的转换了,如下所示:
在分别得到前缀与后缀表达式的各个分部后,我们接下来再将其改写成表达式形式,如下所示:
看到这里有朋友可能就有疑问了,为什么你这里改写的和前面演示的不太一样呢?接下来我们再来将前面演示的前缀和后缀表达式来进行一下各个分部的划分以及找出各分部非组成部分,如下所示:
从上图中我们可以看到,之所以会有区别是因为左右操作数的不同导致的,在前缀表达式的演示例子中,第一部分的内容在第二部分中是作为左操作数,而在后缀表达式的演示例子中第一部分的内容在第二部分中则是作为右操作数。
此时可能有朋友会奇怪,为什么我们一定要把左右分的这么细呢?难道a b != b a
?
相信有朋友已经反应过来了,没错,对于满足交换律的加法与乘法来说,在左边与在右边都是没有区别的,有区别的是减法与除法,a-b
可不一定与b-a
相等,同理a/b
也不一定等于b/a
,因此对于表达式而言,分清楚操作数的左右则是十分重要的。
在了解了前缀、后缀表达式以及前、中、后缀这三种表达式的相互转换之后,我们就要开始进入今天的重点内容了——栈在表达式求值中的应用。
表达式求值是程序设计语言编译中一个最基本的问题,它的实现是栈引用的一个典型范例。下面我们就来分别探讨一下如何通过栈来实现波兰表达式(前缀表达式)以及通过栈来实现逆波兰表达式(后缀表达式);
四、栈实现波兰表达式
对于前缀表达式而言,它的特点就是操作符在操作数的前面,在前面的介绍中我们知道它操作符的使用是遵循后入先出的原则,对于前缀表达式演示的例子中给出的前缀表达式:"*/- abcde"
我们可以通过一个存放操作符的栈即可实现表达式的求值,求值的基本逻辑如下所示:
- 表达式从左往右进行扫描;
- 遇到操作符时入栈,遇到第一个操作数时操作符出栈;
- 遇到第二个操作数时,通过求值变量记录当前表达式的值;
从这个基本逻辑上是不是感觉很简单,但是这是在这种操作符与操作数完全分离的例子,如果替换成" -* abcd*e-fg"
这个例子时,这个算法逻辑就会存在问题,如下所示:
从演示中可以看到,通过这个逻辑得出的最终结果为:"(((((a b)*c)-d) e)*f)-g"
这个与我们需要的对应中缀表达式的结果"(((a b)*c)-d) (e*(f-g))"
可谓是相差甚远,这足以说明一个问题——咱们此时的实现逻辑有问题,那应该如何修改呢?
4.1 问题分析
要修改我们的逻辑,首先我们需要先分析问题所在。从演示中可以看到,通过这个逻辑实现的求值,它只是机械的完成出栈和入栈以及计算结果,并未很好的对操作符的对象进行区分,这才导致了操作符的对象出现了错误。
也就是说我们通过扫描将操作符进行入栈的话,那我们是无法准确区分每个操作符的操作对象的。那么我们何不尝试一下将操作数来进行入栈,用操作数来选择操作符呢?
在前面的介绍中我们有提到过,对于前缀表达式而言,如果我们需要在栈中记录操作数的话,那我们则需要从右往左进行扫描,这是由波兰表达式的形式决定的:
- 从左往右扫描,操作符满足LIFO的操作特性;
- 而从右往左扫描,则是操作数满足LIFO的操作特性。
如果我们换一个角度来看的话,前缀表达式从右往左扫描实际上就是在扫描一个逆波兰表达式。
接下来我们就可以尝试着修改咱们得实现逻辑了;
4.2 问题完善
现在我们确定了入栈的对象为操作数,那么我们就要思考以下几个问题:
- 如何出栈?
- 出栈后的元素如何摆放?
- 完成计算后的结果如何处理?
为了完成我们的算法逻辑,接下来我们就来一一解答这些问题。
- 如何出栈?回答这个问题前我们再来复习一下前缀表达式的形式:
我们现在既然时从操作数的一端进行扫描,也就是说当我们扫描到操作符时,操作符前面先后入栈的两个元素就是操作符所对应的操作数。因此我们在出栈时就需要将这两个元素先后进行出栈。
解决了第一个问题,接下来我们来看第二个问题;
- 出栈后的元素如何摆放?回答这个问题我们依旧需要回顾表达式的形式:
从表达式的形式中可以看到,如果我们从右往左扫描的话,那么先入栈的是右操作数,后入栈的是左操作数,也就是说当遇到操作符时,左操作数是栈顶元素,因此它会先出栈,而后出栈则是右操作数。通过文字表达的不够直观,因此我们来看一下图:
从演示中我们可以更直观的看到,左操作数会先出栈,然后再是右操作数进行出栈,因此我们在进行运算时,只需要先出栈的元素放操作符左边,后出栈的元素放操作符的右边,然后进行运算就没问题了。
现在我们已经解决了两个问题了,下面就是第三个问题:
- 完成计算后的结果如何处理?这个问题是我们需要重点关注的,这里我们通过前面的例子
" -* abcd*e-fg"
回顾一下操作符的各个分部以及对应的组成部分,如下如所示: 对应的前缀表达式的各分部则如下所所示: 现在我们是从右往左扫描,因此我们会优先完成第二部分的计算,而对于第四部分而言,第二部分的计算结果为第四部分的有操作数,既然是操作数,按照我们的逻辑,那我们就需要对其进行入栈。 在完成第四部分的计算后,我们可以看到,当程序计算到第六部分时,此时的第四部分是第六部分的右操作数,因此这时我们同样需要将第四部分的计算结果进行入栈。 同理,在完成第一部分、第三部分以及第五部分的运算后,我们都需要将其进行入栈。 也就是说,此时我们在完成运算后,对我们需要对运算的结果正常的进行入栈操作,直到遇到与它相匹配的操作符,再进行正常的出栈操作即可。
现在这三个问题我们都解决了,我们的算法思路也更加清晰了,此时我们需要完成的算法整体逻辑应该是:
- 从右往左进行扫描;
- 遇到操作数进行入栈操作;
- 遇到操作符进行两次出栈操作;
- 完成出栈后进行计算,先出栈的元素在左边,后出栈的元素在右边;
- 计算完成后将结果进行入栈操作;
- 完成表达式的扫描后将运算结果进行出栈;
有了具体的算法思路,接下来我们就可以通过代码来一一实现对应的功能了;
4.3 算法实现
既然要通过栈来实现表达式求值,那么这里我们还是创建三个文件:
- Stack.c——用来实现栈的创建、初始化、判空、入栈、出栈、销毁、获取栈顶元素等这些基本操作,具体的实现过程这里我就不再进行赘述,对此有疑问的朋友可以回看前面写C语言实现栈的相关博客。在今天的实现过程中我们会使用链栈来实现前缀表达式求值。
- Stack.h——用来对栈的基本操作进行声明以及对相关头文件的应用,因为这里我们需要从右往左扫描字符串,所以我们需要额外引用头文件
<string.h>
通过库函数strlen
来计算字符串的长度;在扫描的过程中我们还需要对字符类型进行识别,因此我们还需要引用头文件<ctype.h>
,通过库函数isdigit
来识别字符是否为数字字符;
- test.c——用来实现函数主体,整个算法进行测试。这个文件中的内容就是我们今天需要重点介绍的内容。
做好准备工作后,接下来我们就可以开始实现咱们的函数主体了;
4.3.1 获取波兰表达式
对于波兰表达式的获取,目前我们还无法用程序实现,因此,这里我们以手动输入为主,当然对其进行接收的肯定是一个字符数组,这里的数组大小我们预设100,对应的代码如下所示:
代码语言:javascript复制#define MAXSIZE 100
//链栈实现波兰表达式求值
void test1() {
LinkStack S;//创建链栈
Init(&S);//初始化栈——这里采用的是不带头结点的单链表实现的链栈
int e = 0;//记录入栈与出栈的元素
char ch[MAXSIZE] = { 0 };//创建接收前缀表达式的字符数组
while (scanf("%s", ch) == 1) {
}
}
这里我简单解释一下这个while语句:
对于库函数
scanf
而言,当他在调用成功时会返回占位符的个数,如这里只有一个占位符%s
,而调用失败时则会返回EOF
,因此我们通过对其返回值的判断来决定是否进行循环,这样就可以达到多次输入的效果,所以这种方式我们也可以称为多组输入。
- 这里需要注意的是,我们此时的数组大小为100,因此我们实际输入的字符只能输入99个,需要给
'