[BUAA-OO] 第一单元总结
前言
第一单元的主题是表达式括号展开及化简,主要考察了对java基本语法的应用以及面向对象的基本思想。本单元共有三次作业——单变量多项式的单层括号展开,含有指数函数exp()和自定义函数的单变量多项式的多层嵌套括号展开,以及求导功能。
三次作业是增量开发的形式,两次作业之间的代码复杂度大大递增,如果在完成第一次作业的时候没有一个可扩展性强的架构,就会像我一样在hw2的时候束手无策不得已选择重构,因此在第一次作业的时候就应该建立一个正确的架构,充分考虑后面两次迭代可能的功能扩展,这里强力推荐在建立最初架构的时候看一下学长学姐的博客。在这篇总结里我将对三次作业的要求以及我的代码进行分析,并结合本人心得体会进行总结。
第一次作业分析
写在前面:!!!这次架构非常不好,代码复杂度高且混乱,建议不要采用!!!
第一次作业主要是关于单变量多项式的展开,其中的因子只有三种:幂函数,常数因子和表达式因子
这是UML
类图:
简单分析本次作业的要求后我们不难发现,我们表达式主要包含三部分:Expr
,Term
,Factor
,而Factor
又有三种:幂函数,常数因子和表达式因子,基于面向对象的思想,我们要对这些类型分别建类。
在作业指导书中提供了两种架构思路,分别是正则表达式解析字符串和递归下降,递归下降对我来说是个新概念,在查阅相关资料后,我理解了递归下降的主要思路:即对输入表达式进行三个层次的抽象,分别是表达式层次、项层次和因子层次,层层嵌套,在处理时通过递归的方式逐层深入处理,直到处理到因子层次时,根据所遇到的因子类型再选择不同的解析方法,然后返回解析完的因子,递归结束。于是我们建类Parser
和Lexer
。
这一架构对比正则表达式有很大优势,尤其体现在hw2和hw3的迭代开发上。首先是这种架构逻辑层次十分清晰,在架构时仅仅关注方法的功能进行设计,而暂时不必关注方法的实现细节;其次是代码的可扩展性强,尤其是体现在后两次的迭代上,我们可以仅仅通过新增新的Factor
因子以及修改接口来解决,并且已经实现了类似括号嵌套的问题。
最后为了实现化简,我们建立Simplify
类,在字符串层面对展开后的表达式进行化简。
但本次架构也存在不少缺点,即将对多项式的相加和相乘等计算杂糅进Term
类的addFactor
方法中,导致代码复杂度大大提高,同时耦合性降低,内聚性提高,不利于后续的迭代开发,因此我在hw2中进行了重构。
实现细节
表达式预处理
首先对原始表达式进行预处理,包括删除空白符
、将+-
变成-
、++
变成+
等处理:
for (int i = 0; i < 4; i++) { //+++->++ 若只有一次
expr = expr.replaceAll("\\+\\+", "+");
expr = expr.replaceAll("--", "+");
expr = expr.replaceAll("\\+-", "-");
expr = expr.replaceAll("-\\+", "-");
expr = expr.replaceAll("\\^\\+", "^");
expr = expr.replaceAll("\\*\\+", "*");
}
(bug分析:注意到这里为什么需要循环进行预处理呢,这里是因为在原表达式中也有可能出现+-+
等三个符号连在一起的情况,若只进行一次处理是无法化为最简状态的。)
层层解析
接着从表达式层开始递归下降解析:
public Expr parseExpr(){
//...
expr.addTerm(parseTerm);//解析第一项
while (lexer.peek().equals("+") || lexer.peek().equals("-")) {
expr.addTerm(parseTerm());//循环处理每一项
}
return expr;
}
public Term parseTerm(){
Term term = new Term();
processTerm(term);//处理第一个因子
while (lexer.peek().equals("*")) {
//...
processTerm(term);//循环处理所有因子
}
return term;
}
public Factor parseFactor(){
if (lexer.peek().equals("(")) {
//解析表达式因子
return expr;
} else if (Character.isLetter(lexer.peek().charAt(0))) {
//解析变量因子
return var;
} else {
//解析常数因子
return new Num(total);
}
}
表达式的toString方法
在解析完表达式以后,利用该方法转化为字符串:
public String toString() {
StringBuilder sb = new StringBuilder();
if (!terms.isEmpty()) {
for (int i = 0; i < terms.size(); i++) {
if (i != 0) {
sb.append("+");
}
sb.append(terms.get(i).toString());
}
}
return sb.toString();
}
字符串化简(Simplify)
最后通过对字符串进行直接处理,以达到化简的目的。
首先可以发现,每一项都有一个共同的结构:
a
x
b
ax^b
axb
因此我利用HashMap
来存储每一项,其中key
为指数(b),value
为系数(a),在读到一项时判断map
中是否有相同指数的项,如果有因数相加,否则将该项加入到map
中继续读下一项:
HashMap<Integer, BigInteger> map = new HashMap<>();//key:指数,value:系数
//...读本项的系数sum和指数xnum
int flag = 0;
for (int key : map.keySet()) {
if (xnum == key) {
BigInteger num = map.get(key).add(sum);
map.replace(key, num);
flag = 1;
}
}
if (flag == 0) {
map.put(xnum, sum);
}
//...将map里存的数据输出成字符串
(用hashmap
的方法对于第一次作业是极其高效的,因为我们只需要两个变量就可以精确描述一项,但是其可扩展性并不强,在第二次作业中我放弃了在最后集中化简字符串的方法,而是在过程中基于多项式进行化简)
最后解析map
中的数据将其输出为字符串,即可获得最终展开后的化简结果。(这里仍需关注系数、指数为1和0的化简情况)
代码复杂度分析
可以看到Simplify.oper()
,Simplify.set()
,Simplify.simplifyExpr()
的方法复杂度较高,这是因为在输出化简后字符串时需要考虑多种化简的情况,如系数为1即可省略;Term.addFactor()
和Term.dealwith()
的方法复杂度很高,其中后者竟达到惊人的53,这是因为我在往项中加入因子的时候,需要判断四种情况:单项式*单项式、单项式*多项式、多项式*单项式、多项式*多项式,因此方法判断逻辑较为复杂,且可读性和可扩展性差,在hw2中被我舍弃了。
bug分析
本次作业没有明显的bug,在强测和互测中都取得了不错的成绩。在互测中也没有发现别人的bug,房间内没有人hack成功。
优化
本次作业在给评测机测试的时候发现,对于某些cost较高的数据,程序运行速度十分缓慢,很可能在强测中发生TLE或RE的错误,鉴于此我对程序进行了优化。
首先分析程序运行缓慢的原因。这是因为我在展开的过程中并没有进行任何的合并同类项,而是简单的add,这就造成面对大cost数据时,在化简前某些term的长度十分冗长,而又因为我在addFactor时的时间复杂度是 O ( n 2 ) O(n^2) O(n2),这就导致程序运行时间大大增加,甚至可能报内存溢出的错误。
找到了原因,接着就可以开始进行优化了。思路很简单,就是在展开的过程中进行合并同类项,删除系数为0的项即可。为了实现这一目的,我创建了一个HashMap记录每一项的系数和幂(key为幂,value为系数),把所有同指数的项进行合并,最后将HashMap中存储的数据输出给Term就可以了
完成优化后再进行测试,发现运行速度大大提高,同时也保证了程序正确性,于是本次优化成功完成。
但值得注意的是,本次优化并不具有好的可扩展性,这一点在我做hw2时就体现出来了,hashmap并不能很好的存储exp的内容,所以这种方法在hw2中便行不通了了。
第二次作业分析
本次作业在第一次的基础上增加了指数函数因子和自定义函数因子,复杂度进一步提高。
由于第一次作业我将多项式的加法乘法包括合并同类项糅合进对项的处理中,导致代码可扩展性大大变差,苦苦挣扎过后,我打算采用更简洁易懂的Poly
、Mono
架构进行重构,以下是我的UML
类图:
分析这次作业要求可知,新增了指数函数因子和自定义函数,基于面向对象的思想,于是我们新增ExpFactor
类和Function
类。
此外为了更方便、清晰地处理表达式展开,我们新建Poly
多项式类和Mono
单项式类,统一处理所有的加减乘以及乘法的计算问题,这也是相对于hw1最关键的改变,这个改变让我们的架构的可扩展性十分强,这在hw3中得到了充分的体现。
对hw1的重构
解析表达式转化为语法树结构
这里采用学长学姐们大力推荐的递归下降方法,通过parseExpr
,parseTerm
,parseFactor
方法逐步解析表达式、项、因子,将其转化为树形结构,返回一个解析好的Expr
类变量,此步骤的目的是将一串毫无逻辑的字符串转化为有树形关系的一系列Factor
的组合,便于后续转化成为多项式。
转化为多项式进行运算
可以观察到,任何表达式都是一个有一个或多个单项式组成的多项式,可以写成:
∑
a
x
b
\sum{ax^b}
∑axb
因此我们新增单项式类Mono
,其成员变量为:
private BigInteger coe;//系数
private BigInteger exp;//指数
接口Factor
有一个toPoly
方法,目的是将各类因子转化为多项式,进而参与上述多项式的加法乘法和乘方:
public interface Factor {
Poly toPoly();
}
而在多项式Poly
类中则是包含单项式类的链表,包括addPoly
,mulPoly
,powPoly
等多个方法对多项式进行计算,最后通过toString
方法将处理好的多项式转化为字符串输出:
private ArrayList<Mono> monoArrayList;
//.......
public Poly addPoly(Poly poly){...}
public Poly mulPoly(Poly poly){...}
public Poly powPoly(int exponent){...}
public String toString(){...}
hw2新增
第二次作业在第一次作业的基础上新增了指数函数及自定义函数的扩展以及括号可嵌套的要求,这时候新架构的优势就大大体现出来了,此时我们事实上已经完成了括号嵌套的要求。
关于指数函数
我们新增一个ExpFactor
类,其成员变量为:
private Factor factor;//括号内的因子
private BigInteger exponent;//指数
在解析表达式的时候,如果遇到指数函数,只需进一步解析里面的因子,将其作为factor
,返回一个新的ExpFactor
型变量即可:
public Factor parseExpFactor() {
//...
Factor factor = parseExpr();
//...
if (lexer.peek().equals("^")) {
//读指数部分
return new ExpFactor(factor, exp);
}
return new ExpFactor(factor, BigInteger.ONE);
}
(debug:注意到对因子的解析我竟然用了parseExpr()
而不是parseFactor()
,是否是多此一举呢?其实这里是由于我设计上的小缺陷,如果我是简单的parseFactor()
,若遇到数据:
1
f
(
x
)
=
e
x
p
(
x
2
)
f
(
x
)
1\\f(x)=exp(x^2)\\f(x)
1f(x)=exp(x2)f(x)
只能返回对应的实参(可见后文自定义函数的实现)而不再解析后面的指数部分,于是我干脆将其视为一个表达式进行解析就巧妙地化解了这个问题)
不难发现,此时单项式已经不是之前的指数加系数的简单结构了,此时的单项式可以写为:
a
x
b
e
x
p
(
P
(
x
)
)
ax^bexp(P(x))
axbexp(P(x))
可以发现,对比hw1
的单项式结构只多了一个P(x)
的可变因子(或者我们可以说是一个多项式),因此我们可以给单项式新增一个Poly
型变量,此时只需三个变量即可精确描述一个单项式:
private BigInteger coe;//系数
private BigInteger exp;//指数
private Poly inside;//指数函数的幂
而针对Poly
的加法和乘法也需要对应修改
对于加法只有当b
和Poly
对应相等时才可以进行合并同类项:
if (exp1.compareTo(exp2) == 0 && inside1.equals(inside2)) {
result.getMonoArrayList().set(result.getMonoArrayList().indexOf(mono1),
new Mono(coe1.add(coe2), exp1, inside1));
}
这里就不得不提到判断两个多项式是否相等的问题了,我的方法非常笨,即一一判别单项式是否对应相等:
for(Mono mono1:this.monoArrayList){
for(Mono mono2:poly.getMonoArrayList()){
if(mono1.equals(mono2))
......
}
}
对于乘法,系数相乘,指数相加,Poly
相加即可:
tmpmonolist.add(new Mono(coe1.multiply(coe2),
exp1.add(exp2), inside1.addPoly(inside2)));
这样我们就实现了指数函数的扩展。
关于自定义函数
首先新增Function
类,主要是完成函数定义式的存储处理以及解析并完成形参和实参的传递。
在解析待展开表达式时若遇到函数名,即对其后的因子进行解析,作为实参传递给Function
,进一步即可开始对函数定义式的解析:
public Factor parseFuntion() {
...//定义因子a,b,c,获取函数名name
a = parseExpr();
if (lexer.peek().equals(",")) {
//...
b = parseExpr();
}
// 同理读c
return Main.getFunction(name).toExprFactor(a, b, c);
}
针对对函数定义式的解析,这里我采用了占位符的思想,将函数定义式中的x
,y
,z
分别替换为特殊字符!
,@
,#
(坑点:注意不要将exp
->e!p
),当解析时遇到特殊字符时,将解析好的实参替代即可:
if(...){...}
else if (lexer.peek().equals("!")) {
//...
return xfactor;
} else if (lexer.peek().equals("@")) {
//...
return yfactor;
} else if (lexer.peek().equals("#")) {
//...
return zfactor;
}
这样我们就基本完成了hw2
的设计。
代码复杂度分析
从代码复杂度分析数据可以看到,所有方法的复杂度均在合理范围内。
不难发现Parser.parseFactor()
和Poly.if2()
的方法复杂度较高,前者主要是由于因子类型的多样型,导致判断逻辑更为复杂;而后者由于涉及exp()中的括号优化问题而复杂度较高。
bug分析
这次作业发现了一个很低级的bug,导致在强测中被扣分了,在互测中被狠狠hack了。问题主要出在优化上,位于Poly
的toSrting()
方法里,具体原因是-x
不是个因子,因此exp(-x)
是不符合输出规范的应该加一层括号为exp((-x))。事实上这个错误完全可以通过无脑加双层括号来避免,只是我过于追求性能分,忽略了正确性的重要性,这也提醒我在后续的优化过程中一定要在保证程序的正确性的基础上进行优化,当不能保证正确性时,可以适当放弃性能分,有舍才有得。
优化
本次作业我仅仅针对exp的多层/单层括号进行优化,尽管如此还是在强测中有些数据点性能得分很低甚至为0,甚至产生了错误,这也让我追悔莫及,于是我在hw3中在保证正确性的基础上针对性能做了优化。
参考
Hyggge’s Blog 学长博客写的很好很详细
第三次作业分析
本次作业在hw2的基础上新增了求导功能以及自定义函数可嵌套定义的要求,而多亏了hw2的良好架构,这次作业只需要修改寥寥几处即可完成功能,本次作业的完成时间少于两个小时,相比前两次作业来说简直不要太舒服。(这也让我更加深刻地理解一开始的良好架构对后续迭代开发的重要影响)
对架构的可扩展性进行进一步分析,考虑到以后可能的迭代情景,例如加入求积分的功能,只需要同求导一样修改Factor接口,为每个因子加入对应的求积分方法即可;又例如若要加入三角函数,只需要新增一个三角函数因子,并且修改单项式的构成,新增一个多项式类型的成员变量表示三角函数括号内的表达式因子,另外修改多项式的加减乘法即可。因此可以看出本次作业的架构是具有很好的可扩展性优势的。
这是UML
类图:
此次作业相比hw2只增添了一些方法,最关键的是Factor
接口新增了求导derivative
的方法,此外就是Poly
类中新增了求最大公因数的相关方法。本次作业没有新增类。
关于自定义函数可嵌套
得益于hw2的优秀的架构,事实上这个功能我们已经完成了,我们在解析函数定义式时,实际上是对表达式因子的解析,所以当在解析函数定义时遇到函数时会跳转到对该函数的解析,因此我们已经完成了嵌套定义的要求~
关于求导
我们可以观察到,每个不同的因子都有且有不同的求导方式,因此我们可以把求导抽象成一个共同的方法,放在因子接口中:
public interface Factor {
Poly toPoly();
Expr derivative();
}
接下来我们只需要为每个因子添加上对应的求导方式,如对常数因子求导为0,对幂函数 x a x^a xa求导为 a x a − 1 ax^{a-1} axa−1等等。需要注意的是,对表达式的求导就是对每一项求导取和,对项的求导应该用到链式法则,伪代码如下:
public Expr derivative() {
if (this.factors.size() == 1) {
terms.addFactor(factors.get(0).derivative());
return new Expr(terms);
} else {
//链式法则
terms.addFactor(factors.get(0).derivative);//前导
terms.addFactor(factors.get(others));//后不导
terms.addFactor(factors.get(0));//前不导
terms.addFactor(factors.get(others).derivative);//后导
return new Expr(terms);
}
}
对因子的求导即跳转到对各类因子求导的不同方法,至此求导功能已完成。
优化
在hw2的强测中,我观察到部分强测点的性能分很低甚至为0,观察这些强测点数据,发现他们的exp函数中都有一个较长的公因子,性能较高的方法是将其最大公因数提出作为exp的指数,正当我为求最大公因数而绞尽脑汁时,我的好舍友告诉我java竟自带求最大公因数的方法gcd()
,于是事情瞬间变得明朗起来了。
首先我们需要求出exp内各个单项式系数的最大公因数,注意指数不能为负数,因此我们要对系数取绝对值abs()
再用gcd()
方法:
public BigInteger getGcd() {
if (只有一个单项式&&指数为0&&无指数函数) {
return BigInteger.ONE;
} else {
BigInteger result = this.monoArrayList.get(0).getCoe().abs();//取系数的绝对值
for (int i = 1; i < this.monoArrayList.size(); i++) {
result = result.gcd(this.monoArrayList.get(i).getCoe().abs());
}
return result;
}
}
然后我们还需要对其中每个单项式除以最大公因数:
for (int i = 0; i < this.monoArrayList.size(); i++) {
Mono pastmono = this.monoArrayList.get(i);
Mono mono = new Mono(pastmono.getCoe().divide(exponent),
pastmono.getExp(), pastmono.getInside());
monos.add(mono);
}
最后我们只需要修改toString
方法,将指数加至字符串里即可。
经过测试此次优化能够保证程序的正确性,至此我们完成了hw3的功能扩展以及基础的优化。
代码复杂度分析
由于这次修改量不大,同hw2的复杂度大致相同,其中Poly
中的equals
方法复杂度爆红,主要原因是因为多项式中存在有单项式和无单项式两种情况,存在许多特判情况,因此判断两个多项式相等的逻辑更为复杂;同时Poly
中的if2
方法复杂度也爆了红,这是因为加入了提取指数的优化。这些地方是不足之处也是待改进的地方。
整体的代码复杂度:
bug分析
本次作业在互测和强测中都没有发现bug,取得了较为不错的成绩。(观察到同房间的同学利用exp嵌套求导来卡时间进行hack,不得不说,我怎么没想到呢! )
互测心得
在hw2的互测里我发现了别人的5个bug(虽然也因为自己的bug被狠狠hack了):
我的互测策略主要是评测机+修改的策略。先用评测机对所有人的代码进行大规模的测试,当发现别人的潜在bug时,分析输入输出数据,找到该bug可能的形成原因,再基于形式化表述和cost的限制对输入数据进行修改,以达到符合合法化要求。
这样的好处是可以利用评测机进行广泛测试,不需要一个一个去阅读他人的代码以发现bug。当我们确定大概的bug原因时,可以带有指向性地阅读成员代码,来寻找bug的原理,据此编写更具有指向性的数据提交。
第一单元心得体会
在开学后的三四周里,强度已经拉满了,前一天还沉浸在放假的轻松愉悦中,后一天就因为看不懂题目而苦恼焦虑。虽然前两周几乎每天都在被OO作业折磨,导致我一度精神压力很大,每天都在担心做不完怎么办,特别是hw2进行重构的那一周。但是在回顾一路走来的过程中也收获了许多,成就感满满。经过一单元的学习,我有以下的经验和体会,希望在后续单元的学习中能够更加得心应手:
- 第一次作业的架构十分重要,如果不想在后续作业的迭代里迫不得已进行重构,就必须在第一次架构里设计扩展性强、高内聚低耦合的程序。建议充分阅读学长学姐的博客,了解后续可能新增的功能,并学习学长学姐的优秀架构。
- 重构十分痛苦,但是又是很难避免。作为初学者,我们很难做到一开始就能有良好、可扩展性强的架构,因此重构往往不可避免也并不丢人。在重构的过程中,往往能增强对题目的理解,因此我们写程序会更加顺利和快速,其实重构所花的时间并没有那么多,还能为后续的迭代开发奠定良好的架构基础,因此当你觉得目前的程序架构确实难以完成新的功能扩展时,不要犹豫,立刻重构。
- 进行充分测试。不要认为过了中测就是过了,不要认为经过部分评测机的测试就过了,你的程序里可能仍存在bug,如果不想在互测和强测里丢分,就请对你的程序进行大量的、充分、含有特殊数据的测试。(hw2血泪史www)
- 课上实验也请对代码进行充分阅读,不要认为过了样例就是过了,虽然难度不大,但是也请不要放松警惕!
- 研讨课是个很好的听取他人优秀想法的机会,在这里你可以听到大佬们的优秀的思路和架构,如果你理解了吸收了,就可以运用到自己的代码中。
- 先导课对我的帮助非常大!在先导课里我掌握了基础git的使用,课程平台的使用,java的基本语法以及面向对象的基础思想,这让我面对作业时上手更快,对我完成第一单元的作业有莫大的支持。
以上便是第一单元的全部内容,总体来说完成的不错,希望在后续单元里再接再厉!(ps:终于可以小小放松一下了…)