小学生四则运算(-) 递归实现
题目见邹欣老师的博客,简单地说是实现一个小学生四则运算的生成及求解程序。
花二十分钟写一个能自动生成小学四则运算题目的命令行 “软件”, 分别满足下面的各种需求
问题困难在对分数的处理上,在有真分数时更加麻烦了一点。
说实话,我真的没法在二十分钟内写完,目前简单的算一下至少也是有了五六个小时 -- 一个小时左右自己写了一个 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+2
与 2+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