OO Unit1 总结博客

Posted by UUQ on 2024-03-28
Estimated Reading Time 13 Minutes
Words 3.5k In Total
Viewed Times

一、架构分析


1. 架构概览

在本节中,将对最后一次迭代(hw3)的项目进行分析,先在此简单描述一下我的思路

  1. 解析并存储自定义函数(UDF)
  2. 去除输入表达式空格和\t递归地进行字符串替换掉函数调用
  3. 对2中得到的表达式边解析边化简,得到最终表达式
  4. 输出结果

因此整体架构可以被分为两部分:解析、预处理部分 以及 存储、化简部分,前者对应Parser、Lexer、UDF相关类,后者对应Expr、Term、Factor等相关类。

在最终的架构中,我从存算分离架构调整为了存算一体的架构,但本质上,解析和化简功能还是可以分开的。下面将分别进行介绍。

2. 项目架构-类图

该类图中省略了一些基本的内容,主要体现类之间的关系和一些核心功能的实现:


使用PlantUML工具编辑绘制的UML图

3. 类设计思路阐释

(1) 预处理和整体思路

对于Parser、Lexer部分的设计和training提供的思路类似,即使用递归下降的方法,通过Lexer对字符串进行token提取,由Parser对token进行识别和解析,完成存储,这里就不过多展开。

对于UDF(函数)的存储,设计了UDF类,

为了对UDF输入进行解析,设计了UdfParser类,实际上和Parser关联不强,只是也使用了Lexer进行token分割和提取,对于自定义函数进行名称、形参、表达式的识别和存储,生成UDF。

(2) 存储&计算

这部分是比较核心的地方,大架构参考training的Expr-Term-Factor的三层结构,设计Factor接口让Expr完成implement,以实现表达式套表达式的括号嵌套。

Factor接口定义了因子的三个共同行为,分别是求导转换为表达式化简,共有四个实现,即Number(常量)、VarFactor(对应x^a)、Expr(表达式因子)、Expo(对应exp())

每个底层因子都很直观地按照形式化表述的定义设计,通过calc和toExpr和d这三个接口中规定的方法,把add和mul都通过Expr类进行实现,减少了实现其他类之间加&乘运算的代码量。

4. 用数据说话——基于统计结果的分析

使用IDEA插件Statistic,得到各个类的规模(代码行数、方法个数)

Class Source Code Lines 方法个数
Factor 5 3
UdfParser 25 3
Number 25 6
Expo 28 6
VarFactor 29 6
Lexer 43 5
Udf 46 5
Term 87 12
MainClass 80 -
Parser 136 9
Expr 301 16

可以看到Expr类的代码行数最多,因为Expr不仅是最终得出答案的最大容器,同时也是因为架构设计时,对于不同Factor的加乘运算都在Expr进行(使用toExpr后mul,或者使用Expr的addST)。

方法数量较多是因为对于每种Factor或Term、Expr,都设计了多种构造函数,虽然可能看起来比较冗余,但是这样易于扩展,使得代码可读性增强。例如在VarFactor的构造函数就有传入int类、传入BigInteger类的两种构造函数。

使用IDEA插件Metric Reloaded,分别计算出各个方法的圈复杂度

Method CogC ev(G) iv(G) v(G)
Expo.Expo(Expr) 0 1 1 1
Expo.Expo(Factor) 0 1 1 1
Expo.calc() 0 1 1 1
Expo.d() 0 1 1 1
Expo.getUp() 0 1 1 1
Expo.toExpr() 0 1 1 1
Expr.Expr() 3 2 2 2
Expr.Expr(Factor) 3 2 2 2
Expr.add(Expr) 1 1 2 2
Expr.addST(Term) 7 1 4 5
Expr.addTerm(Term) 0 1 1 1
Expr.calc() 10 3 5 7
Expr.compareTo(Expr) 0 1 1 1
Expr.copy() 1 1 2 2
Expr.d() 1 1 2 2
Expr.isFactor() 8 6 7 9
Expr.mul1(Expr) 16 6 6 7
Expr.toAns() 34 4 15 18
Expr.toAnsFir() 28 4 11 11
Expr.toAnsIn() 2 1 2 2
Expr.toExpr() 0 1 1 1
Expr.toString() 3 2 3 4
Lexer.Lexer(String) 0 1 1 1
Lexer.getNumber() 2 1 3 3
Lexer.getRem() 0 1 1 1
Lexer.getToken() 0 1 1 1
Lexer.next() 6 2 5 6
MainClass.main(String[]) 1 1 2 2
MainClass.pre(String) 48 8 13 17
Number.Number(int) 0 1 1 1
Number.Number(int, BigInteger) 0 1 1 1
Number.calc() 0 1 1 1
Number.d() 0 1 1 1
Number.getValue() 0 1 1 1
Number.toExpr() 0 1 1 1
Parser.Parser(Lexer) 0 1 1 1
Parser.getLexer() 0 1 1 1
Parser.parseDX() 0 1 1 1
Parser.parseEF() 8 4 6 6
Parser.parseEP() 3 1 3 3
Parser.parseExpr() 8 1 7 7
Parser.parseFactor() 11 5 7 8
Parser.parseTerm(int) 3 1 4 4
Parser.parseVF() 3 1 3 3
Term.Term() 0 1 1 1
Term.Term(BigInteger, BigInteger, Expr) 0 1 1 1
Term.Term(Factor) 4 1 4 4
Term.addFactor(Factor) 5 1 5 5
Term.addPow(BigInteger) 0 1 1 1
Term.calc() 1 1 2 2
Term.d() 1 1 2 2
Term.getFlag() 0 1 1 1
Term.getPow() 0 1 1 1
Term.getPowE() 0 1 1 1
Term.mulFlag(BigInteger) 0 1 1 1
Term.mulFlag(int) 0 1 1 1
Udf.Udf(String, ArrayList, String) 0 1 1 1
Udf.calc() 7 4 5 6
Udf.call(ArrayList) 4 1 3 3
Udf.getName() 0 1 1 1
Udf.getNum() 0 1 1 1
UdfParser.CreateUdf() 1 1 2 2
UdfParser.UdfParser(Lexer) 0 1 1 1
UdfParser.UdfParser(Lexer, HashMap<String, Udf>) 0 1 1 1
VarFactor.VarFactor(BigInteger) 0 1 1 1
VarFactor.VarFactor(int) 0 1 1 1
VarFactor.calc() 0 1 1 1
VarFactor.d() 1 2 1 2
VarFactor.getPow() 0 1 1 1
VarFactor.toExpr() 0 1 1 1
compare(Pair<BigInteger, Expr>, Pair<BigInteger, Expr>) 2 n/a n/a n/a

可以看到圈复杂度高的方法主要有Main的pre方法、Expr的两个toAns方法和Expr的mul1方法,pre方法解析str中的自定义函数、两个toAns方法是最终输出(第一项和其他项),因此有很多分支。mul1方法实现了两个Expr的乘法,涉及判0和一些其他事宜,复杂度较高,而其他方法圈复杂度都小于10,整体复杂性可以接受

而对于每个Class,我们也有:

Class OCavg OCmax WMC
Expo 1 1 6
Expr 3.89 15 70
Lexer 2.2 6 11
MainClass 7.5 13 15
Number 1 1 6
Parser 3.56 7 32
Term 1.75 5 21
Udf 2 4 10
UdfParser 1.33 2 4
VarFactor 1.17 2 7

可以看到WMC(加权方法复杂度)同样聚集在Expr类,其次是Parser,这和架构设计和功能特性关联很强。

二、迭代

1. 架构演化

第一次作业 —— 存算分离的典型

第一次作业由于内容较少,而且training和OOLens都提供了递归下降的解读和示例,因此可能大家的设计主要分为两类——存算一体和存算分离,我最开始为了方便理清思路、便于调试,使用了后者,同时明确了使用基本项(a*x^b)和基本项构成的加法多项式存储结果。

但实际上先解析再计算架构为了区分两个过程,我认为最好设计单独的基本项和多项式类,不要在化简时直接复用解析时使用的Expr,但我觉得其实差不多所以没有分开,开发过程中确实思路上和实现时产生了一些困扰。

第二次作业 —— 缝缝补补和一些动摇

第二次作业允许了括号嵌套,新增了指数因子和自定义函数。前者实际上递归下降方法本身就支持多层括号,而新增指数因子只需要新增一个Expo类,自定义函数经过严谨论证如果不考虑效率(即函数表达式和形参的化简)直接使用字符串替换即可,因此架构只会增添,不会太改变。

但是我担心我的存算分离架构在括号过多的时候可能运行效率不佳,因此在解析表达式因子的时候也调用了Expr.calc()进行计算,但最外一层仍需要最后调用calc,此时我的架构处于一个不太明确的状态,也可能导致重复运算,但由于细节处理得当,整体运行速度允许有这样的冗余。

基本功能实现了,但是为了性能分肯定要做基本的合并同类项——这时候判断exp上面的expr是否相等就要花头脑了。为此我对Expr的存储顺序进行了规定,保证toString后结果唯一,用字符串判断。这时候由于没有单独的Poly类导致写起来确实有点麻烦。

第三次作业

第三次作业加的内容不多,就是求导和自定义函数嵌套。求导只需要先化简内部表达式或因子,在每一层都实现求导,再实现乘法规则即可,自定义函数嵌套由于字符串替换的递归实现,所以无需做出什么改变。

整体架构没有发生什么变化。

可见我的架构实际上比较清晰,类的数量较少,比较轻量级,但每个类的功能不够单一,部分类的代码量大,导致迭代和维护相对困难。

2. 迭代情景假设——三角函数

新增三角函数的话,因为cos和sin之间可以相互转化,所以只需要新增实现Factor接口的sin类,在Parser和Lexer部分相应修改。

对应的,基本项需要扩充为ax^bexp(Expr)*sin(Expr),化简仍然依靠Expr的toString比较sin里面是否相同。

三、本地测试、强测、互测和性能优化

1. 我的“Bug”们

在第一次作业中,为了实现更好的性能分,我试图将正项提前以减少一个加号,但是写的时候对于0项的处理没有弄好,之前的逻辑是在化简的时候保证0不出现,因此输出没有考虑0的部分,但是在优化了部分逻辑、新增正向提前后,前面化简的时候有个部分忘记去除了0项,导致输出错误,十分可惜。

这个惨痛的教训让我意识到:一个功能在可以做的时候就要做好,即使增加冗余也比出错好,这样就可以使自己的程序更加robust,而不是试图依赖上下游的处理,一个不小心就弄出问题。这次出现bug的方法圈复杂度较高、代码行数也高,因此很容易考虑不周到,导致一个疏忽就犯了错,但由于为了追求输出更短,其代码逻辑本身就很琐碎,因此我尝试了增加容错和冗余的方法保证正确性。

在第三次作业中,互测被卡了TLE。因为之前没考虑TLE的事情(毕竟已经边化简边解析了,不会被很简单的1-1的一堆次方卡,而且看到强测时间给的很长),所以很多可以优化效率的地方没有实现,而且增加了一些容错和冗余(比如输出前再计算一次),结果就被卡了。最终分析后,我发现在exp合并的时候,Expr的toString会花费很多时间,而实际上Expr如果不改变是不需要多次toString的,于是在对Expr的toString记忆化处理后,顺利通过bug修复。

2. 测试方法与互测

主要以跑评测机为主,随机生成数据、使用同学们的评测机等,但随机的话其实数据很弱,很难生成出bug,而且互测中即使使用评测机生成出bug数据后还需要手动化简为符合cost的结果,因此手动构造一些极端情况、边界情况的数据也很必要。

另外也会通过粗略分析对方代码架构的方式,有针对性地进行数据设计,有些同学会无脑先做n次方再进行化简,这时候1-1+x的n次方就会让他算得很多导致TLE等等。

长度优化与效率优化

上文提到过,关于长度优化,最基础的合并同类项(第二次以后通过Expr有序toString进行判断)、正项提前(这一项导致输出逻辑变得更长了,因此我特意将输出第一项单独实现了一个方法(toAnsFir),试图使得方法简单可读性强)外,没有进行额外优化(担心时间复杂度变得很坏,以及有时候提取公因数还不如不提)。

而效率优化主要是在计算前先保证内部已经化简(用change的Tag进行记忆化),以及toString的时候通过记忆化,减少了无意义的重复运算。这些优化对于各个方法复杂度几乎没有影响。

四、心得体会

OO正课相比OOpre可谓不是一个层级的,第一单元的学习也让我收获颇丰,不仅是简单的写代码速度提升,更是对于写代码之前,对于简洁高效的架构设计以及预判未来拓展方向、预留拓展空间的考量提升颇多。这一点在OOpre中只是“听到了老师有讲过”,但是没有在写代码过程中自己感受到。

另外第一次作业在实现的时候确实有一定的思维难度,不真正下手写一些感觉自己的理解都是虚无缥缈的,边写边深化自己的理解,然后再订正自己的大思路,我觉得这是适合我的方法,因此我也觉得我其实应该在第二次作业的时候进行重构,因为有了新的想法,发现自己之前的设计有缺陷,其实重构是最好的,如果有五六次作业,或者最后一次不是比较简单好实现的求导,那我肯定果断重构了。

五、建议

第一单元教程很完善,不过我觉得应该在第一次就允许括号套括号,以确保大家对递归下降法的理解比较完善准确。


如果您喜欢此博客或发现它对您有用,则欢迎对此发表评论。 也欢迎您共享此博客,以便更多人可以参与。 如果博客中使用的图像侵犯了您的版权,请与作者联系以将其删除。 谢谢 !