做一个科学计算器(一)

科学计算器是每一个手机都会自带的系统功能,各应用市场也会有数不清的各式各样的计算器。在使用这些计算器的过程中,你有没有想过当你输入一个式子时,程序是怎么运行的呢?如果说四则运算很简单,那么将其他的如指数运算、幂运算、阶乘等结合起来的式子呢?完整的实现一个计算器,其实可以从中学到很多我们可能平时不太会留意到的知识或者开发技巧。本系列将从理论到实践,从如何解析一个数学表达式到为表达式求值,最终实现一个科学计算器。实现后的效果如下:

view.gif

你可以扫以下二维码体验

view.png

在进入正文之前,我们一起来思考一下以下几种数学表达式,如果用javascript来求出表达式的运算结果,你会怎么用什么样的思路来实现?如果实现的宿主环境是微信小程序呢?

(1)简单的四则运算

 "1+2+3"
 "3 + 3 * 3"
 "4 + 5 / 2 * 2"
复制代码

(2)含括号的四则运算

 "3 * ( 2 + 6 )"
 "3 * ( 2 + 6 / 2) + 3"
复制代码

这简单,我左一个if..else...,右一个switch...case...,刷刷几下就能搞定,这尼玛说好的能学到很多东西呢? 不急,看下如果是以下两个字符串呢?

(3)含正负数的运算

"(1 + 2) * ( 2 + 10 / 4 * ( 9 - (5 + 1)))"
"-8 * 5 +++ 2"
复制代码

(4)含指数、幂、三角函数等运算

//其中min是求最小值,a√b是求b开a次方,cos求余弦值,log(a,b)是求以a为底数对于b的对数运算
"(1+9+(2+3)*3 + 2^3) *3 +++1 + min(2,2√(2+ 2  * 7)) --- 1 + 3% + 3√27 + 2^3 + cos(0) + log(2,8)"
复制代码

当我们看到前面两种式子时,第一反应通常会想到将表达式进行分析分割,然后按照特殊字符结合if..else...switch...case...等分支逻辑来实现。但实际上尽管这么简单的式子,真正按照这种思路实现起来一样会遇到很多棘手的问题。有的同学可能会想到使用javascript的evalnew Function来实现式子的求值。因为只要式子是符合javascript表达式语法的,都可以动态执行。那么如果式子不符合呢?如果是严格模式下呢?如果是小程序环境下呢(微信小程序不支持evalnew Function动态执行js)。哪怕是解决了这些问题,但当遇到以上类似(3)和(4)这种复杂的式子,那么我们将无从下手。

实际上,对一个数学表达式的求值是有比较成熟处理的方案的。掌握了其原理,以上这些式子都将变得没有想像的复杂。

poor.png

以下我们正式进入本系列文章,完整看完你将可能学到以下一些知识:

  • 数学表达式的前缀表达式(波兰表达式)、中缀表达式和后缀表达式(逆波兰表达式)
  • 调度场算法
  • 中缀表达式转前缀表达式和后缀表达式的方法
  • 前缀表达式和后缀表达式的求值
  • 将求值融合到表达式解析的过程中进行
  • 融合求值时对于剩余符号的处理技巧
  • 实现一个完整的计算器(可用于小程序)
  • 错误处理的一点技巧
  • 将计算器封装成一个可自定义符号的库并开源

本文先从理论开始,分别介绍几种表达式的特点、转换计算以及调度场算法。

1. 中缀表达式

中缀表达式就是平时我们使用的数学表达式的表示方法,因为操作符处于操作数中间而得名。如:

1+2+3
1 * (2 + 3)
复制代码

由于这种表达方式符合人们的普遍用法,因此绝大多数的程序语言使用这种方式来编写数学表达式。但是,中缀表达式对于计算机来讲是不容易被解析的。另外,中缀表达式的括号不可省略,需要通过括号将操作数与操作符括起来,指示运算的次序。

2. 前缀表达式(波兰表达式)

前缀表达式是操作符位于操作数前的一种表达方法。如1*(2+3)的前缀表达式为*1+23

2.1 中缀表达式转换前缀表达式的步骤

(1)初始化两个栈,分别为操作符栈S1,输出栈S2

(2)从右至左读取中缀表达式,读到的字符记为c

(3)如果c是个数字,则压入S2

(4)如果c是一个操作符,则进行如下比较:

a. 如果`c===')'`则将c压入S1
b. 否则,若`c==="("`,则循环弹出S1的符号压入S2,直至遇到`)`(`)`弹出但不压入栈)
c. 否则,若S1为空,或者S1栈顶元素为`)`则将c压入S1
d. 否则,获取S1的栈顶元素,如果**栈顶元素优先级不比c高**,则将c压入S1
e. 否则(即栈顶元素优先级比c高),将S1栈顶元素弹出并压入S2,转到c步骤,继续比较c与栈顶元素
复制代码

(5)转到(2)循环取出下一个元素,直到表达式的最左边

(6)将S1中剩余的操作符依次弹出并压入S2栈

(7)依次弹出S2中的元素,其结果即为前缀表达式

讲了以上的转换步骤后,我们对前面的例子1 * (2 + 3)以及一个更复杂的例子5*((3+2)*(6-10/5)-3)演练一下这个步骤。

注意:以下所有的例子均没有对式子的空格做处理,实际写代码还要处理空格

首先是 1*(2+3) (1)初始化两个栈S1 = [],S2 = []

(2)读取到),直接压入S1,结果为S1 = [')'],S2 = []

(3)读取到3,属于数字,直接压入S2,结果为S1=[")"],S2 = [3]

(4)读取到+,此时S1不为空,但栈顶元素为),走c步骤,压入S1,结果为S1=[")","+"],S2 = [3]

(5)读取到2,属于数字,压入S2,结果为S1=[")","+"],S2 = [3,2]

(6)读取到(,走b步骤,将+压入S2,)弹出,结果为S1=[],S2 = [3,2,"+"]

(7)读取到*,走c,结果为S1=["*"],S2 = [3,2,"+"]

(8)读取到数字1,压入S2,结果为S1=["*"],S2 = [3,2,"+",1]

(9)表达式读取完毕,将S1剩余元素依次弹出压入S2,结果为S1=[],S2 = [3,2,"+",1,"*"]

(10)依次弹出S2,得到最终结果*1+23

第二个例子5*((3+2)*(6-10/5)-3)比较复杂,我们使用一个表格来分析和跟踪整个过程

元素 操作符栈S1 输出栈S2 说明
) ) 空栈 匹配到),直接入S1
3 ) 3 数字,入S2
- ) - 3 操作符,S1栈顶为),-直接入S1
) ) - ) 3 ),直接入S1
5 ) - ) 3 5 数字,入S2
/ ) - ) / 3 5 操作符,S1栈顶为),/直接入S1
10 ) - ) / 3 5 10 数字,入S2
- ) - ) - 3 5 10 / 操作符,其优先级比栈顶元素 /低,/弹出并压入S2,之后S1栈顶为),-入S1
6 ) - ) - 3 5 10 / 6 数字,入S2
( ) - 3 5 10 / 6 - 左括号(,循环弹出S1压入S2,直到弹出)
* ) - * 3 5 10 / 6 - 操作符*,栈顶为)*入S1
) ) - * ) 3 5 10 / 6 - ),直接入S1
2 ) - * ) 3 5 10 / 6 - 2 数字,入S2
+ ) - * ) + 3 5 10 / 6 - 2 操作符,栈顶为),操作符+入S1
3 ) - * ) + 3 5 10 / 6 - 2 3 数字,入S2
( ) - * 3 5 10 / 6 - 2 3 + 左括号(,循环弹出S1压入S2,直到弹出)
( 空栈 3 5 10 / 6 - 2 3 + * - 左括号(,循环弹出S1压入S2,直到弹出)
* * 3 5 10 / 6 - 2 3 + * - 操作符,S1为空栈,*直接入S1
5 * 3 5 10 / 6 - 2 3 + * - 5 数字,入S2
完成 空栈 3 5 10 / 6 - 2 3 + * - 5 * 已读取完式子,将S1剩余元素依次弹出并压入S2

最终得到的结果是:* 5 - * + 3 2 - 6 / 10 5 3

以上即为数学表达式的前缀表达式以及中缀表达式到前缀表达式的转换方法,我们可以看到,前缀表达式的结果是没有括号的。以下有个例子可以尝试转换一下

'8-(6*8+(16-8)/4-(5-4)/2)*2-6'
//转换结果为
'- - 8 * + * 6 8 - / - 16 8 4 / - 5 4 2 2 6'
复制代码

2.2 前缀表达式的计算

前缀表达式的计算方法是:从结尾到开头读取前缀表达式,如果是数字,则将数字压入输出栈;如果是个符号,则弹出输出栈中的两个数字,与符号参与运算,得到的结果再压入输出栈。循环这个步骤,直到整个式子遍历完毕,最后弹出输出栈的元素,即为最终的结果。

比如以上8-(6*8+(16-8)/4-(5-4)/2)*2-6得到的前缀表达式- 8 - * + * 6 8 - / - 16 8 4 / - 5 4 2 2 6的运算步骤如下:

(1) 首先读取到的都是数字,压入输出栈得到 [6,2,2,4,5]

(2) 读到-,弹出两个数字运算,得到1,压入,结果为[6,2,2,1]

(3) 读到 /,弹出两数字运算,得到1,压入,结果为[6,2,0.5]

(4) 再得到三个数字,压入,结果为[6,2,0.5,4,8,16]

(5) 同理得到[6,2,0.5,4,8] -> [6,2,0.5,2] -> [6,2,1.5] -> [6,2,1.5,8,6] -> [6,2,1.5,48] -> [6,2,49.5] -> [6,99, 8] -> [6,-91] -> [-97]

(6) 弹出输出栈,得到-97

3. 后缀表达式(逆波兰表达式)

与前缀表达式正好相反,后缀表达式是操作符位置是处于操作数后面的表示方法。如1*(2+3)的前缀表达式为123+*。同样,后缀表达式是不需要括号的,中缀表达式转为后缀表达式时,不会保留括号。

3.1 中缀表达式转后缀表达式的步骤

(1)初始化两个栈,分别为操作符栈S1,输出栈S2

(2)从左至右读取中缀表达式,读到的字符记为c

(3)如果c是个数字,则压入S2

(4)如果c是一个操作符,则进行如下比较:

a. 如果`c==='('`则将c压入S1
b. 否则,若`c===")"`,则循环弹出S1的符号压入S2,直至遇到`(`(`(`弹出但不压入栈)
c. 否则,若S1为空,或者S1栈顶元素为`(`则将c压入S1

d. 否则,获取S1的栈顶元素,如果**栈顶元素优先级比c低**,则将c压入S1

e. 否则(即栈顶元素优先级比c高或相等),将S1栈顶元素弹出并压入S2,转到c步骤,继续比较c与栈顶元素
复制代码

(5)转到(2)循环取出下一个元素,直到表达式的最左边

(6)将S1中剩余的操作符依次弹出并压入S2栈

(7)将S2逆序转换后依次弹出S2中的元素,其结果即为后缀表达式

还是对以上两个例子1 * (2 + 3)5*((3+2)*(6-10/5)-3)演练一下这个步骤。

首先是1 * (2 + 3)

(1)初始化两个栈S1 = [],S2 = []

(2)读取到1,属于数字,入S2栈S1 = [],S2 = [1]

(3)读取到*,属于操作符,此时S1栈为空,所以入S1栈,S1 = ['*'],S2 = [1]

(4)读取到(,左括号,直接入S1栈,S1 = ["*", "("], S2 = [1]

(5)读取到2,属于数字,入S2栈,S1 = ["*", "("], S2 = [1, 2]

(6)读取到+,属于操作符,但S1栈顶为(,所以+直接入S1,S1 = ["*", "(", "+"], S2 = [1, 2]

(7)读取到3,属于数字,入S2,S1 = ["*", "(", "+"], S2 = [1, 2, 3]

(8)读取到),循环弹出S1中的操作符,直到遇到(S1 = ["*"], S2 = [1, 2, 3, "+"]

(9)表达式读取完毕,将S1剩余元素依次弹出压入S2,``S1 = [""], S2 = [1, 2, 3, "+", ""]`

(10)逆序依次弹出S2,得到结果123+*

5*((3+2)*(6-10/5)-3) 这个例子依然使用一个表格来分析和跟踪匹配转换的过程

元素 操作符栈S1 输出栈S2 说明
5 空栈 5 数字,入S2
* * 5 操作符,S1为空,直接入S1
( * ( 5 左括号,入S1
( * ( ( 5 左括号,入S1
3 * ( ( 5 3 数字,入S2
+ * ( ( + 5 3 操作符,栈顶为左括号,直接入S1
2 * ( ( + 5 3 2 数字,入S2
) * ( 5 3 2 + 右括号,循环取出S1的栈顶元素,直到遇到左括号
* * ( * 5 3 2 + 操作符,栈顶元素为左括号,直接入S1
( * ( * ( 5 3 2 + 左括号,直接入S1
6 * ( * ( 5 3 2 + 6 数字,入S2
- * ( * ( - 5 3 2 + 6 操作符,栈顶为左括号,直接入S1
10 * ( * ( - 5 3 2 + 6 10 数字,入S2
/ * ( * ( - / 5 3 2 + 6 10 操作符,且优先级比栈顶元素高,直接入S1栈
5 * ( * ( - / 5 3 2 + 6 10 5 数字,入S2
) * ( * 5 3 2 + 6 10 5 / - 右括号,循环取出S1的栈顶元素,直到遇到左括号
- * ( - 5 3 2 + 6 10 5 / - * 操作符且优先级比栈顶元素低,故弹出栈顶元素压入S2
3 * ( - 5 3 2 + 6 10 5 / - * 3 数字,入S2
) * 5 3 2 + 6 10 5 / - * 3 - 右括号,循环取出S1的栈顶元素,直到遇到左括号
完成 空栈 5 3 2 + 6 10 5 / - * 3 - * 表达式读取完成,将S1的剩余元素依次弹出压入S2

将以上结果逆序后再依次弹出得到结果5 3 2 + 6 10 5 / - * 3 - *

3.2 后缀表达式的求值

后缀表达式的计算方法是:从开头到结尾读取后缀表达式,如果是数字,则将数字压入输出栈;如果是个符号,则弹出输出栈中的两个数字,与符号参与运算,得到的结果再压入输出栈。循环这个步骤,直到整个式子遍历完毕,最后弹出输出栈的元素,即为最终的结果。

如以上5*((3+2)*(6-10/5)-3)的后缀表达式5 3 2 + 6 10 5 / - * 3 - *,其计算得到的结果过程如下:

[5,3,2] -> [5,5] -> [5,5,6,10,5] -> [5,5,6,2] -> [5,5,4] -> [5,20] -> [5,20,3] -> [5,17] -> [85]

弹出输出栈得到结果为85

4. 小结

以上即为数学表达式的前中后缀表达式以及中缀表达式转换为前缀表达式和后缀表达式的方法。此外前缀表达式和后缀表达式也可以互相转换,前缀表达式和后缀表达式也可以转为中缀表达式,本文不做深入讨论。但可以稍微做下分析。 其实三种表达式本质上是对一棵二叉树的三种遍历方式,以5*((3+2)*(6-10/5)-3)为例,其可以表示为以下二叉树

tree.png

我们对这棵树进行三种遍历,结果如下:

  • 先序遍历: * 5 - * + 3 2 - 6 / 10 5 3
  • 中序遍历: 5 * 3 + 2 * 6 - 10 / 5 - 3
  • 后序遍历: 5 3 2 + 6 10 5 / - * 3 - *

我们可以发现,其实先序遍历对应的正是前缀表达式,而后序遍历对应的是后缀表达式。由于中缀表达式是不能省略括号的,所以在遍历的时候需要在适当的时机补上括号,那么什么时候补上呢?观察一下上面二叉树,如果我们在中序遍历的时候在包含子树的节点上添加一个括号可以得到(5*((3+2)*(6-(10/5))-3)),如果去除根节点的以及对符号进行优先级的判断的话,那么就可以得到原本的中缀表达式(不去除,计算顺序也能保证正确)。所以,有了以上的基础,我们就可以知道,除了上述的转换方法,如果将中缀表达式表示为一棵树的话,那么对其进行不同的遍历也是能够得到另外两种表达式的。同时,对三种表达式进行相互的转换也就有了思路。关于转换的过程,你可以看下这篇文章:前缀、中缀、后缀表达式转换详解

5. 调度场算法

前面我们讲解了三种表达式以及如何将中缀表达式转为前缀表达式和后缀表达式。其实这种转换方法可以认为是调度场算法中针对四则运算(二元运算符)的一种特殊处理。但实际情况下,一个数学表达式可能不仅仅包含二元运算符,还有一元运算符(如正负号)和函数表达式(如min(1,2,3)求最小值)。而一元运算符又可以有前缀运算符(如-)和后缀运算符(如阶乘!)。因此,仅仅使用以上的转换方法是不足以满足需求的。这就需要用到完整的调度场算法。以下就来学习一下调度场算法。

5.1 概念

调度场算法是一种专门用于将中缀表达式转换为后缀表达式(逆波兰表达式)的经典算法,由艾兹格·W·迪科斯彻(Edsger Wybe Dijkstra,像是不小心擦了一下键盘打出来的名字)引入,因为其操作类似于火车的编组场而得名。

5.2 完整的算法步骤

(1)初始化一个操作符栈S和一个输出队列Q

(2)从左到右读取表达式,读取到的字符记为c

(3)如果c表示一个数字,那么将其添加到输出队列Q中;如果c表示一个函数,那么将其压入栈S当中;如果c表示一个函数参数的分隔符(例如一个半角逗号 , ):从栈当中不断地弹出操作符并且放入输出队列中去,直到栈顶部的元素为一个左括号为止。如果一直没有遇到左括号,那么要么是分隔符放错了位置,要么是括号不匹配。如果c表示一个操作符,那么进行如下的比较:

  • 如果c是一个左括号,那么就将其压入栈当中。
  • 否则,如果c是一个右括号,需要做如下操作:
    • 从S不断地弹出操作符并且放入Q中,直到S顶部的元素为左括号为止
    • 左括号只是从S的顶端弹出,但不放入输出队列Q中去
    • 如果此时S的栈顶为一个函数,则将其放到队列Q中去
    • 如果S已为空,但是没有遇到左括号,说明括号不匹配
  • 如果c是一个操作符,继续做如下比较:
    • 如果S为空,则将c压入S
    • 否则,将S中的栈顶元素记为o,如果c是左结合性的并且优先级小于等于o,或者c是右结合性的并且优先级小于o,那么将o压入Q,循环这个步骤,直到条件不成立。最后将c压入S。

(4)读取结束后,如果S还有操作符,如果S的栈顶是个右括号,那么说明表达式括号不匹配。否则,逐个弹出S的操作符压入Q队列,如果弹出的是左括号,说明表达式有多余的左括号。

(5)退出算法,Q队列即为转换的结果

以上即为调度场算法完整的步骤,内容不长,但是展开来加上错误处理的话其实内容也很多。同样以两个例子(一个正确式子,一个错误式子)来演练下这个算法

-2 * ( 5 * ( 6 - 4 )23 + min(2+3,3,4)) / 2

元素 操作符栈S 输出队列Q 说明
- - 运算符,S为空,压入
2 - 2 数字,进入Q
* -* 2 运算符,*优先级别-高,入S
( -*( 2 左括号,直接入S
5 -*( 25 数字,入Q
* -( 25 运算符,S栈顶是(,*入栈S
( -(( 25 左括号,直接入S
6 -(( 256 数字,入Q
- -((- 256 运算符,S栈顶是(,-入栈S
4 -((- 2564 数字,入Q
) -( 2564- 右括号,循环弹出S元素压入Q,直到遇到左括号并将左括号弹出不压入Q
^ -(^ 2564- 运算符,且^优先级比*高,^入S
2 -(^ 2564-2 数字,入Q
^ -(^^ 2564-2 ^是右结合性,且与S栈顶元素^优先级相同,则^直接入S
3 -(^^ 2564-23 数字,入Q
+ -*(+ 2564-23^^* 运算符,且+的优先级比S栈顶元素^优先级低,循环弹出S栈顶压入Q,直到栈顶元素优先级比+低,将+压入S
min -*(+min 2564-23^^* min是一个函数,直接压入S
( -*(+min( 2564-23^^* 左括号,直接入S
2 -*(+min( 2564-23^^*2 数字,入Q
+ -*(+min(+ 2564-23^^*2 运算符,S栈顶是(,-入栈S
3 -*(+min(+ 2564-23^^*23 数字,入Q
, -*(+min( 2564-23^^*23+ 函数参数分隔符,不断弹出S元素,直到遇到左括号
3 -*(+min( 2564-23^^*23+3 数字,入Q
, -*(+min( 2564-23^^*23+3 函数参数分隔符,不断弹出S元素,直到遇到左括号
4 -*(+min( 2564-23^^*23+34 数字,入Q
) -*(+min 2564-23^^*23+34 右括号,循环弹出S元素压入Q,直到遇到左括号并将左括号弹出不压入Q
) -* 2564-23^^*23+34min+ 右括号,循环弹出S元素压入Q,直到遇到左括号并将左括号弹出不压入Q
/ -/ 2564-23^^23+34min+ 运算符,且/优先级等于*优先级,循环弹出S栈顶压入Q,直到栈顶元素优先级比/低,将/压入S
2 -/ 2564-23^^*23+34min+*2 数字,入Q
end 2564-23^^*23+34min+*2/- 读取完毕,逐个弹出S的操作符压入Q队列

最后输入队列Q,得到2564-23^^*23+34min+*2/-即为转换的结果,我们尝试对这个后缀表达式进行求值,得到以下步骤:

2 5 6 4->2 5 2->2 5 2 2 3->2 5 2 8->2 5 256->2 1280->2 1280 2 3->2 1280 5->2 1280 5 3 4 ->2 1280 3(这里函数求值,我们并不知道需要传多少个参数,先假设我们已经知道)->2 1283 ->2575 -> 2 2576-> 1283 -> -1283

通过与中缀表达式的计算结果比较验证为一致的结果。

以上是对一个正常的式子做转换,下面对一个错误的式子2*(8/(4+1)+5来做一下演练。这个式子缺少了一个右括号。过程如下:

元素 操作符栈S 输出队列Q 说明
2 2 数字
* * 2 运算符,S为空,直接压入S
( *( 2 左括号,直接入S
8 *( 2 8 数字
/ *(/ 2 8 运算符,S栈顶为左括号,直接入S
( *(/( 2 8 左括号,直接入S
4 *(/( 2 8 4 数字
+ *(/(+ 2 8 4 运算符,S栈顶为左括号,直接入S
1 *(/(+ 2 8 4 1 数字
) *(/ 2 8 4 1 + 右括号,循环弹出S直到遇到左括号
+ *(+ 2 8 4 1 + / 运算符,且优先级比/低,将S栈顶压入Q,循环比较直到栈顶元素优先级比+低或为左括号
5 *(+ 2 8 4 1 + / 5 数字
end * 2 8 4 1 + / 5 + 读取结束,循环弹出S,由于此时会弹出左括号,说明式子中有多余的左括号,即式子错误

结果:式子解析错误,有多余的左括号

6. 总结

从以上对各个知识点的讲解中,我们可以描述出一个数学表达式应该具有的一些属性或者特性

(1)表达式只能包含定义的字符(数字、运算符、括号、函数以及自定义的一些扩展运算符等)

(2)所有的操作符需要知道其计算的优先级以及结合性

(3)运算符的处理方法

(4)针对一个函数(包括运算符处理方法)需要知道其传递的参数个数

(5)运算符属于几元运算符(如+可以是一元,也可以是二元)

在明确了以上的一些属性后,我们就可以结合表达式的转换方法对一个表达式进行转换并求值,从而实现一个既可以是简单的四则运算,也可以是很复杂的混合计算器。当然,也就能解决像小程序下限制eval等功能对于数学表达式的求值问题。从下一篇文章开始,我们借助本文的知识和算法一步一步实现一个复杂计算器。实现的过程会从简单到复杂分为多个版本进行迭代(基于后缀表达式)。

下一篇:做一个科学计算器(二)

Guess you like

Origin juejin.im/post/7034690927763390500