有趣的地方

有趣的地方

【数据结构】你知道波兰表达式和逆波兰表达式吗?我才知道原来栈在表达式求值中还能这样使用……

栈在表达式求值中的应用

封面

导读

大家好,很高兴又和大家见面啦!!!
在前面的内容中我们详细介绍了栈的第一种应用——在括号匹配问题中的应用,如果还没有阅读过的朋友可以回看前面两篇文章。在今天的内容中我们将介绍栈的另一种应用——在表达式求值中的应用。

表达式相信大家应该不陌生了,在学习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"我们可以通过一个存放操作符的栈即可实现表达式的求值,求值的基本逻辑如下所示:

  1. 表达式从左往右进行扫描;
  2. 遇到操作符时入栈,遇到第一个操作数时操作符出栈;
  3. 遇到第二个操作数时,通过求值变量记录当前表达式的值;

从这个基本逻辑上是不是感觉很简单,但是这是在这种操作符与操作数完全分离的例子,如果替换成"+-*+abcd*e-fg"这个例子时,这个算法逻辑就会存在问题,如下所示:
错误逻辑

从演示中可以看到,通过这个逻辑得出的最终结果为:"(((((a+b)*c)-d)+e)*f)-g"这个与我们需要的对应中缀表达式的结果"(((a+b)*c)-d)+(e*(f-g))"可谓是相差甚远,这足以说明一个问题——咱们此时的实现逻辑有问题,那应该如何修改呢?

4.1 问题分析

要修改我们的逻辑,首先我们需要先分析问题所在。从演示中可以看到,通过这个逻辑实现的求值,它只是机械的完成出栈和入栈以及计算结果,并未很好的对操作符的对象进行区分,这才导致了操作符的对象出现了错误。

也就是说我们通过扫描将操作符进行入栈的话,那我们是无法准确区分每个操作符的操作对象的。那么我们何不尝试一下将操作数来进行入栈,用操作数来选择操作符呢?

在前面的介绍中我们有提到过,对于前缀表达式而言,如果我们需要在栈中记录操作数的话,那我们则需要从右往左进行扫描,这是由波兰表达式的形式决定的:

  • 从左往右扫描,操作符满足LIFO的操作特性;
  • 而从右往左扫描,则是操作数满足LIFO的操作特性。

如果我们换一个角度来看的话,前缀表达式从右往左扫描实际上就是在扫描一个逆波兰表达式。

接下来我们就可以尝试着修改咱们得实现逻辑了;

4.2 问题完善

现在我们确定了入栈的对象为操作数,那么我们就要思考以下几个问题:

  • 如何出栈?
  • 出栈后的元素如何摆放?
  • 完成计算后的结果如何处理?

为了完成我们的算法逻辑,接下来我们就来一一解答这些问题。

  1. 如何出栈?
    回答这个问题前我们再来复习一下前缀表达式的形式:
    操作符 + 操作数 + 操作数 操作符+操作数+操作数 操作符+操作数+操作数
    我们现在既然时从操作数的一端进行扫描,也就是说当我们扫描到操作符时,操作符前面先后入栈的两个元素就是操作符所对应的操作数。因此我们在出栈时就需要将这两个元素先后进行出栈。

解决了第一个问题,接下来我们来看第二个问题;

  1. 出栈后的元素如何摆放?
    回答这个问题我们依旧需要回顾表达式的形式:
    操作符 + 左操作数 + 右操作数 操作符+左操作数+右操作数 操作符+左操作数+右操作数
    从表达式的形式中可以看到,如果我们从右往左扫描的话,那么先入栈的是右操作数,后入栈的是左操作数,也就是说当遇到操作符时,左操作数是栈顶元素,因此它会先出栈,而后出栈则是右操作数。
    通过文字表达的不够直观,因此我们来看一下图:
    出栈演示
    从演示中我们可以更直观的看到,左操作数会先出栈,然后再是右操作数进行出栈,因此我们在进行运算时,只需要先出栈的元素放操作符左边,后出栈的元素放操作符的右边,然后进行运算就没问题了。

现在我们已经解决了两个问题了,下面就是第三个问题:

  1. 完成计算后的结果如何处理?
    这个问题是我们需要重点关注的,这里我们通过前面的例子"+-*+abcd*e-fg"回顾一下操作符的各个分部以及对应的组成部分,如下如所示:
    各分部即组成部分
    对应的前缀表达式的各分部则如下所所示:
    各分部
    现在我们是从右往左扫描,因此我们会优先完成第二部分的计算,而对于第四部分而言,第二部分的计算结果为第四部分的有操作数,既然是操作数,按照我们的逻辑,那我们就需要对其进行入栈。
    在完成第四部分的计算后,我们可以看到,当程序计算到第六部分时,此时的第四部分是第六部分的右操作数,因此这时我们同样需要将第四部分的计算结果进行入栈。
    同理,在完成第一部分、第三部分以及第五部分的运算后,我们都需要将其进行入栈。
    也就是说,此时我们在完成运算后,对我们需要对运算的结果正常的进行入栈操作,直到遇到与它相匹配的操作符,再进行正常的出栈操作即可。

现在这三个问题我们都解决了,我们的算法思路也更加清晰了,此时我们需要完成的算法整体逻辑应该是:

  1. 从右往左进行扫描;
  2. 遇到操作数进行入栈操作;
  3. 遇到操作符进行两次出栈操作;
  4. 完成出栈后进行计算,先出栈的元素在左边,后出栈的元素在右边;
  5. 计算完成后将结果进行入栈操作;
  6. 完成表达式的扫描后将运算结果进行出栈;

有了具体的算法思路,接下来我们就可以通过代码来一一实现对应的功能了;

4.3 算法实现

既然要通过栈来实现表达式求值,那么这里我们还是创建三个文件:

  • Stack.c——用来实现栈的创建、初始化、判空、入栈、出栈、销毁、获取栈顶元素等这些基本操作,具体的实现过程这里我就不再进行赘述,对此有疑问的朋友可以回看前面写C语言实现栈的相关博客。在今天的实现过程中我们会使用链栈来实现前缀表达式求值。
    栈的基本操作

  • Stack.h——用来对栈的基本操作进行声明以及对相关头文件的应用,因为这里我们需要从右往左扫描字符串,所以我们需要额外引用头文件<string.h>通过库函数strlen来计算字符串的长度;在扫描的过程中我们还需要对字符类型进行识别,因此我们还需要引用头文件<ctype.h>,通过库函数isdigit来识别字符是否为数字字符;
    链栈头文件

  • test.c——用来实现函数主体,整个算法进行测试。这个文件中的内容就是我们今天需要重点介绍的内容。

做好准备工作后,接下来我们就可以开始实现咱们的函数主体了;

4.3.1 获取波兰表达式

对于波兰表达式的获取,目前我们还无法用程序实现,因此,这里我们以手动输入为主,当然对其进行接收的肯定是一个字符数组,这里的数组大小我们预设100,对应的代码如下所示:

#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个,需要给'\0'留一个位置,这个细节大家在输入的时候别忘记了哦。

我相信大家对这一段代码应该都是没啥问题了的,下面我们继续往后看;

4.3.2 从右往左扫描表达式

在波兰表达式中,此时我们需要从右往左进行扫描,因此我们需要知道当前波兰表达式的字符个数,这里我们可以通过库函数strlen来进行计算;对字符串扫描的方式我们之前也有过介绍,可以通过对应的数组下标来进行扫描,因此这里我们还是借助for循环来完成扫描,代码如下所示:

		int len = strlen(ch);//获取字符串长度
		for (int i = len - 1; i >= 0; i--)//从右往左进行遍历
		{
		}

这个代码的实现比较简单我就不再过多赘述,我们继续往后看;

4.3.3 遇到操作数进行入栈操作

在这个功能的实现中,我们需要完成以下的几个内容:

  1. 判断元素类型——是操作数还是操作符;
  2. 提取元素——这里我们是实现的整型运算,所以需要将对应元素提取出来并转换成整型;
  3. 元素入栈——在提取完元素后,我们则需要对该元素进行入栈操作

这三个内容的实现并不复杂,下面我们直接来看对应的代码:

			if (isdigit(ch[i])) {
				e = ch[i] - '0';//将数字字符转整型
				Push(&S, e);//将元素进行入栈
			}

这里我们要介绍的代码是字符转整型的过程,这个我们是通过字符所对应的ASCII码值来实现的,现在我们来回顾一下ASCII码表,如下所示:
ASCII码表
从表中我们可以看到对于数字字符而言,它们与字符0的差值与它们所代表的数字是相同的,因此我们在进行数字字符与整型的转换时可以通过 + − 0 +-0 +0来实现;

4.3.4 遇到操作符进行对应操作

在遇到操作符时,按照我们的算法逻辑,我们需要完成以下内容:

  1. 判断操作符的类型
  2. 进行两次出栈操作;
  3. 完成出栈后进行计算,先出栈的元素在左边,后出栈的元素在右边;
  4. 计算完成后将结果进行入栈操作;

因此这里我们需要两个变量来记录出栈的元素并将完成计算后的结果通过变量e来进行记录并进行入栈,所以对应的代码如下所示:

			else if (ch[i] == '+') {//当扫描到加号时,进行出栈并执行加法
				int a = 0, b = 0;//记录出栈元素
				Pop(&S, &a);//完成第一次出栈
				Pop(&S, &b);//完成第二次出栈
				e = a + b;//完成操作符的运算
				Push(&S, e);//运算完成后重新入栈
			}

现在我们就很好的将上述内容一一实现了,对于其它的三个操作符,我们的完成步骤同样如此,如果我们对于每一个操作符都来编写这些代码,是不是在无形之中就增加了我们的工作量呢?为了简化代码,我们可不可以考虑将这一过程封装成函数?

答案是肯定的,这里的实现过程我就不展开介绍,直接看代码:

void Calculetion(LinkStack* S, int e, char ret) {
	int a = 0, b = 0;//记录出栈元素
	Pop(S, &a);//完成第一次出栈
	Pop(S, &b);//完成第二次出栈
	if (ret == '+')
		e = a + b;
	else if (ret == '-')
		e = a - b;
	else if (ret == '*')
		e = a * b;
	else if (ret == '/')
		e = a / b;
	else {
		printf("无法识别操作符%c\n", ret);
		return;
	}
	Push(S, e);//运算完成后重新入栈
}

这个函数封装的思想比较简单,就是将重复的操作单独拎出来,唯一不同的操作就是运算符的不同,因此我们可以通过条件语句来对不同情况进行对应的处理,为了增加算法的健壮性,这里我们在函数中增加了对于不属于四种算术操作符的处理;而此时我们在原函数中只需要调用这个Calculetion函数即可。

4.3.5 完成表达式的扫描后将运算结果进行出栈

上述内容全部完成后,也就到了咱们得最后一步输出计算结果,这里的实现也是比较简单的,代码如下所示:

		Pop(&S, &e);//将结果进行出栈
		if (Empty(S))
			printf("%s = %d\n", ch, e);
		else {
			printf("计算出错\n");
			Print(S);//输出栈顶元素
		}

这里之所以需要判空,同样也是为了提高算法的健壮性,能够及时的反馈出错的情况。下面我们就来看一下完整的代码展示以及对应的运行结果;

4.3.6 算法测试

测试函数完整代码如下所示:

#define MAXSIZE 100
//链栈实现波兰表达式求值
void test1() {
	LinkStack S;//创建链栈
	Init(&S);//初始化栈——这里采用的是不带头结点的单链表实现的链栈
	int e = 0;//记录入栈与出栈的元素
	char ch[MAXSIZE] = { 0 };//创建接收前缀表达式的字符数组
	while (scanf("%s", ch) == 1) {
		int len = strlen(ch);//获取字符串长度
		for (int i = len - 1; i >= 0; i--)//从右往左进行遍历
		{
			if (isdigit(ch[i])) {
				e = ch[i] - '0';//将数字字符转整型
				Push(&S, e);//将元素进行入栈
			}
			else  {//当扫描到操作符时,进行出栈并执行对应运算
				Calculetion(&S, e, ch[i]);
			}
		}
		Pop(&S, &e);//将结果进行出栈
		if (Empty(S))
			printf("%s = %d\n", ch, e);
		else {
			printf("计算出错\n");
			Print(S);//输出栈顶元素
		}
	}
}

接下来我们就来测试一下代码,我们同样还是以前面的例子来进行测试,只不过这里我们需要将字母替换为数字,测试用例为:"+-*+1234*5-67",对应的中缀表达式为:"(((1+2)*3)-4)+(5*(6-7))",正确答案为:0,下面我们就来进行算法测试阶段:
算法测试
从测试结果中我们可以看到,此时咱们的测试是正常通过的,也就是说现在我们很好的通过栈实现了波兰表达式。下面我们再来看看如果要实现逆波兰表达式,会有哪些改动;

五、栈实现逆波兰表达式

前面我们已经介绍过了,在实现波兰表达式时我们是从右往左进行的扫描,而逆波兰表达式则刚好相反,也就是需要从左往右进行扫描,在扫描的过程中,算法实现的操作是一样的,唯一的不同是此时我们不需要计算字符串的长度,我们可以直接通过字符串结束标志'\0'来作为循环的判断条件。下面我们直接来看对应的代码:

//链栈实现逆波兰表达式求值
void test2() {
	LinkStack S;//创建链栈
	Init(&S);//初始化栈——这里采用的是不带头结点的单链表实现的链栈
	int e = 0;//记录入栈与出栈的元素
	char ch[MAXSIZE] = { 0 };//创建接收前缀表达式的字符数组
	while (scanf("%s", ch) == 1) {
		for (int i = 0; ch[i]; i++)// !!!修改处从左往右进行遍历
		{
			if (isdigit(ch[i])) {
				e = ch[i] - '0';//将数字字符转整型
				Push(&S, e);//将元素进行入栈
			}
			else {//当扫描到操作符时,进行出栈并执行对应运算
				Calculetion(&S, e, ch[i]);
			}
		}
		Pop(&S, &e);//将结果进行出栈
		if (Empty(S))
			printf("%s = %d\n", ch, e);
		else {
			printf("计算出错\n");
			Print(S);//输出栈顶元素
		}
	}
}

这里的测试用例我们还是以前面的用例来进行测试"12+3*4-567-*+",表达式的结果同样为0,下面我们直接来测试:
算法测试2
可以看到,程序运行没问题,而且也能给出正确结果。因此我们现在就通过栈实现了波兰表达式与逆波兰表达式的求值问题。

结语

在今天的内容中,我们详细介绍了以下内容:

  • 表达式的另外两种形式——波兰表达式与逆波兰表达式,这两种形式根据操作符的位置我们又可以将波兰表达式称为前缀表达式,逆波兰表达式称为后缀表达式;
  • 前中后缀表达式之间的相互转换——我们在进行转换时需要通过表达式的形式来抓住对应的主体,在明确主体后,我们只需要移动操作符的位置,即可完成对应的转换;
  • 通过栈实现波兰表达式与逆波兰表达式——今天的重点内容我们还是以通过栈来实现波兰表达式与逆波兰表达式的求值。

通过今天的内容,我希望大家能够掌握波兰表达式与逆波兰表达式的手算求值过程以及手动进行相互转换的过程,而更注重实操的朋友能够通过今天的内容理清用栈实现波兰表达式与逆波兰表达式的算法逻辑并能够自己手搓一份代码进行验证。

今天的内容到这里全部介绍了,在下一篇内容中,我们将详细介绍如何通过代码实现表达式之间的相互转换,大家记得关注哦!喜欢博主作品的朋友还希望你能够点赞、收藏加评论,最后感谢各位的支持,咱们下一篇再见!!

发表评论:

Powered By Z-BlogPHP 1.7.3

© 2018-2020 有趣的地方 粤ICP备18140861号-1 网站地图