301
社区成员
发帖
与我相关
我的任务
分享先祭出最终代码的平面结构:

图中有三个主要的文件夹分别为src、expr、derivation:
src文件夹存放了Main程序和主程序中需要直接使用的方法类,依次是Preprocessor、Lexer、Parser、Operator类,作为实现包。
expr文件夹存放了方法类需要使用到的小方法类(如OperatorTools类)和数据存放类(如Num、Var、Term、Expression等),作为工具包。
derivation文件夹存放了和求导相关的方法类和数据类,作为特殊功能的实现包。
其中main方法实现如下:

由图可知本代码框架下信息流动的方向为:
输入 =》 预处理器 =》 函数解析器 =》 导数解析器 =》 后缀表达式生成器 =》 后缀表达式计算器 =》 输出
首先在代码软件包层次进行复杂度分析:

由图可知,src包平均复杂度最高位src,但其总体复杂度最小;expr包虽然平均复杂度排第二,但其总体复杂度最高。由于src包中只完整实现了Lexer方法和Preprocessor方法,其他方法只做接口使用,而Preprocessor方法复杂度较低、Lexer方法实现和规范无异,因此将分析重点放在expr包的分析上。下面是expr包中在Class层级上进行复杂度分析的结果:

由图可知,expr包中的DerivationKiller类、RepoItem类、FunctionKiller类、OperatorTools类复杂度较高,下面分别对其进行分析:

由图可知类中共有2个方法,其中KillDerivation方法承担了主要的复杂度。
分析原因为:本代码对求导方法的实现和主代码的表达式化简进行了隔离处理,并不是直接在parser的过程中进行的求导。而在求导过程中需要调用表达式化简这一主方法,因此KillDerivation在求导方法和主方法之间实现了某种意义上的"递归"。主方法和KillDerivation方法相互调用产生递归,这不符合面向对象的“高内聚、低耦合”的设计思想,使得代码复杂度增加。
改进方案为:直接在parser里面进行求导,而不必单独构建求导方法,从而避免重复使用代码带来复杂度增加和不必要的开销。

由图可知类中共有8个方法,其中out1方法和analysis方法承担了主要的复杂度,而out1和analysis方法均是toString方法的具体实现的一部分,因此直接对toString方法进行分析。
分析认为:由于toString方法实现的是Factor单元存储形式(ArrayList)到字符串形式的映射。在本单元的代码功能要求下,为了实现最终表达式的最简化需要使用大量的条件判别,造成控制分支数目过大,这一方法的复杂度不可避免。

依然是killFuction方法占据主要复杂度,这里复杂度超标原因和(1)DerivationKiller类本质上相同,不再赘述。

由图可知类中共有4个方法,其中sub方法和mul方法承担了主要的复杂度。
分析原因为:对于sub方法而言,由于要进行a*x^b*exp(expression)形式的合并同类项,在判断两项的expression相同时使用了(expression1-expression2)是否为0的判断方法,而实现(expression1-expression2)计算本身又需要调用sub,因此产生了递归调用使复杂度增加。其次由于无法对要运算的两个表达式进行排序,因此只能将表达式的项进行两两比对,导致寻找同类项合并的时间复杂度达到了O(n^2)。对于mul方法而言,由于乘法运算后会产生新的同类项也需要进行同类项合并,其复杂度高的原因和sub方法类似。
改进方案为:在第二次小组讨论的分享中,有组员分享了对表达式各项进行排序的方法,即新建一个类,储存一个字符串,将a*x^b*exp(expression)映射为字符串b + “,” + expression,然后对该字符串进行字典排序,从而实现表达式的有序化,这样可以直接判断exp()的两个指数(expression1==expression2)是否为真,从而避免了递归调用sub方法,同时在同类项合并时也能使用二分查找算法,大大降低合并同类项的复杂度。

图中Factor为接口,下面是其具体的实现类。其中Num存储数字信息,Var存储指数函数信息,Index存储幂函数信息,Term存储项信息(即Num、Var、Index、RepoExpression以*连接后的项),RepoExpression存储表达式信息且输出自动转换为逆波兰表达式。

上图中,箭头均由被依赖项指向依赖项。在图片的第一行是主程序运行的逻辑,在各个类旁边是其简要的设计考虑和功能。
在三次作业迭代开发的过程中,我的代码架构并未进行重构,以下是对三次作业中我的代码架构如何逐步成型的简要说明:
代码平面架构:

参考train-1的advance部分利用递归下降算法实现了对表达式去括号和生成逆波兰表达式,但未对其合并同类项,考虑直接设计一个逆波兰表达式计算器(Operator),放在逆波兰表达式生成器(Lexer、Parser)之后,实现合并同类项功能。此外,因为最终要得到最简表达式,设计一个预处理器PreProcessor,实现对表达式的初步化简,以减少后续分析和化简的工作量。
代码平面架构:

由于复杂度增加的需求,需要将方法和具体的实现工具进行分离从而更好地了解代码结构和Debug,需要将原本集成的方法-存储类进行拆分。
对于预处理操作(PreProcessor)而言,新增了FuctionKiller类作为函数处理功能的实现部分,为了支持FunctionKiller在任何时候都能访问到输入开头的函数定义信息,又新建了Function类作为函数信息的存储的访问载体。
对于后缀表达式的计算操作(Operator)而言,这里从Operator中提取了OperatorTools方法置于工具包中,同时扩展了OperatorTools,使其支持a*x^b*exp(expression)形式的存储和计算。在支持存储方面,这里具体表现为新建了Factor接口,用Num,Var,Term,Index,RepoExpression作为接口的具体实现。在支持计算方面,这里具体表现为新建了RepoItem类表示了整个逆波兰表达式的项的信息,RepoItem类可以作为被操作数直接在Operator中进行运算,且支持toString为中缀表达式。为了支持RepoItem对逆波兰表达式的每一项的信息存储,还新建了Expression类作为项信息存储的元单位。
代码平面架构即为最终代码的平面结构,这在文章的开头已有截图和阐述,这里具体讲讲相对于HW2的增量开发部分。
由于HW2中我将函数处理放在了预处理模块中且独立了FunctionKiller方法,因此要实现在函数定义时的上文复用,只需在Function记录函数表达式信息时先行对表达式FunctionKiller一下即可。
对于求导操作,我以exp2实验的部分代码为参考,和FunctionKiller一样,将其单独实现为一种方法,即DerivationKiller,其具体依赖的数据类和方法类放于Derivation软件包中,而DerivationKiller则放于expr软件包中,从而PreProcessor中只需扩展调用DerivationKiller即可实现表达式去导数。
由于本代码架构将求逆波兰表达式和计算逆波兰表达式进行了隔离,本代码可以直接支持输入为逆波兰表达式的表达式化简任务。
由于有单独的预处理器,本代码可以支持在表达式之前进行宏定义。
由于有单独的OperatorTools类,在加入新的自定义运算符时可以直接在OperatorTools工具包中扩展即可。
我只在第二次作业的强测中出现了两个Bug:
(1)mul操作后进行同类项合并时只合并了最前面的两项,后面的同类项被程序自动忽略。求其原因为没有对代码进行充分的复杂度测试。
(2)函数的参数表括号中出现空格将导致未知的错误。求其原因为在Preprocessor中错误地把FunctionKiller放在了Preprocess前面,导致在消除函数时表达式中还有未消除的空格。求其原因为扩展功能时没有考虑原本功能对新功能可能产生的影响。
通过评测机生成测试用例对代码进行测试,这一途径能够找到90%的漏洞,但是在另外10%评测机无能为力的情况下,我会结合被测程序的代码设计结构来设计测试用例,关注其代码结构中的条件关系和代码各个操作的先后和有无,寻找其逻辑漏洞进行精确打击。
在程序结构的优化上,出于对main函数内表述简洁性的注重,将复杂的操作浓缩为抽象的方法,而将其具体实现放在另一个软件包,增加了代码结构的逻辑性和可读性。
在程序性能的优化上,对于sub方法而言,由于要进行a*x^b*exp(expression)形式的合并同类项,在判断两项的expression相同时使用了(expression1-expression2)是否为0的判断方法,而实现(expression1-expression2)计算本身又需要调用sub,因此产生了递归调用使复杂度增加。同时由于无法对要运算的两个表达式进行排序,因此只能将表达式的项进行两两比对,导致寻找同类项合并的时间复杂度达到了O(n^2)。
改进方案为:对表达式各项进行排序。新建一个类,储存一个字符串,将表达式的每一项,即a*x^b*exp(expression)映射为字符串b + “,” + expression,然后对该字符串进行字典排序,从而实现表达式的有序化,这样可以直接判断exp()的两个指数(expression1==expression2)是否为真,从而避免了递归调用sub方法,同时在同类项合并时也能使用二分查找算法,大大降低合并同类项的复杂度。
代码架构的考量需要从一开始就做好规划。本人因为一开始没把计算过程放入Parser过程解决,导致后面的设计思路都和主流方法有较大出入,经过几次迭代开发,发现将计算功能单独拿出来实现对后续的迭代开发并没有很大的帮助,反而显得拖沓。
方法并不是越多越好、越离散越好,考虑“低耦合”的因素,我一开始认为应该将获取逆波兰表达式和计算逆波兰表达式操作进行隔离,但没有意识到逆波兰表达式这一中间状态并没有太大存在的必要,我们并不需要先生成一个逆波兰表达式才能进行计算,而是在递归下降进行语法解析时就可以进行逐层计算。
为了更好的进行第一单元的学习,考虑在以后的迭代开发中可以加入除法、取模等操作(可以引入分数类型来实现这一点),从而能够实现负指数求值,进而可以引入对x的赋值操作,将表达式视为一个函数求其在不同x下的数值(甚至还可以考虑实现exp的n阶泰勒展开)。
汗流浃背了