数学表达式计算

BUAA 2024 OO Unit1
Unit1 概览
Unit1的核心任务是进行数学表达式的化简,在保证正确性的前提下以化简后字符串的长度作为性能的评价依据。程序设计的目的旨在用自动化的方法把人从丑陋、易错的多项式展开、求导中解放出来,实现一个简单的数学计算器。
Hw1 ——万丈高楼平地起
读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的单变量表达式,输出恒等变形展开所有括号后的表达式。
1 | input: |
难点分析
Unit1 的核心问题是数据结构的设计,即采取什么样的数据组织方式,可以有效地进行表达式的解析、化简、输出。不论进行怎样的迭代,数学表达式始终在最基本的加减乘除四则运算的框架下,因此最直观的方法就是根据运算的优先级分层组织。结合教程给出的示例,便很自然的采取表达式、项、因子三级体系来存储数学表达式。
表达式是项的集合,项之间具有加减的关系。
项是因子的集合,因子之间具有相乘的关系。
因子是最基本的微元,具有常数因子、变量因子、表达式因子(括号提高了表达式的优先级)等基本类型。
表达式存储
Expr
类的属性是指数和存放Term
的容器。Term
类的属性是存放Factor
的容器和项的系数。尽管把不同类型的Factor
统一处理,似乎会提出更高的要求,但能够把复杂度拆分下放,降低Expr
类的负担,避免扩展时拆东墙、补西墙、丢三落四。(但本着屎山最多堆三周的原则,我在Term
中将不同类型的Factor
分类存储,导致后期维护头重脚轻,相当困难)
因为因子具有不同的类型(且可能会扩展),因此Factor
宜作为一个接口来实现,对不同类型的因子分别建类(表达式因子本质上就是表达式加括号,可用Expr
类直接替代),实现在Term
中需要调用的方法即可。
我
自作聪明地预判后面会增加多变量的迭代,所以在函数因子中额外增加了变量名称、变量指数的属性,导致后续的求导、同类项合并、函数代入不得不全部实现了多变量的功能,复杂度飙升:cry::cry::cry:
表达式解析
在建立了整体的框架后,递归下降便是解析表达式的自然选择。
解析表达式时,parseExpr
方法只根据表达式中的+
-
符号来组织通过parseTerm
方法获得的项。
解析项时,parseTerm
方法只根据项中的符号来组织通过parseFactor
方法获得的因子。
解析因子时,parseFactor
方法只根据不同类型因子的标识符(括号、变量、常数)来生成因子。
我们通过分层处理,将复杂的问题简化,提高了代码的可读性和可扩展性。
表达式化简
表达式化简的核心在于多项式相乘和同类项合并,这个部分也是最影响算法时间复杂度的地方。由于喜欢自己摸索(碰壁),没有阅读过任何 OO 相关的博客,所以这个部分采取的是很朴素的模拟人类计算过程的方法。
在多项式相乘上,我并没有专门设计一个多项式类来处理,而是通过Expr
类中的addTerm
方法来实现。我们知道,多项式相乘
实际上指的是Term
中表达式因子的运算。因为多项式相乘的结果是个表达式,所以在解析项的环节,我只是把这些表达式因子存储起来,在把Term
添加到Expr
的环节中才把它们展开。
在addTerm
方法中,首先建立一个空表达式result
,将Term
中的非表达式因子整合成项添加到result
中。接着再建立一个空表达式temp
,同时遍历下一个表达式因子中的每个项与result
中的每个项,将二者相乘得到的项添加到temp
中。待下一个表达式因子遍历完毕后,将result
指向的对象改为temp
指向的对象,以此类推即可实现多项式相乘。
在同类项合并方面, Hw1 中只需在Term
类中实现判断变量名称及其指数是否相同的方法即可。
需要注意的是,在向
temp
中添加项时务必同时进行合并同类项以降低容器大小,否则就会空间复杂度爆表。若不同时合并同类项时,仅就能展开出 项,超出堆空间的限度。
表达式输出
在三级存储的整体框架下,表达式输出与表达式解析采取同样的思路。
输出表达式时,Expr
的toString
方法只根据项之间的关系来组织调整Term
的toString
方法获得的项字符串。
输出项时,Term
的toString
方法只根据项中的因子情况来组织调整Factor
的toString
方法获得的因子字符串。
输出因子时,不同类型的因子根据自身的特点,实现相应的toString
方法即可。
需要注意的是,我们的
toString
方法实际上是重写了Object
类的toString
方法。我们在 idea 的调试中看到的信息,实际上就是调用toString
方法的结果。倘若toString
方法对表达式、项、因子的内容有所修改(比如排序),可能会造成调试和直接运行结果不一致的现象。
符号处理
在整个Unit1中,输入的表达式都存在着多重的符号。即表达式中的第一个项前可有+
-
,项中的第一个因子前可有+
-
,常数因子自身也可带+
-
。对这些多余的符号进行正确的处理是我们解析表达式的前提。
有同学采取了添加零的方法来处理,但这会增加表达式化简的复杂度;考虑到额外的正负号也是按照层次生成,也有同学采取了递归下降的方法来处理,但这也会显著为后续的层层处理带来麻烦。我在这块耍了一个小聪明。
我们注意到,不论是表达式中的正负号还是因子中的正负号,其最后影响到的都是项的正负。因此我们可以把正负作为每一项的特性,而不看作项与项之间的关系或者因子的特点。因此,解析表达式时,我们可以记录读取到的-
数量,据此判断项的正负需要颠倒,解析因子时亦然。这样就巧妙地规避了额外符号的问题,哪怕输入不当人一直叠加正负号也没问题。
Hw2 ——函数代换伤不起
在 Hw1 的基础上,增加了支持嵌套括号、自定义函数、指数函数的新内容。
1 | input: |
嵌套括号
在Hw1中,我们已经实现了多项式相乘,其核心就是将项中的表达式因子展开,生成表达式。而嵌套括号的内在意义也就是表达式因子中含有表达式因子,这意味这我们处理括号的方式没有改变,仍然是表达式因子的展开,只不过要先展开表达式因子内部的表达式因子。因此,很自然地,我们采取递归的方式处理嵌套括号。
在 Hw1 addTerm
方法的基础上,我们只需对原有的表达式因子进行调整。对于每一个表达式因子,建立一个空的表达式tempExpr
,遍历其中的项并递归调用addTerm
方法添加到tempExpr
中。通过addTerm
表达式因子展开的功能,我们得到一个不含表达式因子的表达式因子tempExpr
,那么便可以接着采用 Hw1 的方法,将tempExpr
与result
相乘。
这种递归看起来很玄乎,让人难以琢磨,但实际上我们只需关注递归的功能和出口便可以轻松下手。
addTerm
的功能就是将项中的表达式因子展开,出口就是最内层的项不含表达式因子无需展开。这样,我们不关注实现的细节过程,而把这个还没写完的函数当作已经写好的具有特定功能的函数去用,复杂递归便可手到擒来。
指数函数
指数函数本身的实现比较简单,新建一个Exp
类,使其具有Expr
属性作为指数,同时实现Factor
接口使其成为因子即可。
指数函数的难点在于合并同类项。在Term
层面上,我们需要额外判定两个项是否具有指数函数(若有,是否相同);在Exp
层面上,我们需要判断其指数表达式是否相同,这就要求我们实现Expr
层面判断是否相同(可以通过相减是否为零来判断),而这又将递归地去在Term
层面判断同类项,直至项中不含Exp
的出口。
这里同样采取了递归下降的算法,但在实现了嵌套括号之后,
Exp
的问题实际上已经不算困难。
自定义函数
自定义函数的难点在于函数代入。一个很自然的想法是字符串替换,即在预处理阶段直接用实参去替换函数表达式中的形参,得到代换后的表达式再做计算。鉴于我已经实现了多变量的功能,所以我很自然地抛弃了字符串替换的方法,选择了对象替换,即在表达式解析阶段把实参作为表达式因子对象去替换函数表达式中的变量因子对象。于是,很自然地,我出bug了…
Bug 1
Bug 产生原因——函数代入错误
举个例子:
1 | 1 |
在将f(x,1)
中的第一个实参x代入时,由于x处在形参y的位置,故代入函数表达式得到(1-x)*x
。而在代入第二个实参时,由于1处在形参x的位置,第二个形参x与第一个实参x名称相同,导致无法区分形参和实参,将1代入后遂得到了错误的表达式(1-1)*1。
其实我最初考虑过这样的问题,并通过对变量设置isPara
属性来判断是否为形参,但在我整个代码的框架下,设置这样的属性并不能解决问题。
首先,在 Term 层面,我有一个HashMap<String,Monomial>
属性,通过变量名称索引变量因子,这也就意味着项中无法同时存放两个名称相同的形参和实参。如果仅仅是这,那倒还不是问题,因为我可以把实参作为表达式因子存入项中,来避免无法存储同名形参和实参的问题。
但遗憾的时,由于我采用的是加入即化简的策略,一旦替换就会化简表达式,把实参合并入形参中,使得isPara
这一属性在变量名称一致的情况下失效,导致错误。
Bug 修复方案——区分变量名称
由上述分析可以看到,项中无法同时存放两个名称相同的形参和实参,是一个死结。在不大改架构的前提下,修复bug的唯一方案就是区分形参和实参的变量名称。
(由于实参实际上只含有变量x,所以也可以采取先替换形参x再替换其他形参的策略来避免这个问题。但作为一个完美主义者,加之我的整个架构都是为了处理多变量而设计的,所以我没有采用这种投机的方法:dog::dog::dog:)
因此,我选择采用大小写替换的方式来区分形参实参。即在解析函数表达式的环节,将对应的形参换为大写的方式存储(实际上只要实参的变量名称和形参不一样都可以)
这个bug表面上看起来只是一个疏忽,但反映出的却是功能耦合的问题。对象替换看似清晰直观,但却要在表达式解析阶段进行处理;字符串替换看似繁琐复杂,却能够实现函数代入和表达式解析的分离。对扩展开放,对修改封闭,因为你无法预料到原有架构会带来什么奇奇怪怪的问题。
Hw3——指数优化玩不起
在 Hw2 的基础上,允许了函数表达式调用其他已定义的函数,新增了求导的功能。
1 | input: |
函数调用
函数表达式调用其他已定义的函数实现起来比较简单。
在我的对象替换的实现中,获取函数表达式和解析表达式是一体的,所以几乎不需要更改什么,只要确保获得一个函数之后立刻放入函数集合中供表达式解析使用即可。
在字符串替换的实现中,也只需要对函数表达式进行函数代入的字符串替换过程即可。
求导实现
求导本身只是增添了一种运算方式,并没有加入新的基础类型,所以只需要在表达式、项、因子三级按照求导公式新增求导的方法,上层递归调用下层,注意做好深拷贝即可。
输出长度优化
在长度的优化上,幂函数的处理相对简单,注意x
比x^1
短即可。而exp
输出形式的选择则相对复杂。
exp 长度优化
1 | exp((10+20*x)) |
从上面这个例子可以看到,优化exp
长度的出发点就是公因数的提取,使得较长的数字只以指数的形式出现一次,从而缩小长度。
但很显然的是,提取出公因数未必会变短(取决于exp
内项的多少、公因数本身的长度和提取出公因数后每个项的缩短程度)。
提取出最大公因数未必会比提取其他因子短(取决于公因数本身的长度和提取出公因数后每个项的缩短程度)。
同时,我们也要考虑形式化表述要求对字符串长度带来的影响,比如exp((2*x))
写成exp(x)^2
可免除内部括号和乘号。
因此我们需要对不同公因数下输出的长度作以比较再行决定输出的形式。
在这里,我选择了遍历最大公因数与最大公因数除以10之间的公因数,找到提取后长度最短的一种进行输出。这么做是因为上面提到的提取出最大公因数未必会比提取其他因子短的主要原因在于二者对每个项的缩短程度相同,而十倍则是必然使项缩短的一个界限。
Bug 2
Bug 产生原因——指数提取出负数
尽管BigInteger
类提供的gcd
方法并不会出现公因数为负数的结果,但当exp
内仅有一项时,我会直接返回这个项的系数。而如果这个项恰好为负的时,就会导致错误。比如,我会把exp((-200*x))
优化为exp(x)^-200
。
Bug 修复方案——添加绝对值
很简单,返回的公因数取其绝对值即可。
优化不成还获得了C房体验卡,痛定思痛,痛何如哉!:disappointed::disappointed::disappointed:
仙人优化
尽管上面对exp
的处理似乎已经臻于完备,但在不少情况下,仍非最优解。
1 | exp((3*x+2*x^2+2*x^3+2*x^4+2*x^5)) |
在大多数情况下,因为个别项的存在,我们未必能提取出公因数。这个时候,我们就可以考虑个别项个别处理,以少数项长度的牺牲换取多数项长度的优化。这种处理需要极高的算法造诣,遍历大量的邻近公因数构成公因数集合再依次比较筛选,需要设置一定的阈值来避免超时的风险。笔者能力有限,在此不作赘述。
程序分析
复杂度分析
class | OCvag | OCmax | WMC |
---|---|---|---|
src.expression.Exp | 2.2 | 6.0 | 22.0 |
src.expression.Expr | 2.7 | 7.0 | 47.0 |
src.expression.Function | 1.1 | 2.0 | 8.0 |
src.expression.Monomial | 1.4 | 2.0 | 13.0 |
src.expression.Num | 1.0 | 1.0 | 3.0 |
src.expression.Term | 3.0 | 10.0 | 82.0 |
src.expression.Token | 1.2 | 2.0 | 5.0 |
src.expression.Token.Type | 0.0 | ||
src.Lexer | 3.0 | 15.0 | 24.0 |
src.Main | 2.0 | 2.0 | 2.0 |
src.Parser | 3.8 | 12.0 | 31.0 |
Total | 237.0 | ||
Average | 2.51 | 5.9 | 21.5 |
从图表中可以看到,Expr
类、Term
类和Parser
类中负载了过多的方法,这与我上面提到的未能有效的把 复杂度下放 、 表达式的解析与函数的代入耦合 有关。
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
src.expression.Exp.toString() | 8.0 | 4.0 | 3.0 | 6.0 |
src.expression.Term.addFactor(Factor) | 17.0 | 7.0 | 6.0 | 10.0 |
src.expression.Term.isLike(Term) | 16.0 | 8.0 | 6.0 | 13.0 |
src.expression.Term.toString() | 8.0 | 5.0 | 7.0 | 8.0 |
src.Lexer.classify(String) | 15.0 | 1.0 | 15.0 | 15.0 |
src.Parser.parseFactor() | 18.0 | 9.0 | 15.0 | 15.0 |
… | … | … | … | … |
Total | 234.0 | 145.0 | 237.0 | 265.0 |
Average | 2.5 | 1.5 | 2.5 | 2.8 |
从图表中可以看到,Exp
的优化、Term
类对不同类型因子的处理输出、同类项的判断是复杂度超标的主要原因,需要做出更严整的类的设计以实现复杂度下放。而Lexer
和Parser
中过多的if-else
语句也是程序设计不成熟的重要体现,需要做出更好的分类、抽象、包装工作。
架构分析
总体来说,这次架构设计相对完善,两次迭代主要是在添加新的代码,并没有经过大面积的重构,整体思路也没有明显变化。但因为懒惰,没有进一步的包装、抽象、分离,导致个别类显得臃肿不堪。
class | CLOC | JLOC | LOC |
---|---|---|---|
src.expression.Exp | 0.0 | 0.0 | 88.0 |
src.expression.Expr | 2.0 | 0.0 | 170.0 |
src.expression.Function | 0.0 | 0.0 | 30.0 |
src.expression.Monomial | 2.0 | 0.0 | 59.0 |
src.expression.Num | 0.0 | 0.0 | 12.0 |
src.expression.Term | 3.0 | 0.0 | 311.0 |
src.expression.Token | 0.0 | 0.0 | 21.0 |
src.expression.Token.Type | 0.0 | 0.0 | 4.0 |
src.Lexer | 0.0 | 0.0 | 72.0 |
src.Main | 3.0 | 0.0 | 25.0 |
src.Parser | 0.0 | 0.0 | 145.0 |
Total | 10.0 | 0.0 | 937.0 |
Average | 0.91 | 0.0 | 85.18 |
尽管有的地方写的比较抽象,但总体而言扩展性还是可以的,特别是三次作业中都实现了相应的多变量的功能,比如同类项合并、化简、求偏导等,而且已经经过测试,做好了多变量扩展的底层工作。按照本次作业数学计算工具的发展方向,我认为预留多变量的功能是相当必要的。
Bug 检查
评测机搭建
得益于假期对python的学习和AI的帮助,我在这次作业中成功搭建了自己的评测机。主要是通过random
库来生成输入,通过sympy
库来检验正确性。同时,我在python中用字符串替换的方式实现了函数代入的功能。因为sympy
在处理exp
时是通过代值计算的方式进行比较,导致其无法应对大量嵌套的情况。所以最后也是学习了同学的思路采取对减是否为零的方式来检验正确性。
但比较遗憾的是,由于我的评测机没有加入格式检查的功能,导致我指数为负的bug没有被检测出来;同时,由于检测的数据量较低,我函数重复代入的错误也没有被检测出来。这两个问题应该是我下一个单元搭建时应该考虑的重点。不过好在用自己的评测机发现了别人的bug,刀人很爽。
从我个人的经验来看,出现了 bug 的方法和未出现 bug 的方法在代码行和圈复杂度上并无明显差异,评测机的检验也只是一种碰运气似的辅助手段。要避免 bug 出现或者及时发现 bug ,更多的是需要架构上的考量,对项目的每个环节都有清晰的认知、规划、分工才能实现设计的完备性。
1 | “要多想。”父亲说。 |
心得体会
有一说一,尽管磕磕碰碰、bug不断,但还是挺喜欢这种独立设计一个体系结构的感觉(就像上学期的计组一样),毕竟只有死去才能活来。
为了进行高效的程序设计,我们就要有意识地重构,把代码按照共性特征抽象、封装起来,实现层次结构的清晰分明,对程序各部分的功能特征熟稔于心,进而才谈得上轻松有效的扩展。
如果你不想大面积地重构话,那就多多进行小面积的重构吧!
未来方向
作为面向对象的开局之作,Unit1 不论是在难度上还是工作量上都比较合适,可以充分体会到编程的乐趣。其贯穿始终的递归下降思想,对于工程化的架构设计大有裨益。在此之外,我认为可以渗透一些 Java 的语法知识,让我们在充分认识 Java 工具的基础上去灵活应用(要不直到 Unit1 我才知道内部类这个东西)。