前言
在三次迭代一章包含了许多繁琐的细节,新增的内容在文章结尾
- 复杂度、依赖度分析使用IDEA的插件MetricsReloaded
- 代码量分析使用IDEA自带的Statistic
- UML图绘制使用StarUML绘制
题目说明
面向对象编程第一单元的主题为展开、化简并输出一个关于x的表达式
- 第一次迭代:加、减、乘、乘方四种运算方法
- 第二次迭代:允许多层括号,加入指数函数与自定义函数
- 第三次迭代:自定义函数定义方式拓展,加入求导运算
三次迭代
本章内容为作者在北航面向对象编程平台上的三次作业的总结博客汇总而成,仅做了一些更正与添加。笔者认为它们最能代表每次迭代中笔者的想法,因此将它们放在这里,作为记录,也作为纪念。
本章包含了架构设计与程序分析
第一次作业
设计与架构
本次作业中,我们需要展开一个关于x的表达式中所有的括号并尽可能将其化为最简形式
笔者将这一需求细化为四个任务:
-
预处理表达式
-
解析表达式
-
展开&化简表达式
-
输出表达式
架构图
预处理表达式
笔者建立了Preprocess
类对读入的字符串进行预处理,有下列功能
-
消除所有的空白(调用
String
类下的replaceAll() 方法) -
消除连续出现的
+
-
号
解析表达式
笔者直接模仿训练构建了Lexer
Parser
类 ,在此不赘述
-
个人认为训练栏目中的代码非常清晰易操作,很值得学习
-
笔者没有模仿oolens特地建立
Token
类,因为目前Lexer的判定逻辑并不复杂,引入Token
类有将问题复杂化的风险(也因为我懒)
展开&化简表达式
笔者最开始看到题目时,完全不知道在解析表达式之后如何利用解析结果得到化简的表达式,也因此惆怅了很久。在此期间找到了CSDN平台上届学长的社区,阅读了大量学长的优秀架构,形成了自己最初的想法:
- 建立多项式类
Polynomial
与单项式类Monomial
来存储表达式与表达式的各项:
- 在
Expr
Term
Factor
中创建toPolynomial() 方法,将表达式转化成未化简多项式
public Polynomial toPolynomial() {
//...
for (Term term : termList) {
polynomial.addPolynomial(term.toPolynomial());
}
//...
}
- 在
Polynomial
中创建simplify() 方法,一次性简化表达式
public void simplify(){
monoHash = new HashMap<>(); //一开始用到了HashMap<Integer,Monomial>
for (Monomial mono : monoList) {
//根据HashMap的特性进行化简
}
}
这种先展开再化简的处理方法,根本无法处理表达式因子高次幂的情况,在展开过程中会产生非常多的中间项,导致运算次数大幅增加。如(1+1+1+1+1+1+1+1)^8(虽然这不符合互测cost标准)的运算次数为107 量级,可能在强测中产生TLE问题。
笔者对这个问题进行优化后得到了时间复杂度较低的程序,可以较快计算出诸如(1+1)^100
与(x+x^2)^100
等表达式:
public Boolean canMergeWith(Monomial other) { //在每次加法之前进行检查
return this.exponent == other.exponent;
}
输出表达式
输出多项式笔者写了两版,第一版直接把问题从MainCLass
依次传递给Polynomial
与Monomial
解决,并在方法中直接输出,不再返回:
public void printPolynomial(){
//...
for(Monomial mono : monoList){
mono.printMonomial();
//...
}
//...
}
在第二版中使用StringBuilder
类的方法append() 与toString 返回字符串并统一在MainClass
中输出
public String polyToString() {
StringBuilder sb = new StringBuilder();
//...
for(Monomial mono : monoList){
String s = mono.monoToString();
if(s != null){
sb.append(s);
}
}
//...
return sb.toString();
}
在本次作业中,二者的效果是相同的,都能正确的给出无误的输出。但第二种方式在转为字符串后仍能进行一定的处理,在未来作业中可能有用。
程序结构分析
代码量分析
本次作业 Source Code 共372行,主要集中在 Polynomial
类中,因为多项式转化、多项式化简与字符串转换主要在该类中完成。
复杂度分析
类复杂度
Polynomial
与 Preprocess
两类复杂度较高。前者集中了复杂度较高的 polyToString() 方法;而后者只有一个预处理字符串方法,该方法多次遍历输入的字符串。
方法复杂度
本次作业逻辑并不复杂,代码中没有出现复杂度很高的方法,圈复杂度 v ( G ) m a x < 10 v(G)_{max}<10 v(G)max<10
依赖度分析
可以注意到,笔者本次作业基本做到了“低耦合”。
MainClass
直接或间接地使用了所有类的方法,因此Dcy值(该类直接依赖的类的数量)与Dcy*值(该类间接依赖的类的数量)较高。
Monomial
与 Polynomial
类Dpt值(直接依赖该类的类的数量)较高,主要因为各个类都有**toPolynomial()**方法,其中也使用到了 Monomial
的方法与对象。
Parser
类构造了 Expr
Term
与 Factor
类,依赖到这些类的构造方法,因此Dcy值与Dcy*值较高。
第二次作业
设计与架构
架构图
-
笔者听取荣文戈老师的建议,将 main() 中的部分任务下抛给
InOut
类 -
因迭代需求,添加了自定义函数
CustomFunction
类与自定义函数因子Func
存储方式
由于本次迭代引入了指数函数,表达式的最小单元由单项式:
c o e i ⋅ x p o w i coe_i·x^{pow_i} coei⋅xpowi
变成了如下形式:
c o e i ⋅ x X p o w i ⋅ e E p o w i coe_i·x^{Xpow_i}·e^{Epow_i} coei⋅xXpowi⋅eEpowi
于是我们需要在 Monomial
类中存储 Epow 中的多项式:
private Polynomial expPolynomial = new Polynomial();
注意到有部分同学(包括笔者)在在储存Epow时选择了HashMap进行存储,这样的存储方式忽略了指数函数乘法化简,可能无法较好的化简:
e x p ( x ) ⋅ e x p ( x 2 ) + e x p ( x + x 2 ) exp(x)·exp(x^2)+exp(x+x^2) exp(x)⋅exp(x2)+exp(x+x2)
在此基础上还需要对 Monomial
Polynomial
类之间的乘法、加法进行修改。
深克隆
笔者在设计第一次作业时注意到了许多博客里都提到了深克隆问题,但在第一次作业中并未发现何处需要使用深克隆。不幸的是,这次作业出现了深克隆相关的问题:
public Monomial mulMonomial(Monomial multiplier) {
//新建coe、pow对象
Polynomial poly = new Polynomial();
poly.addPolynomial(this.expPolynomial);
poly.addPolynomial(multiplier.expPolynomial);
return new Monomial(coe, pow, poly);
}
这段代码看上去没有深克隆的问题,因为在建立 Monomial
对象时新建了 coe 、pow 与 poly ,但 poly 的 addPolynomial() 方法其实并未建立新的 Polynomial 对象,导致 poly 只是加入了对一些原有的 Monomial
类的引用。笔者添加了 createSame() 进行深克隆:
public Polynomial createSame() {
Polynomial newPoly = new Polynomial();
for (Monomial monomial : monoList) {
newPoly.addMonomial(monomial.createSame());
}
//...
}
给出一个与深克隆有关的测试点:
( e x p ( x ) + 1 ) 2 (exp(x)+1)^2 (exp(x)+1)2
构造方法
笔者在阅读同学的博客后为 Monomial 类加入了更多的构造方法:
public Monomial(BigInteger coe, BigInteger powerX, Polynomial poly) {
//深克隆等用到
}
public Monomial(BigInteger coe) {
//Number转换为Polynomial用到
}
public Monomial(int powerX) {
//Power转换为Polynomial用到
}
public Monomial(Polynomial polynomial, int expPower) {
//Exponent转换为Polynomial用到
}
以上的构造方法能减少构造相关的bug,增加代码可读性。
自定义函数
笔者在 CustomFunction
中定义了如下的属性:
private static HashMap<String, String> functions = new HashMap<>();
private static HashMap<String, Integer> variableNum = new HashMap<>();
private static final String[] args = new String[]{"!", "#", "%"};
private static final String mark = "~";
其中前两个属性分别以函数名-函数定义式、函数名-参数数目为键值对,后面的属性主要保存一些未定义字符,方便进行字符串替换。使用 static 关键词是为了直接通过类名进行方法调用:
CustomFunction.addFunction(input);
CustomFunction.callFunction(funcName,para);
在研讨课上笔者阐述了自己通过字符串处理解决自定义函数处理问题的想法,被荣文戈老师批评为”面向过程编程思想“。笔者对该类进行了调整,不知道是否更”面向对象“一些。
程序结构分析
代码量分析
本次作业 Source Code 共641行,主要集中在 Monomial
与 Polynomial
类中,因为包含了主要的简化处理、字符串输出代码。
复杂度分析
类复杂度
复杂度仍主要集中于Monomial
与 Polynomial
两类。
方法复杂度
本次作业逻辑较为复杂,出现了复杂度较高的方法,整体圈复杂度 v ( G ) m a x < 10 v(G)_{max}<10 v(G)max<10 。本次迭代涉及合并同类项判定,使用了递归的 equals() 方法,复杂度很高; monoToString() 方法中有一些指数函数输出括号等的特判,增加了更多分支与循环,复杂度较高。
依赖度分析
新增的 Func
类中的 Expr
属性解析需要新建一个 Parser
,因此其Dcy值与 Parser
类的Dcy值相近。
第三次作业
设计与架构
架构图
求导的实现
新增导数因子 Deprivation
类:
private Expr expr;
笔者的具体思路是,先将 Deprivation
中的表达式 Expr
转化为 Polynomial
,再通过 Polynomial
中的求导方法 deprive() 得到求导后的结果:
//Polynomial中的求导方法:
public Polynomial deprive(){
Polynomial poly = new Polynomial();
for(Monomial mono : monoMap.keySet()){
//...
}
return poly;
}
//Monomial中的求导方法
public Polynomial deprive(){
Polynomial polynomial = new Polynomial();
if(!powerX.equals(BigInteger.ZERO)){
//对x求导
}
//对exp(...)求导
return polynomial;
}
用到了求导的乘法法则与链式法则:
( c o e ⋅ x n ⋅ e e x p r ) ′ = c o e ⋅ n ⋅ x n − 1 ⋅ e e x p r + c o e ⋅ x n ⋅ e e x p r ⋅ ( e x p r ) ′ (coe·x^n·e^{expr})' = coe·n·x^{n-1}·e^{expr}+coe·x^n·e^{expr}·(expr)' (coe⋅xn⋅eexpr)′=coe⋅n⋅xn−1⋅eexpr+coe⋅xn⋅eexpr⋅(expr)′
自定义函数相关
本次加入了支持“用已定义函数定义新函数的”的要求。由于笔者使用的是字符串替换的方法,在上一次迭代中便可支持这一定义方式。考虑如下两个函数:
f ( x ) = x 2 ; g ( y ) = f ( y 2 ) f(x) = x^2;g(y)=f(y^2) f(x)=x2;g(y)=f(y2)
CustomFunction
类的 addFunction() 会直接存储两个字符串定义式。但在调用时 callFuction() 会用实参替换形参,假设调用为:
g ( x 2 ) g(x^2) g(x2)
calFunction() 将实参替换掉实参后得到字符串:
f ( ( x 2 ) 2 ) f((x^2)^2) f((x2)2)
解析上述字符串后再次调用 calFunction() 得到字符串:
( ( ( x ) 2 ) 2 ) 2 (((x)^2)^2)^2 (((x)2)2)2
再正常解析上述字符串即可得到结果。
程序结构分析
代码量分析
本次作业 Source Code 共703行,主要集中在 Monomial
与 Polynomial
类中,因为包含了主要的简化处理、求导、字符串输出代码。
复杂度分析
类复杂度
方法复杂度
基本同上次迭代。图片较长,观感不佳,故不附图。
依赖度分析
程序bug
笔者在三次作业的强测、互测均未出现bug
只要不是难以发现的bug,那都只是程序漏洞!——荣文戈老师
以下是一些书写代码时发现的“程序漏洞”:
在此感谢所有公开发布评测机的同学!
hack策略
hack思路
笔者互测采取了人工构造数据的方式。第一单元对互测测试点的限制非常严格,因此仅从互测角度而言人工构造测试点和评测机构造数据点并无显著差距。主要思路为构造一个覆盖率接近100%的数据点,这样能检测到所有被测试者有没有考虑到笔者所考虑到的情况。以第一次作业为例,下面的数据几乎覆盖到了我写的代码的每一行:
+(0)^0+x* +7*x^+2*(x-x)^0-(x+1)^8-(x^0+x^2+x^+03)^+0+-1145+(x^2)^8-(x^4)^4++1--1+-1-+1
但由于第一次互测同一个room的同学都很优秀,这个例子并没有hack到任何人…
hack样例
笔者在第一次作业没有成功hack到任何人,以下为第二三次作业成功hack的数据:
//hw2
1
f(y,x) = x^1
-f((f(0,0)),0)//这位同学的自定义函数调用出了点问题
//hw3
0
dx(exp(exp(exp(exp(exp(exp(exp(exp(x^2)))))))))//好像是存储问题?
优化总结
笔者的优化全部基于正确性之上,即只进行了力所能及的优化,不能完全保证正确性的复杂优化笔者并未加入。
- 第一次作业只有一个可以优化的点:优先输出正系数项,这样输出的有效长度会比先输出负系数的情况短一个字符。这种优化看上去意义不大,但在有效长度较小的情况下对性能分的影响很大,如 x 2 − x 与 − x + x 2 x^2-x与-x+x^2 x2−x与−x+x2 。
- 第二三次作业的优化方式相近,因为其输出格式没有变化。
- 合并指数项: e i ⋅ e j ⋅ e k = e i + j + k e^i·e^j·e^k=e^{i+j+k} ei⋅ej⋅ek=ei+j+k
- 提取公因数: e a ⋅ ( i + j + k ) = ( e i + j + k ) a e^{a·(i+j+k)}=(e^{i+j+k})^a ea⋅(i+j+k)=(ei+j+k)a
- 可忽略的括号: e x p ( ( − 6 ) ) = e x p ( − 6 ) exp((-6)) = exp(-6) exp((−6))=exp(−6)
- 不可忽略的括号: e x p ( ( − x ) ) ≠ e x p ( − x ) exp((-x))≠exp(-x) exp((−x))=exp(−x)
使用以上优化,笔者在三次作业中分别得到了100、88、93分的性能分。
e x p ( 123 ) = e x p ( 1 ) 123 exp(123) = exp(1)^{123} exp(123)=exp(1)123
pps:本单元有的同学进行了如下的优化 ,而笔者因为担心正确性出问题(还因为菜)没有优化这点:
e
x
p
(
(
x
+
66666
⋅
(
x
2
+
x
3
)
)
)
=
e
x
p
(
x
)
⋅
e
x
p
(
(
x
2
+
x
3
)
)
66666
exp((x+66666·(x^2+x^3)))=exp(x)·exp((x^2+x^3))^{66666}
exp((x+66666⋅(x2+x3)))=exp(x)⋅exp((x2+x3))66666
博客推荐
在开始写代码前,笔者看了许多优秀学长学姐的博客,挑选了一些个人喜欢的列在下方:
-
LeoStarven 博客写的比较详细,学长人也很好
-
Hyggge’s Blog 对我的架构启发很大
-
Coooookie282 给出了一点架构的伪代码,可惜一开始看不懂
心得体会
此情可待成追忆,只是当时已惘然。
笔者在构思第一次作业时由于没有很多架构设计经验(先修课的架构与第一单元的架构相比实在是太弱小了),阅读了许多学长的博客,看到了很多优秀的架构,经历了一个比较痛苦的学习过程。但到博客周往回看,这种痛苦的学习过程才是弥足珍贵的,它让笔者从依赖学长博客形成自己的思路转变为自己构思、设计并编写代码。在第二三次作业用自己的想法较完美地解决新需求,此中的成就感难以言说。
希望在多次经历这种痛苦后,笔者能成长为一个独立且强大的编程者。
未来方向
笔者认为本课程的课程难度设置有些问题。这并不是说课程难度过大,而是笔者认为把第一单元第一次作业的强度设置如此之高,使得学生将面向对象编程先修课视为必修课,实在失去了将其设为“一般专业课”的意义。或许为本课程设置寒假预习课并将部分先修课内容放入预习课内,给同学们留下选择的余地更合理一些。