【个人项目总结】C#四则运算表达式生成程序
S1&2.个人项目时间估算
PSP表格如下:
|
S3.优化程序
优化所耗费的时间大致占据代码复审和测试阶段的40%,具体时间参照上表
优化点:
A. 存储潜在的冗余表达式
在表达式重复性方面,笔者考虑采用牺牲空间换取时间的方式,使用Dictionary类管理程序生成表达式过程中产生的参考数据,具体数据模式如下:
<表达式结果,List<以此为结果的所有表达式>>
这里强调的所有有两点含义,一是指程序输出的表达式中,所有以键值为结果的表达式,而也包括所有与这些表达式重复的式子(即通过交换律可以互相转换)。
例如:
1
↓-----映射----->
{(1+7)÷(2×4)
(1×2)-(3-2)//这两个式子由程序生成并输出,它们有相同的结果但并不重复
(7+1)÷ (2×4)
(7+1)÷ (4×2)
(1+7)÷ (4×2)
(2×1)- (3-2)//这四个式子与前面两个构成了重复,它们在程序运行过程中产生但不输出,不过由于其值为1,所以也添加到此List中
就以实现这一点为目标,程序进行了两方面优化:
一是在生成表达式的过程中,同时生成与该表达式重复的所有式子并通过ref修饰的List<String>参数予以输出,一旦输出便添加到Dictionary中作为下一个表达式的参考;
另一则是以String的形式存储该表,而不是直接存储表达式对象,通过重写显示转换方法,实现Expression类与String类的快速转换
代码如下:
1 public static implicit operator Expression(String expStr)
2 {
3 String result = MathCore.calculate(expStr); //计算表达式结果
4 String pattern = "(\\+)||(-)|(×)|(÷)|";
5 Regex regex = new Regex(pattern);
6 MatchCollection list = regex.Matches(expStr); //利用正则匹配得出表达式中的运算符个数
7 int opnum = list.Count;
8 return new Expression(expStr, result, opnum);
9 }
这样,本程序回避重复性的算法思路就很清晰了:
生成表达式并获得结果——>通过交换律生成与之等价的表达式列——>通过结果进行查表,如果有该结果,则再查有无重复式子(如无该结果,则表达式必然有效,直接插表即可)——>如有,则放弃该表达式 ; 如无,则将该表达式的表达式列插入,继续生成直至达到规定数目
如何证明程序中真的没有出现重复的式子?
根据这个算法就很简单了,显然我们可以将输出的所有表达式和相应生成的重复表达式一并输出(本项目中该功能通过-i开启,通过CheckSame.exe对当前目录下Exercise.txt进行检测),因为输出的表达式也与自身重复,所以只有输出的表达式会在输出文件中出现两次,这样如果输出的表达式中有两个重复了,即一个式子与另一个式子生成的重复表达式相同,该表达式就会出现两次以上,这样就很容易通过查重程序检测出来了。只要理解的重复性规则正确,保证对于每个表达式充分生成了与之重复的所有表达式,该检测方法即可有效对结果的重复性进行检测。
B. 表达式结果伴随而表达式生成,与计算模块独立
当我得知,还需要为这个程序设计一个计算表达式结果并与答案表比对的模块时,我的第一反应是直接用这个计算模块去求生成的有效表达式的值,因为这样程序的编码,设计会简单很多。但是,相应的,生成一遍、再读一遍、再出栈入栈各种算一遍,耗费的时间资源就高了起来。此外,生成的表达式,其计算顺序未必与生成顺序一致。
比如:e => e1 - e2 => e1 - e5 - e6 => e3×e4 - e5 - e6 => 5×2 - 6 -1
如果按照生成时从后往前的顺序,先算e2:6-1,再算e1:5×2,最后算e1-e2: 5×2 - 6 -1 ,结果必然是错的;
为了解决此问题,我标准化了表达式的生成规范,即:对于产生的任何二元表达式,都为其在外面加上括号,通过括号,就完整的保留了整个表达式生成过程中各个部分的出现次序,运算的优先级也与生成次序统一到了一起,这样就可以做到生成一个子表达式时,迅速得到其结果并反馈给母表达式使用,且无优先级影响,不会出错。得到一元式或二元式的结果很容易,一级一级向上传递结果,最终结果计算就得到了简化,而试图进行括号匹配,或对原式一点点做拆分、优先级分析是很麻烦的(不过在计算模块中,我们仍然必须实现这一点),这样比产生运算式答案的效率上就大大提升了
C. 使用long而不是int
虽然本程序目标对象是小学生,不过这样随机产生的运算式仍有可能因为通分问题产生数值非常大的分子,这里引用我所在团队一位同学的论断
我们刚才提到了值域的扩大对于最终结果的最大值的影响是十分之大的,其能高达r^8倍。比如下面这个例子,如果我们的值域是20:
( 16'11/15 ÷ 17'4/19 ) × ( 6'1/2 ÷ 12'1/15 )
这里还有一个坑的地方,在于值域的上限。上面提到分子中最高有可能到r^8的水准是有计算依据的。我们设想存在这样一个数字,其分子接近r^2,分母接近r,但是分子与分母互质。设这个数为x,那么
x * x * x * x 完全可以达到r^8的数量级。按这样计算,如果使用int
定义分子分母的类型,那么只要 r 达到 15 ,就可能出现超过 int 型范围的数字,最后导致结果为 负数。
大家可以去参看他的博客(http://www.cnblogs.com/SivilTaram/p/4828591.html),在本次工程项目中,我很多想法都来自于他的启示。
因此,在分数类中,我们需要将分子分母定义为long,也即长整形,这样便可以使得程序支持的值域达到200以上,基本可以满足需求了。
由于Math类的很多方法是针对整形的,我们需要针对长整形重新写一下方法,如Abs取绝对值,Max取最大值,以及计算两个长整形数的GCD算法,这些笔者都整合到MathCore这个静态方法类里了,前面提到用于计算表达是值的calculate()方法自然也是该类的成员
D.计算模块的简化
数字表达式计算式是本项目里面另一个需要投入精力的地方,笔者自己写了一个计算程序,并非最优,但通过采用栈的数据结构,也做到了一定程度上对计算过程的简化(无中缀后缀表达式转换)
算法思路如下:首先针对本体的表达式规范(无负数,但有带分数,仅含四则运算符和括号),通过用正则表达式获得其各个元素,代码如下:
1 String pattern = "(\\d+'\\d+\\/\\d+)|(\\d+\\/\\d+)|(\\d+)|(\\+)|(-)|(×)|(÷)|(\\()|(\\))";//列举所有可能的操作数或操作符形式
2 Regex reg = new Regex(pattern);
3 exp = "(" + exp.Replace("\\s+", "") + ")";//通过加括号,我们可以保证任何计算过程回溯的终点为“(”,这有助于处理形式统一
4 MatchCollection ele = reg.Matches(exp);
然后通过建栈,逐一压入操作元素,对于不同操作符,作如下处理:
1.如遇到×
÷且后一个元素为操作数的话(即非“(”),取当前栈顶操作数与之运算并将结果压入栈中;
2.如遇到“)”则自后向前(由于栈是后进先出的)运算至栈顶出现“(”并将其取出
(注意考虑 -e1-e2 、-e1+e2的情况,自后向前运算时不仅要取运算符和前一个操作数,当运算为加减法时前一个操作符是否为-号也是需要去处理的)
取出并算出结果后,如前一个运算符(当前栈顶)为×
÷,则此结果与前一个操作数运算后,继续取栈顶,循环判断至前一个运算符为+-或"("时,将最终运算结果压入栈顶结束过程
生成时所加的括号规范作用在这里就体现出来了,对于该算法,计算出最后结果的时候,整个式子必须有至少一对括号才可以。
E. 除法减法有效性处理
本程序设计一大核心思想就在于尽可能的避免冗余性处理,对于除法或减法无效的情况(减法出现负数,除法整除或者分母为0)均可以通过调换操作数(减数和被减数,分子和分母)的位置进行处理,当然,对于操作数相等的情况应尽量避免
关于性能分析,因为VS2012为早期的桌面免费版(社区版?),似乎并没有这样的工具,所以只好借同学的VS生成了一张
S4 项目测试
首先分享程序本体及测试文件,重复性检测程序,在配有的Readme文档中,我作了相应的说明。(下载 密码:7ck5)
测试用命令行参数:(Defauts: -r 10 -n 10000 -o 3)
1.ExpressionOutputer.exe
Time Cost: 00:00:00.1730099
2.ExpressionOutputer.exe -e ./Exercise.txt -a ./Answer.txt (Depend on exercise file)
Time Cost(Defauts Setting): 00:00:05.0092865
3.ExpressionOutputer.exe -e ./Exercise2.txt -a ./Answer2.txt (Depend on exercise file)
Time Cost(Defauts Setting): 00:00:05.1052920
4.ExpressionOutputer.exe -r 1 -n 30
Time Cost: 00:00:00.0150009
5.ExpressionOutputer.exe -r 2 -n 500
Time Cost: 00:00:00.0160009
6.ExpressionOutputer.exe -r 3 -n 10000
Time Cost: 00:00:00.3540203
7.ExpressionOutputer.exe -r 5 -n 20000 -o 4
Time Cost: 00:00:00.4230242
8.ExpressionOutputer.exe -r 10 -n 100000 -i
Time Cost: 00:00:03.2331849
9.ExpressionOutputer.exe -r 10 -n 100000 -o 5 -i
Time Cost: 00:00:04.5312591
10.ExpressionOutputer.exe -r 255 -n 1000000
Time Cost: 00:00:12.8217333
-o与-i是笔者程序引入的两个新参数,均在readme中有所介绍,-o为限制操作符个数,-i为开启冗余表达式输出
存在问题:
1.程序对于输入处理,在报错机制上尚不完善,因此仍需规范输入,对于负数表达式的支持不完全(支持负数结果但不支持负操作数)
2.对于除法,实际上程序允许被除数与除数相等的情况,因此使得低范围下生成题目的数量和速度得到了大幅提升,对于除法表达式中必须为真分数这一规定的意义持疑问态度,依据博文中的规定:
- 真分数:1/2, 1/3, 2/3, 1/4, 1’1/2, …
实际上规定的是带分数,带分数是假分数的另一种表达形式,而假分数允许取值为1,并非特例
3.对于命令行参数的处理尚不完善,还有很多规范参数输入的地方没有做,如取值范围与生成表达式数目之间应当遵循的一定关系
由于时间原因这些问题遗留了下来,还请见谅
S5 收获与体会
通过本次的个人项目练习,我收获了很多有关C#的高级运用技巧,如运算符重载,传出参数,静态方法类,IO流,Diretionary类等等,也许是因为C#的语法特点与JAVA的面向对象思想十分相似,这门语言上手还是蛮快的。在设计项目,进行编程的时候充分考虑用户需求并试图做一些优化,这样的事情在上学期的OO课中已经得到了充分锻炼,因此一些处理方法,如方法正确性证明,前后置条件覆盖性分析,根据用户需求规划各个类的功能部,工厂模式等在软工这样的项目型作业中就能够学以致用。不过个人项目毕竟是个人项目,代码风格,可读性,以及团队协作等方面仍需通过结对项目和团队项目进行锻炼,笔者将会在后续的课程任务中继续努力,逐步达到并尝试超越 软件工程这门课程对于学生在能力上的要求。
以上 by kibbon