OO第一单元总结:表达式展开

BUAA_OO_Unit1 表达式展开

作业迭代

HW1

第一次作业的类图如下:
在这里插入图片描述

设计思路

第一次作业是对输入的表达式进行括号展开,主要帮助我们熟悉递归下降和层次化设计思想。根据作业的形式化表述,自然而然地构建了Expr-Term-Factor的类结构,幂函数、数字常量、表达式分别建类并实现Factor的接口,进行统一管理。

在递归下降思想的启发下,使用Lexer对表达式构成进行切割,并在Parser中进行解析:从Expr自上而下地逐层解析各部分,又从Factor自下而上地实例化各个部分(使用HashSet容器存储对应的子成分)。特别地,考虑到因子符号最终影响的其实是Term的符号,因此为其增加sign属性,在解析过程中随时更新Term的符号。

起初我一直在纠结如何将解析与展开结合在一起,也没能想出一个好的结果。后来意识到,解析和展开的过程应该实现责任分离,借助解析为展开提供便利。由分析得到最终表达式的统一形式如下:
P ( x ) = ∑ M ( x ) = ∑ a ∗ x b P(x) = ∑M(x)=∑a*x^b P(x)=M(x)=axb

经过上面的过程,我们可以得到一个被解析完毕的Expr,依照形式化描述对其进行自下而上的toPoly的转化,并在转化过程中实现合并同类项,至此我们便可将问题转化成各个因子toPoly的实现与多项式运算这两个部分。为了实现多项式运算,我选择在Poly中创建容器ArrayList<Mono>,但在后续作业中发现此处ArrayList的使用对后续扩展性产生了较大的影响(HW2的修改)。对多项式运算的实现包括加法、乘法、乘方运算,难度并不大,但需要在进行addPoly时及时做号合并,否则可能会出现OutOfMemory的情况。

流程梳理
  • 对输入的字符串进行预处理,删除空格,合并多余的±号

  • 使用LexerParser对表达式各部分进行递归下降解析,获得解析完毕的表达式

  • 将解析完毕的表达式从下到上转化成Poly形式,并进行toString重写输出

  • 在输出时做最基本的优化:

    • 正项提前
    • 系数为±1、0,指数为0、1的分类处理
复杂度分析

使用MetricsReloaded,得到主要方法的复杂度如下(未列出方法的复杂度与最后一项完全一致):
在这里插入图片描述

分析可知,大部分方法的复杂度都在合理范围内,但仍有个别方法复杂度超标。

  • Parser部分在对各种因子解析时,需要先判断因子所属类型再进行各自的解析。我没有将其拆分成不同类型因子解析的方法,而是堆砌在一起,形成了较为臃肿的结构。又由于我将符号提取成Term的一个属性,因此在parseExprparseTerm部分都需要进行特判提取符号,造成复杂度的升高。
  • Mono.toString()方法在进行字符串输出时,由于需要针对不同的情形做分类优化,因此会使用大量的if-else语句进行特判,导致复杂度和代码量都相当高。这也意味着后续迭代过程中,该部分可能会进行大规模改动,复用性相当差。

在设计时其他类并不负责相关计算,统一将计算部分集中于Poly这一类中,实现了较低的耦合。

HW2

第二次作业的类图如下(由于第三次作业新增工作量不大,此处直接使用HW3完成后的类图):

在这里插入图片描述

从第一次作业到第二次作业的跨度对于我个人来说是比较大的,虽然能够在第一次作业的架构上做修改而继续沿用,但会有很多细节问题出现,以下也会提及。

第二次作业主要在第一次的基础上新增了指数函数自定义函数。我在完成本次作业时,先实现了自定义函数的部分,因为我们采用的是递归下降的分析策略,因此后续引入的指数函数不会对其造成影响。

自定义函数

从本质上看,自定义函数实际上就是待展开的Expr,实现的主要思路便是映射+替换

为了降低耦合,我创建了一个新的类FunctionHandler对自定义函数进行存储、解析与映射,返回一个已展开、待解析的Expr

自定义函数声明时,将函数标识符f g h与函数定义式和形参列表分别建立映射,等待后续使用;当解析过程进行到自定义函数因子时,将实参映射到形参,通过toString进行字符串的替换来实现。

// 伪代码
funcDef.put(f/g/h, String expression);
formalParameterList.put(f/g/h, ArrayList<String> formalParameterList);
···
// 实参toString后替换形参
for (int i = 0; i < formal.size(); i++) {
            funExpression = funExpression.replaceAll(formal.get(i), actual.get(i).toString()); 
}

最终函数因子内部只需要接受完全展开的Expr,并借助parseExpr即可完成解析。

在使用字符串替换时,有几处细节需要特别注意:

  • 可能出现二次替换的情况,如f(z,y,x)=x+y+z f(x,x^2,x^3),我选择先进行形参替换z->a y->b x->c,再把替换之后的“新形参”a、b、c替换成实参,即可解决此问题
  • 可能会出现e'x'p中的x被误换的可能,我使用了exp->e->exp的前后替换来规避这个问题
指数函数
Mono的修改

新增指数函数后,原有Mono基本形式已不再适用,但好在第一次作业架构的扩展性较好,我们可以直接新增Mono的属性来得到新的多项式结构:
P ( x ) = ∑ a ∗ x b ∗ e x p ( Q ( x ) ) c = P ( x ) = ∑ a ∗ x b ∗ e x p ( c ∗ Q ( x ) ) P(x) = ∑a*x^b*exp(Q(x))^c=P(x) = ∑a*x^b*exp(c*Q(x)) P(x)=axbexp(Q(x))c=P(x)=axbexp(cQ(x))
此时Mono的属性变为:

private BigInteger coe; // 系数
private BigInteger exp; // 幂函数指数
private Poly factorPoly; // 展开后的exp内的多项式

为了方便后续的合并,我在构造时默认factorPoly = null,如果是指数函数因子则调用setExponent方法,同时将指数乘入exp内部,统一化形式。

小重构

在确定Mono的基本形式后,便迎来了此次作业的重头:Poly的小重构。由于基本单元发生变化,我们不得不重写Poly内的运算逻辑,而此时我在第一次作业中选择的ArrayList<Mono>的劣势也显现了出来:

// Mono.java
exp.equals(mono.getExp()) && factorPoly.equals(mono.getExponent())
// Poly.java
poly.equals(poly1.poly) // 容器为ArrayList

Mono的相等判断应为expfactorPoly是否相等,此时无需考虑系数相等;factorPoly的相等判断的是ArrayList<Mono> poly内每一个Mono是否相等,如果此时仍不考虑系数相等,则会出现exp((3*x)) + exp((4*x)) = exp((3*x))的情况,factorPoly内的内容被认为是相等的,这显然是不合理的。

究其原因,便是容器选择的不恰当,因此我选择使用HashMap<Mono,BigInteger>来实现Monocoe的同时存储,这样保证了MonoPoly比较逻辑的分离与统一,便不会出现以上的情况,但是需要重写所有的运算方法,这也是我在架构上的一些不合理之处。

深克隆

此外,在本部分的实现过程中,我遇到了深克隆浅克隆的问题。由于在设计过程中不全面的考量,在一些地方出现了浅克隆(引用)导致的本需要维护的对象在计算过程中内容被修改的情况。为了解决这一问题,我选择将所有计算结果都存放在新建的对象中,即算一个新建一个,也因此设计了多个构造函数。

 public Poly() // 原始构造
 public Poly(HashMap<Mono, BigInteger> targetPoly) // 复制元素
 public Poly(int a) // 常数Poly
复杂度分析

在这里插入图片描述

在此我们只选取了部分复杂度较高的、较重要的方法,未展示的方法复杂度在合理范围内。

我们可以发现,此处与第一次作业的高复杂度方法有较高的重叠:

  • Mono.toString复杂度是断层的高!这也是第一次作业为自己埋下的隐患,将字符串输出与化简集合在同一个方法内导致复杂度爆升。为了化简引进的Mono.getStrMono.isFactor等方法由于进行了大量分类讨论特判,也具有很高的复杂度,这也为我后续出现bug埋下了伏笔。。。

由于Mono结构的改变,导致PolyMono耦合度大大上升,但由于二者本身具有很高的相关性,我暂时没有想到很好的解决方法,这也造成了我在本次作业完成时经常陷入逻辑的混乱(如嵌套边界)。

HW3

第二次到第三次作业的迭代,我只新增了一个求导因子类以及MonoPoly的求导方法,整体的思维量和工作量并不大。

由于一贯沿用了递归下降的分析思想,嵌套函数的实现在HW2就已经完成。

在前面的作业中,我们知道——实现了Factor的类,实际上是能够实现toPoly的最小单元,即Factor的最终归宿就是Poly。因此求导因子的引入便可以转化成对Poly进而对Mono的求导方法的实现,而Mono的求导方法只需要使用高中求导知识加以一些分类讨论来实现。
( a ∗ x b ∗ e c ) ′ = a ∗ ( b ∗ x b − 1 + x b ∗ c ′ ) ∗ e c (a*x^b*e^c)' = a* ( b*x^{b-1} + x^b *c' )*e^c (axbec)=a(bxb1+xbc)ec

复杂度分析

本次仅引入了两个方法,其复杂度如下:

methodCogcev(G)iv(G)v(G)
Mono.deriveMono5333
Poly.derivePoly1122

可以看到新方法的数量与复杂度并不高,这也是前两次作业较为合理的框架所带来的优势。

架构设计体验

三次作业迭代成型过程以及小重构在上面均已提及。此处主要再次分析架构的可扩展性。

畅想几个新的迭代场景:

  • 引入新因子:三角函数因子

    • 新建类因子->修改Mono结构->新增解析方法parseFactor->实现接口方法toPoly->修改Poly内合并计算方法->优化输出。
  • 引入更多变量:x y z

    • 对于更多的变量标识符,只需在变量因子内新增var_name属性,并在词法分析时仿照对x的处理,进行类似的过程复现即可。
  • 自定义函数中引入dx

    • 因为在解析自定义函数的过程中会同时实现对dx的解析,对于我当前的架构不需要做出过多的调整。

Mono-Poly的架构下,基本可以保证迭代时设计思路的流畅性,但相关细节可能还需具体分析。

个人bug分析

bug复现与分析

本人在第二次强测中挂掉了两个数据点,分别为:

  • 指数未使用BigInteger exp导致极端数据爆int范围
  • 简化时将exp内的负系数提到了指数部分,导致指数部分出现了负数形式exp((-x)) -> exp(x)^-1

针对第一个bug,是由于本人考虑不全面以及思维上的惰性导致的。由于在HW1中明确不存在嵌套括号,因此我选择使用int型变量储存Mono的指数(当时可以保证不会出现范围爆炸)。但在进行HW2的书写时我过分着眼于其他部分的架构而忽略了这个细节。

对于第二个bug,是由于本人在尝试对exp内因子化简时考虑不全面,盲目地进行exp((a*x^b)) -> exp(x^b)^a的化简,从而误将a<0的情况忽略导致提出了负指数。

综合来看,自己的程序出现bug的根源是设计与测试的不充分。如针对bug1的极端数据和bug2的基本数据都没有考虑到,后者是在提交前几小时完成的,完成后直接交给评测机跑了几百次数据,没有出错便认为万事大吉,也是自己思想惰性的体现。

复杂度考量

第一处bug是由于架构上的考虑不充分导致的,并不涉及特定的类或方法;第二处bug主要存在于本人所写的指数函数括号优化方法Mono.getStr与结果输出方法Mono.toString中,这两个方法的复杂度可谓是灾难级别的,基本占据了全部复杂度的大头,未出现bug的方法的规模相比之下可谓微乎其微。

methodCogCev(G)iv(G)v(G)
Mono.getStr1161010
Mono.toString5312022

在OO交流群里,看到有同学提到化简部分不应存在于toString内部,在此处我是认同的 (这一堆又臭又长的代码自己都没有看下去的欲望)。将化简与输出逻辑做拆分,对于不同情况新建方法处理,可以有效降低复杂度,提高可读性。而对于化简部分的具体写法,由于本人愚钝,目前没有更简洁明了的写法。

hack策略

在三次互测中,本人均成功hack了同房间的同学。起初hack时试图使用评测机跑出被测程序的bug,评测机生成的数据虽然足够随机,但是不够特殊,实际上测出bug的数据基本都是手搓的。

// HW1
(10000*x)^5
// HW2
0
exp((-2*x))
// HW3
2
g(z)=exp(exp(exp(exp(z))))
f(y)=exp(exp(exp(exp(g(y)))))
f(exp(exp(exp(exp(x^8)))))

在第三次作业的hack中,我突然发现,在本地运行的稍微卡顿的数据,在评测机中会超时! 而对于TLE数据的构造,则可以借用exp低cost的特点以及自定义函数来构造接近cost极限的数据,从而更可能发生TLE的hack。比如舍友砍出的惊天一刀,正是对这一策略的完美诠释。

2
g(z)=exp(exp(exp(exp(z))))
f(y)=exp(exp(exp(exp(g(y)))))
f(g(exp(exp(exp(exp(x^8))))))

关于hack策略:

  • 鉴于每位同学的代码都具有一定规模,且实现思路都有所差异,逐行分析试图读懂是不现实且费时的。因此在hack前对同房间代码做快速浏览,只需得知其基本架构如何;随后选取关键代码部分(如变量定义类型、化简方法)尝试阅读,尝试发现隐藏的bug;
  • 在平时作业的完成过程中,注意积累检测出自己bug的、典型的测试数据,用这些数据去尝试hack,可能会收获惊喜。
  • 正如被人被测出的bug,构造测试数据应保证全面且特殊,不可忽略边缘数据

优化策略

HW1的优化较为简单,在第一部分也已提及。

由于本人HW2花费的时间过长,最后剩余用来优化的时间并不充足,也只是在一个下午赶出了最基本的几处优化:

  • exp内部因子为常数、系数为1的幂函数、指数函数时,去掉多余括号
  • 将形如exp(a*x^b)优化为exp(x^b)^a

但正是第二部的化简让我在强侧和互测中狠狠吃亏:我在提取a时忽略了其为负数的情形,导致提出后的表达式出现了负指数的情况。 一开始评测机似乎忽略了这一点,导致我没有被hack成功。 因此在之后我便选择了求稳没有做进一步的化简。

心得体会

第一单元对于本人来说可谓是地狱般的折磨。

我由于在上学期申请了缓考,开学前所有时间都花费在了复习上,直至第一周周中才结束考试。此时的我对表达式展开、递归下降没有任何的概念,身边的同学怨声载道,吐槽作业难度之大,这无疑对我造成了巨大的精神压力,期间我也一直在怀疑能否在规定时间内完成作。好在最终也是压线提交,长舒一口气。

不过第二次作业接踵而至,再一次迎来了吃饭睡觉写OO的生活,这一次遇到了更大的困难和更多的bug,没日没夜地调试,让我有了上学期计组实验的感觉。期间不止一次地陷入自我怀疑,不过看着自己的架构逐渐丰满完善,bug被一个个消除,不断调整心态、接纳自己,纵使过程是痛苦的,却也是值得铭记的。

第一单元的学习也算圆满结束,收获良多。面向对象的学习道阻且长,仍需继续努力。

未来方向

  • 希望平衡下作业难度跨度,第二次作业压力拉满,第三次作业只新增了四十多行,感觉有些极端了()
  • 适当放宽hack的数据限制,鼓励合理且更有创造性的测试数据
  • 19
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值