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)=∑a∗xb
经过上面的过程,我们可以得到一个被解析完毕的Expr
,依照形式化描述对其进行自下而上的toPoly
的转化,并在转化过程中实现合并同类项,至此我们便可将问题转化成各个因子toPoly
的实现与多项式运算这两个部分。为了实现多项式运算,我选择在Poly
中创建容器ArrayList<Mono>
,但在后续作业中发现此处ArrayList
的使用对后续扩展性产生了较大的影响(HW2的修改)。对多项式运算的实现包括加法、乘法、乘方运算,难度并不大,但需要在进行addPoly
时及时做号合并,否则可能会出现OutOfMemory
的情况。
流程梳理
-
对输入的字符串进行预处理,删除空格,合并多余的±号
-
使用
Lexer
和Parser
对表达式各部分进行递归下降解析,获得解析完毕的表达式 -
将解析完毕的表达式从下到上转化成
Poly
形式,并进行toString
重写输出 -
在输出时做最基本的优化:
- 正项提前
- 系数为±1、0,指数为0、1的分类处理
复杂度分析
使用MetricsReloaded
,得到主要方法的复杂度如下(未列出方法的复杂度与最后一项完全一致):
分析可知,大部分方法的复杂度都在合理范围内,但仍有个别方法复杂度超标。
Parser
部分在对各种因子解析时,需要先判断因子所属类型再进行各自的解析。我没有将其拆分成不同类型因子解析的方法,而是堆砌在一起,形成了较为臃肿的结构。又由于我将符号提取成Term
的一个属性,因此在parseExpr
和parseTerm
部分都需要进行特判提取符号,造成复杂度的升高。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)=∑a∗xb∗exp(Q(x))c=P(x)=∑a∗xb∗exp(c∗Q(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
的相等判断应为exp
与factorPoly
是否相等,此时无需考虑系数相等;factorPoly
的相等判断的是ArrayList<Mono> poly
内每一个Mono
是否相等,如果此时仍不考虑系数相等,则会出现exp((3*x)) + exp((4*x)) = exp((3*x))
的情况,factorPoly
内的内容被认为是相等的,这显然是不合理的。
究其原因,便是容器选择的不恰当,因此我选择使用HashMap<Mono,BigInteger>
来实现Mono
与coe
的同时存储,这样保证了Mono
与Poly
比较逻辑的分离与统一,便不会出现以上的情况,但是需要重写所有的运算方法,这也是我在架构上的一些不合理之处。
深克隆
此外,在本部分的实现过程中,我遇到了深克隆浅克隆的问题。由于在设计过程中不全面的考量,在一些地方出现了浅克隆(引用)导致的本需要维护的对象在计算过程中内容被修改的情况。为了解决这一问题,我选择将所有计算结果都存放在新建的对象中,即算一个新建一个,也因此设计了多个构造函数。
public Poly() // 原始构造
public Poly(HashMap<Mono, BigInteger> targetPoly) // 复制元素
public Poly(int a) // 常数Poly
复杂度分析
在此我们只选取了部分复杂度较高的、较重要的方法,未展示的方法复杂度在合理范围内。
我们可以发现,此处与第一次作业的高复杂度方法有较高的重叠:
Mono.toString
复杂度是断层的高!这也是第一次作业为自己埋下的隐患,将字符串输出与化简集合在同一个方法内导致复杂度爆升。为了化简引进的Mono.getStr
与Mono.isFactor
等方法由于进行了大量分类讨论特判,也具有很高的复杂度,这也为我后续出现bug埋下了伏笔。。。
由于Mono
结构的改变,导致Poly
与Mono
耦合度大大上升,但由于二者本身具有很高的相关性,我暂时没有想到很好的解决方法,这也造成了我在本次作业完成时经常陷入逻辑的混乱(如嵌套边界)。
HW3
第二次到第三次作业的迭代,我只新增了一个求导因子类以及Mono
和Poly
的求导方法,整体的思维量和工作量并不大。
由于一贯沿用了递归下降的分析思想,嵌套函数的实现在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
(a∗xb∗ec)′=a∗(b∗xb−1+xb∗c′)∗ec
复杂度分析
本次仅引入了两个方法,其复杂度如下:
method | Cogc | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Mono.deriveMono | 5 | 3 | 3 | 3 |
Poly.derivePoly | 1 | 1 | 2 | 2 |
可以看到新方法的数量与复杂度并不高,这也是前两次作业较为合理的框架所带来的优势。
架构设计体验
三次作业迭代成型过程以及小重构在上面均已提及。此处主要再次分析架构的可扩展性。
畅想几个新的迭代场景:
-
引入新因子:三角函数因子
- 新建类因子->修改
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的方法的规模相比之下可谓微乎其微。
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Mono.getStr | 11 | 6 | 10 | 10 |
Mono.toString | 53 | 1 | 20 | 22 |
在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的数据限制,鼓励合理且更有创造性的测试数据