小学生四则运算(-) 递归实现

题目见邹欣老师的博客,简单地说是实现一个小学生四则运算的生成及求解程序。

花二十分钟写一个能自动生成小学四则运算题目的命令行 “软件”, 分别满足下面的各种需求

问题困难在对分数的处理上,在有真分数时更加麻烦了一点。

说实话,我真的没法在二十分钟内写完,目前简单的算一下至少也是有了五六个小时 -- 一个小时左右自己写了一个 Fraction 类及其运算,然后觉得可能使用成熟的库更加方便(如果本程序主要是要让自己独立完成库的设计的话,我的选择也许就是有悖于要求了,但要是按照张老师的说法,最终呈现给用户的形式多样,可以是网页应用等,那么,选取他人的库进行修改肯定是很不错的想法,毕竟整个项目有许多工作要做),所以在 github 上寻找了半个小时的库,发现计算分数的并不多。这里选取了一个捷克友人的库进行扩展。在写这篇文档的时候又考虑了一下使用“逆波兰式”实现的可能性,发现也许使用逆波兰式更加方便简单一些,会另开一篇文章介绍。

分析

1.给定一个表达式字符串进行求解,首先最好是对输入进行一些处理(可以很有效地降低后续处理的难度),比如说将“减号”与“负号”,“除号”与“分号”区分对待,可能省略的“乘号”,过多的“正负号”,可能拥有的扩展运算 **(或 ^)、!、带分数等。(当然,如果真的是小学生四则运算可能没这么麻烦,但是需求的不确定性确实存在,比如说,“来来来,加个阶乘运算吧 ...”),对于减法我们将其视为加法(2-1 => 2+(-1))。

举些例子(为不引起混淆,使用 # 表示“除号”,/ 表示 “分号”,' 表示带分数):


+1*2        =>      (1)*2     //正号
-1+2      =>      (-1)+2       //负号
3*-2        =>      3*(-2)     //负号
3+-2        =>      3+(-2)         //两个或多个加减号
(1+2)(3+4)  =>  (1+2)*(3+4)     //省略的乘号

2**3        =>      2^3         //求幂


2!3          =>      (2!)*3     //阶乘

3#1/2         =>    3#(1/2)     //除去分数
1'1/2       =>      (1+1/2)     //带分数

...

2.将处理过的表达式进行求值,这里就可以使用“逆波兰式”了,但目前的实现是使用“递归”:

基本想法:按运算符优先级依次进行处理,() > ! > / > ^ > *# > +(我们没有减号),其中我们将 /(分号)优先级定义地很高,比如说 2^1/2 => 2^(1/2)


/* 伪代码 */

parse(s:String) {

    // 1. Extract all parentheses into TokenLists
    extractParentheses();

    // 2. Convert operator tokens to operators, including their arguments
    //    in the correct order ^ * / + -
    extractOperator(TokenOperatorFactorial.class);
    extractOperator(TokenOperatorPower.class);
    extractOperator(TokenOperatorDivideFraction.class); //addby miaodx to first deal with fraction
    extractOperator(TokenOperatorMultiply.class);
    extractOperator(TokenOperatorDivide.class);
    extractOperator(TokenOperatorModulo.class);
    extractOperator(TokenOperatorAdd.class);
    extractOperator(TokenOperatorSubtract.class);
}

详细的代码可以参见 TokenList.java,其中,处理括号以及处理操作符都包含了对 parse 的递归调用,很明显的是递归存在栈溢出的可能,但当算式不至于太长时还是比较安全的。

举例(以字符串表示的 SyntaxTree 的形式给出,更加直观一些,其实另一个主要原因是原库给出了实现):

2*(3+4)#1/2+3'2/3-5#(6-2)。在预处理后变为,
2*(3+4)#(1/2)+(3+(2/3))+(-5#(6+(-2))),先处理 () ->

取出带有()的部分:(3+4),(1/2),(3+(2/3)),(6+(-2)),对这几部分分别处理:

(3+4)   => ADD{3,4}
(1/2)   => FRACTION{1,2}
(3+(2/3)) => (3+FRACTION{2,3})  => ADD{3,FRACTION{2,3}}
(-5#(6+(-2)))   => (-5#ADD{6,-2}) => DIV{-5,ADD{6,-2}}

所以原式变为 2*ADD{3,4}#FRACTION{1,2}+ADD{3,FRACTION{2,3}}+DIV{-5,ADD{6,-2}},处理 * =>

2*ADD{3,4} =>   MUL{2,ADD{3,4}}

所以原式变为 MUL{2,ADD{3,4}}#FRACTION{1,2}+ADD{3,FRACTION{2,3}}+DIV{-5,ADD{6,-2}},处理 # =>

MUL{2,ADD{3,4}}#FRACTION{1,2}   => DIV{MUL{2,ADD{3,4}},FRACTION{1,2}}

所以原式变为 DIV{MUL{2,ADD{3,4}},FRACTION{1,2} }+ADD{3,FRACTION{2,3}}+DIV{-5,ADD{6,-2}},处理 + 最终得到:

ADD{ADD{DIV{MUL{2,ADD{3,4}},FRACTION{1,2}}, ADD{3,FRACTION{2,3}}}, DIV{-5,ADD{6,-2}}}

计算求值的话可以同步进行。这样有些乱,我们给出一张图来看:

小结

1.对输入的预处理非常重要,处理好的规整的输入可以有效地减少后续的工作量,举个例子 (1+2)(3+4)3++-+2 进行预处理后变为 (1+2)*(3+4)3+(-2),这比在 parse 中判断 ( 前面是不是 )+- 前面是不是还有运算符能有效地降低编码难度和出错率。

当然,输入的预处理也是有代价的,多趟对字符串的操作特别是配合着正则表达式(应该只能使用正则表达式)会比较费时,如果对时间要求过高也许会出现一些瓶颈。

2.“递归”方法,因为选用的库就是使用的递归实现,所以就先这样吧,至少没有太多需求说计算长达上百个字符长度的四则运算。

3.SyntaxTree,得到语法树除了使结果看着“很科学”之外,还能顺便解决程序一次运行生成的题目不能重复,即任何两道题目不能通过有限次交换+和×左右的算术表达式变换为同一道题目的问题。

举例:

不进行处理的语法树:
1+2+3 => ADD{ADD{1,2},3}
2+1+3 => ADD{ADD{2,1},3}
3+(2+1) => ADD{3,ADD{2,1}}

1+3+2   => ADD{ADD{1,3},2}

我们想让三者相同且与第四个区分开来,只需对语法树进行简单的处理即可,比如生成语法树时左右内容按照字符串大小排序(我们的语法树是用字符串表示的)。

那么 1+22+1 都将变为 ADD{1,2} 同理,其余也是如此,这样便可保证相同的题目生成的语法树是相同的。

TODO

1.1.3^1/2 =>

MATH ERROR:
    Can't calculate fractional power.

这个问题是可以理解的。

2.运行时间,由于使用的是递归的方式进行计算,当算式过长时效率会很有问题,举个例子,在我的机子上计算

"53*7+1-54*441-9+33/4+4/32-6*31/4/3/6-5-9-5-6-9/84-9-58+5/2/2-7-5-9/5+63/3/1*6/3*4-1-2/8/1+4/114/8*3/6/5-2*9*2-635/2/9-2*8*3*88"

(字符串长度为 126,其实计算没有太多)的结果:

Token list:
[53, *, 7, +, 1, +, -54, *, 441, +, -9, +, (, (, 33, ), /, (, 4, ), ), +, (, (, 4, ), /, (, 32, ), ), +, -6, *, (, (, 31, ), /, (, 4, ), ), /, (, (, 3, ), /, (, 6, ), ), +, -5, +, -9, +, -5, +, -6, +, -1, *, (, (, 9, ), /, (, 84, ), ), +, -9, +, -58, +, (, (, 5, ), /, (, 2, ), ), /, 2, +, -7, +, -5, +, -1, *, (, (, 9, ), /, (, 5, ), ), +, (, (, 63, ), /, (, 3, ), ), /, 1, *, (, (, 6, ), /, (, 3, ), ), *, 4, +, -1, +, -1, *, (, (, 2, ), /, (, 8, ), ), /, 1, +, (, (, 4, ), /, (, 114, ), ), /, 8, *, (, (, 3, ), /, (, 6, ), ), /, 5, +, -2, *, 9, *, 2, +, -1, *, (, (, 635, ), /, (, 2, ), ), /, 9, +, -2, *, 8, *, 3, *, 88]

Building syntax tree...

Syntax tree:
ADD{ADD{ADD{ADD{ADD{ADD{-1,ADD{ADD{ADD{-5,ADD{-7,ADD{ADD{-58,ADD{-9,ADD{ADD{-6,ADD{-5,ADD{-9,ADD{-5,ADD{ADD{ADD{ADD{-9,ADD{ADD{1,MUL{53,7}},MUL{-54,441}}},FRACTION{33,4}},FRACTION{4,32}},MUL{-6,FRACTION{FRACTION{31,4},FRACTION{3,6}}}}}}}},MUL{-1,FRACTION{9,84}}}}},FRACTION{FRACTION{5,2},2}}}},MUL{-1,FRACTION{9,5}}},MUL{4,MUL{FRACTION{6,3},FRACTION{FRACTION{63,3},1}}}}},MUL{-1,FRACTION{FRACTION{2,8},1}}},MUL{FRACTION{FRACTION{3,6},5},FRACTION{FRACTION{4,114},8}}},MUL{2,MUL{-2,9}}},MUL{-1,FRACTION{FRACTION{635,2},9}}},MUL{88,MUL{3,MUL{-2,8}}}}

耗时:33077毫秒

330 秒,这是无法忍受的,所以,最好是转化为“逆波兰式”进行计算处理。


Good Luck & Have Fun