OO第一单元总结:表达式解析的迭代过程
前言
本单元主题是关于数学表达式的解析问题,分成了三次迭代作业。从仅含x的单变量幂函数解析到加入选择表达式、exp指数函数与自定义表达式,再到最后一次引入递推函数和求导。本文将阐述本项目中各个模块的功能职责、对象间的交互逻辑,性能优化方面所做的尝试以及bug分析等等。
一、基于度量的结构分析
项目规模与复杂度统计表
| 类名 (Class) | 属性数 (Fields) | 方法数 (Methods) | 总规模 (LOC) | 平均方法规模 (Avg LOC/M) | 最大分支数 (OCmax) |
|---|---|---|---|---|---|
| Parser | 3 | 12 | 273 | 22.75 | 12 |
| Calculate | 1 | 16 | 221 | 13.81 | 9 |
| Mono | 0 | 4 | 81 | 20.25 | 11 |
| Printer | 0 | 5 | 97 | 19.40 | 6 |
| Poly | 1 | 13 | 85 | 6.54 | 3 |
| RecFunc | 2 | 7 | 67 | 9.57 | 3 |
| RecParser | 2 | 4 | 58 | 14.50 | 7 |
| ExpRegistry | 1 | 2 | 11 | 5.50 | 1 |
| Main | 0 | 1 | 24 | 24.00 | 3 |
数据分析:
1.核心逻辑集中于Parser和Calculate.Calculate的方法数最高(16个),说明运算逻辑拆分地比较细,各种计算方法一目了然。
2.通过ExpRegistry类统一管理exp引用,将各类的属性数均控制在3个以内,遵循低耦合的设计思路。
3.Parser类的规模较大,解析过程可能比较繁复,后续可考虑进一步提取子方法以化简代码。
类的复杂度分析 (Method Complexity)
| class | OCavg | OCmax | WMC |
|---|---|---|---|
| Calculate | 3.31 | 9 | 53 |
| ExpRegistry | 1.00 | 1 | 2 |
| Main | 3.00 | 3 | 3 |
| Mono | 5.00 | 11 | 20 |
| Parser | 3.10 | 12 | 62 |
| Poly | 1.46 | 3 | 19 |
| Printer | 4.40 | 6 | 22 |
| RecFunc | 1.29 | 3 | 9 |
| RecParser | 3.50 | 7 | 14 |
| Total | 204 | ||
| Average | 2.83 | 6.11 | 22.67 |
内聚与耦合的分析:
在本项目的设计过程中,我重点关注了模块职责的划分以及对象间依赖关系的简化。以下是基于度量数据的分析:
- 高内聚:代码方法平均复杂度较低(2.83),说明逻辑被拆分成了多个功能单一的方法,符合单一职责原则。
- 低耦合:整个代码平均属性低,通过ExpRegistry类集中管理全局引用,避免了类与类之间通过大量成员变量传递数据,有效降低了内容耦合度。
- 局部复杂度较高:Parser、Calculate和Mono的OCmax偏高,说明解析与计算等不够简洁。虽然类级内聚度高,但方法级内聚仍有提升空间。
综合评估
| 评估维度 | 评价 | 结论 |
|---|---|---|
| 内聚性 | 较高 | 各模块职责分明,实现了功能的有效分离。 |
| 耦合度 | 较低 | 利用ExpRegistry有效解决了复杂对象间的关联风险。 |
最终架构类图
各类设计考虑
1.Parser类:基础表达式解析,负责处理标准的算术表达式逻辑,通过递归下降保证括号嵌套的正确性。
2.Calculate类:负责运算处理,将复杂的求导、合并同类项、去括号等算法集中处理,通过统一的接口操作数据。
3.Poly && Mono类:数据存储模型。Poly用Map存多项式,Mono存单项式。设计初衷是建立一个最简化的数学表达形式,让后续的合并同类项变成简单的Map操作。
4.Printer类:保证最后输出的字符串符合题目要求的基础上尽可能缩小输出的字符串的长度。
5.ExpRegistry类:维护一个Map<String, Poly>,将复杂的exp(Poly) 内容提取出来,并用其字符串形式作为value。这使得复杂的嵌套指数在 Poly 中可以被当做一个简单的原子名称来处理。
6.RecParser类:设计上独立于主解析器,专门负责按照固定格式提取 fnf_nfn 定义中的系数、参数和额外项,降低了Parser的复杂度。
7.RecFunc类:实现记忆化展开,利用一个 Poly[ ] cache数组存储已经计算过的 fnf_nfn。当调用 getBody(n) 时,它会递归地展开递推式,并利用Calculate.ChangeFunc进行变量代换。
设计优点:
本架构的优点体现在利用ExpRegistry将复杂的exp(P)exp(P)exp(P)提取为唯一的原子ID,实现了嵌套逻辑的简单化处理。通过这种方式,深层嵌套的指数函数被转化为平级的引用关系,极大地降低了求导和运算的复杂度。配合Calculate.mergeExpAtoms在运算中实时执行指数合并(eA⋅eB=eA+Be^A \cdot e^B = e^{A+B}eA⋅eB=eA+B),提升了化简效果。同时RecFunc采用记忆化展开策略,可以提升递推函数调用时的性能。
设计缺点:
代码中缺乏多态性的运用,在解析因子和求导运算时依然依赖大量的if-else分支判断,这不仅导致代码复杂度升高高,也不符合开闭原则,加大了后续扩展新功能的难度。
二、架构设计体验
1、架构成型过程与重构体验
HW1
我采用经典的递归下降方法解析表达式,通过Expr -> Term -> Factor的层级结构一层层地拆解表达式,让每一个类只负责自己的那一部分计算,最后通过递归调用toPoly汇总结果。
HW2
第二次作业我进行了大面积的重构,因为原来的存储方式不适合exp和自定义函数的情形。在这次重构的过程中,我通过ExpRegistry建立全局注册表,将每一个exp(inner)视为一个独立的“原子”,并分配唯一标识。同时将单项式(如x^2 *exp(x))编码为字符串key,存入TreeMap中。此外引入Calculate类统一处理多项式乘法和快速幂。
HW3
本次作业基本就是在HW2的基础上进行迭代,在新增y因子的基础上,在Calculate中新增递归求导运算功能,并且针对递推函数专门写了RecParser来解析递推函数,同时写了RecFunc来记忆化处理递推函数,工作量相对第二次作业来说显著减少。
2、新的迭代情景与可扩展性
新迭代情景:自然对数函数(ln)扩展
假设在下一次迭代中,系统需要引入自然对数函数 ln。具体需求如下:
- 因子扩展:支持
ln(Poly),括号内为满足必要性括号原则的因子(例如ln((x^2 + exp(x))))。 - 符号求导:支持对
ln因子求导(但是由于除法不容易实现,所以根据实际情况而定,如保证不对对数函数求导)。根据链式法则:
ddxln(f(x))=1f(x)⋅∂f∂x\frac{d}{dx} \ln(f(x)) = \frac{1}{f(x)} \cdot \frac{\partial f}{\partial x}dxdln(f(x))=f(x)1⋅∂x∂f - 数学化简:从性能分角度能够处理特殊对数性质,如 ln(1)=0\ln(1) = 0ln(1)=0(当然,从正确性角度非必须)。
2. 最终设计在这一情景上的可扩展性分析
基于在HW3中确立的架构,对ln 展现出了较好的可扩展性:
- 在parseFactor分支中增加对字符串
l(即ln)的识别。解析ln(inner)时,内部的inner可以直接再次调用parseExpression()。由于我的架构支持递归解析,像ln(ln(x))这种深层嵌套也能支持解析。 - 可以写一个
LnRegistry,同样地将形如ln(x+1)的式子作为一个唯一的原子字符串ID存储,同类项合并也可仿照exp。 - 求导部分看具体题目要求,如果确定对ln也求导,则根据链式法则在Calculate中增加相应的求导逻辑。
- Printer中正常扩展对ln函数的打印即可。此外在Printer输出前,可以增加简单的常数检查。如果ln内部的Poly是常数1,则直接将ln1其替换为0.
PS:建议增加ln而非sin/cos或exp,让后来人尝尝新滋味
三、分析自己程序的bug
强测中未通过的点:
第二次作业中,未通过的数据点如下:
0
[(((x+1)^4)==((x+1)^2)^2)?(0):(((((((((((((x^2+x+1)^8)^8)^8)^8)^8)^8)^8)^8)^8)^8)^8)^8)]
对于这个数据点,我的代码会出现TLE。究其原因是我在解析选择表达式时,无脑地把A、B、C、D因子全都解析了,才会掉入圈套。在修复bug的过程中,我增加了优化逻辑,即在A与B相等时只解析C,不等时只解析D,如此便能规避此类问题。
互测中未通过的点:
仅在第三次互测中被hack,且被hack的逻辑一致,即因为不够好的gcd方法下,多层exp嵌套导致的TLE,其中一个样例如下:
1
f(x) = exp(exp(exp(exp(exp(exp(x)^2)^2)^2)^2)^2)^2
0
exp(exp(exp(exp(exp(exp(exp(exp(exp(exp(exp(exp(f(f(f(f(x)))))^2)^2)^2)^2)^2)^2)^2)^2)^2)^2)^2)^2
由于是最后一次迭代作业,所以我修复的方式也很简单粗暴——直接把gcd部分全都删掉(当然有不少大佬有既能优化输出长度又能规避TLE的方法,本人实力不济故选择此粗鄙之法)。
对比出错方法与未出错方法:
其实我出现bug的方法并不是wa或者需要大改的错误,所以改动前后并没有在代码规模和复杂度上产生较大差异(反倒是第三次作业中直接删去gcd方法降低了复杂度),故不作深入探讨。
四、分析自己发现别人程序bug所采用的策略
- 借助评测机来测试别人的代码(虽然仅仅在最后一次作业中测试出一个同学在函数解析时未处理符号的问题)
- 通过常见的易错点手动构造极端样例来hack,这在第三次作业中成功帮我hack到三个人。
- 特别指出,我结合了被测程序的代码来设计测试用例,如针对被测代码又exp的拆开优化等构造exp嵌套与提取公因子的样例来进行hack并且成功hack到一人
- 人脉也是互测强劲的手腕(?)第二次作业确实得到了部分同学hack成功的样例,但是第三次作业也被四个不同的同学以相同换汤不换药的样例hack了四次。
五、分析自己的优化
- 第一次作业通过评测机认识到可以把正项提前以换取可能的表达式长度缩短,具体实现我是按照系数大小进行排序输出,如果正负项同时存在时保证了正项在前。
- 第二次作业与第三次作业在第一次作业的优化策略上,增加了exp提取公因式的优化,具体实现就是遍历每一项寻找最大公因数,如果大于1便尝试提取出来,看看是否长度缩短,效率比较低,但在一定程度上可以稍微减少输出长度,但也埋下tle的风险。
在保证正确性与简洁性方面,我认为可以做到。针对正负项这个优化比较简单,只需按照系数大小输出即可解决,不复杂且不会出错。至于gcd的优化,虽然我的gcd非常粗暴,不够高效,但是实现起来并不复杂。即使因此而在互测中被hack,但我不认为这违背了正确性,因为输出的结果并不会出错,至于TLE的问题应该是我写的gcd性能差导致的,若要改进可以考虑在这方面进行改进。
六、大模型相关使用
1、
| 作业 | 代码AI占比 | 性能优化AI占比 |
|---|---|---|
| HW1 (基础递归) | 20% | 0% |
| HW2 (原子化重构) | 20% | 10% |
| HW3 (求导与递推) | 10% | 0% |
2、在hack方面,我借助ai分析每个人的bug,虽然一个也没找到。在评测机搭建方面,我在学长评测机基础上,让ai进行相应的修改。在博客作业方面,我借助ai生成表格格式以及一些数学公式的格式编写。
3、我认为大模型在debug方面效果不太好,在辅助写代码方面效果不错,在搭建评测机方面效果还行,在markdown的格式方面能教我正确的格式。
4、对于房间里的其他人是否大量使用ai生成代码,本人是侦探推理作品的爱好者,本着“疑罪从无”的原则,既然我没有足够的证据,所以我不妄加推断。
七、心得体会
本次作业首先最大的感受就是太久没有写Java代码,导致从语法到设计我都忘的一干二净,所以在完成作业的过程中也是相当痛苦,经常要问大模型某个东西的语法是怎么样的。其次,对于性能分与正确分的平衡,一开始我想尽可能多拿性能分,后面我认识到其实能保证正确分就相当不错了,没必要死磕性能分,很可能还会带来意想不到的错误。最后就是关于大模型,目前大模型的能力已经能辅助我们很好的完成作业,我认为不能完全摒弃对大模型的使用,正确合理地使用大模型是可以提高我们完成作业的质量和正确性。
八、未来方向
整个第一单元的设计已经比较完善。未来如果要改进的话,我认为可以在互测阶段专门设置一个用系统评测机提交样例来测试房间同学代码的选项(与直接进行正式互测不同),即选择这次提交样例是否用于正式互测。因为互测时在tle这方面本地跟评测机跑的结果会有出入,但是在构造这种能让人tle样例的思路其实大差不差,导致构造的样例会比较像,同学会担心造成同质hack过多次,所以如果可以用评测机测试自己造的样例而非正式提交到互测,也许可以促进同学们的互测工作。
380

被折叠的 条评论
为什么被折叠?



