前言
第一单元作业主要为对所输入的表达式进行化简以及去括号操作,通过对数学意义上的表达式结构进行建模,完成单变量多项式的括号展开,体会层次化设计的思想的应用和工程实现。本单元作业的表达式主要包括单变量多项式的加减乘和乘方、指数函数(exp())的加入、自定义函数的定义与调用以及求导等。
三次作业层层递进,每一次都会出现新的要求,因此保证代码的可扩展性是至关重要的。同时,若没有java编程语言的基础,直接上手第一次作业,就像一个小孩还不会走路,可我们却要求他能够跑起来(非常不恰当的比喻)。在上OO课前,我已经有了以下的基础:
- OOpre课程:会传授java语法基础的知识,并且使用OO课程网站,对OO正式课程很有帮助。
- OOlens推送:提前可以了解到作业的解析方法,如递归下降法等。
虽然如此,第一单元作业依然十分艰难,下面对三次作业进行简要的分析:
第一次作业分析
第一次作业为单变量多项式的加减乘和乘方,包括的因子主要为幂函数、支持前导0的带符号整数、表达式因子三种。
代码类图
本次作业的UML图:
架构设计体验
本次作业对表达式的解析分为三个部分——expr
,term
和factor
,在解析过程中我们可以使用正则表达式或递归下降法,但由于本人对于正则表达式的记忆比较模糊,同时根据学长描述可知,使用正则表达式会在后面出现一些棘手的bug(之后再讲),于是选择了递归下降的解析方法。解决此问题之后,再有就是,如何对已经解析完成得到的变量进行加减乘和乘方操作呢?在我的架构中,是将expr
,term
,factor
几种类都转换为共同的类Value
,之后在统一的类中进行运算操作,下面来进行分析:
递归下降法
递归下降法主要包括两个部分——lexer
和parser
,lexer
负责遍历我们所输入的字符串,判断每个字符的类型,即该字符是数字?“x”?还是+、-、*等,将其分为一系列的语法单元。而parser
根据lexer
所给的语法单元,进行语法分析,递归构建expr
,term
,factor
等类。
lexer
本次作业,会出现的语法单元为数字、+、-、*、^、x、(、)几种,我在lexer中利用私有变量type对其进行标记。
public class Lexer {
private final String input; //输入字符串
private int pos = 0; //遍历位置
private String curToken; //所得到的字符
private int type;//规定1为数字,2为+,3为-,4为*,5为^,6为x,7为(,8为)
//...
public void next() { //下一个位置
//...
char c = input.charAt(pos);
if (Character.isDigit(c)) {
curToken = this.getNumber();
type = 1;
} else if (c == '(' || c == '+' || c == '*' ||
c == ')' || c == '^' || c == '-' || c == 'x') {
pos += 1;
curToken = String.valueOf(c);
type = toType(c);
}
}
在lexer
中可以使用方法next()
使得遍历位置得以推进,直到遍历完所有字符,将解析出来的语法单元以数字type
的形式进行呈现,方便parser
类的解析。
parser
Parser
类用于对表达式进行解析,构建expr
、term
、factor
等类,共有parseExpr()
、parseTerm()
、parseFactor()
三种方法,分别嵌套调用。
由于我的架构中没有出现预处理Process
类,对于形式化定义中的多个加减号,无法对其进行化简,所以我选择将符号一层一层地传递下去,在最后构建factor
时在根据之前传进来的符号来确定最终factor
的正负号,以parseExpr()
和parseTerm()
为例:
public Expr parseExpr(int signExpr) {
Expr expr = new Expr(signExpr - 1);
expr.setIndex(-1);
if (lexer.getType() == 2 || lexer.getType() == 3) {
//对于第一项,若前面正负号则传进去,若没有默认为正号
int sign = lexer.getType();
lexer.next();
expr.addTerm(parseTerm(sign)); //传进项的正负
} else {
expr.addTerm(parseTerm(2)); //第一项前没有符号
}
while (lexer.getType() == 2 || lexer.getType() == 3) {
int signNow = lexer.getType();
lexer.next();
expr.addTerm(parseTerm(signNow));
}
//...
return expr;
}
public Term parseTerm(int signOfTerm) {...}
public Factor parseFactor(int signOfFactor) {...}
表达式运算
实现不同类之间的加减乘运算是十分困难的,但我们从结果的角度思考,我们最终化简得到的表达式都是形如下面的格式:
因此我们只需要将所有解析出来的类都转化成一个共同类,在该类上实现加减乘以及乘方算法,问题便会迎刃而解,也就是架构中的Value
和Single
类,其中Single
代表每一个单项式,其形式在本次作业中只可能为整数或为带有x的单项式。而多个Single
便构成了我们最终所求的Value
类,如下所示:
public class Single {
private int type; //1为数字,2为x的式子
private int sign; //正负
private BigInteger num; //系数
private String name; //变量名称
private int index; //指数
//...
public String toString() {
if (type == 1) {
return String.valueOf(num);
} else {
StringBuilder sb = new StringBuilder();
getPolyString(sb);
return sb.toString();
}
}
public void add(Single single) {...}
public void sub(Single single) {...}
public Single mul(Single single) {...}
public class Value {
private final ArrayList<Single> single;
public Value(ArrayList<Single> single) {
this.single = single;
}
//...
public String toString() {...}
public void valueAdd(Value value) {...}
public Value valueMul(Value value) {...}
构建好之后,我们便可以在Expr
、Term
、Factor
等类中实现toValue()
方法,该方法也是通过递归调用实现,即Expr
调用Term
的方法,Term
再去调用Factor
,代码如下:
//Expr.java
public Value toValue() {
Value value = terms.get(0).toValue();
if (terms.size() != 1) {
for (int count = 1; count < terms.size(); count++) {
value.valueAdd(terms.get(count).toValue());
}
}
//...
return value;
}
同时为了体现因子之间的逻辑关系,构建了抽象类Factor
实现接口
public interface Factor {
Value toValue();
}
//以Number.java为例
@Override
public Value toValue() {
//...
}
这样,我们就实现了对表达式的解析以及运算化简操作,最终只需在顶层main
类中调用toString()
方法即可得到化简之后的表达式.
代码复杂度分析
从分析中可见,方法Value.toString()
和Lexer.toType(char)
的复杂度较高,分别为16和21,主要原因为Value
的toString()
涉及到递归调用所有Single
的toSting()
方法,后者则是采用三目运算符来判断语法单元的类型,因共有8种类型,导致复杂度较高。
类的复杂度分析中,Lexer
、Parser
、Value
的运算复杂度较高,主要为计算语法单元的类型,解析时需 要进行大量的对类型的判断以及Value
中的运算操作,都会使复杂度变高,不过都在可接受范围内。
总体平均值复杂度都在可观范围内。
第一次作业体验
第一次作业相对是比较难的,并且还处于开学第一周,状态感觉并没有调整过来,写代码时总会犯许多小错误,导致在debug的时候很b溃,不过好在实验课程以及学长的博客对我帮助很大,最后成功攻破这个难题,发现输入的表达式都能顺利输出,那种成就感是无法用言语表达的,这可能也是OO这门课的乐趣所在。
第二次作业分析
第二次作业加入了指数函数exp()和自定义函数,要求进一步进行了扩展。
代码类图
本次作业的UML图如图所示:
架构分析
本次作业相比于第一次作业加入了函数的概念,我依然采用Single-Value
的模式,自然又会出现新的问题。
- 如何对自定义函数进行解析?
- 如何对指数函数进行解析?
- 最终如何对指数函数进行
toValue()
操作以及加减乘和乘方操作?
自定义函数的解析
Definer类
在自定义函数的处理中,我加入了Definer
类,该类主要作用为储存输入表达式前所定义的函数内容,同时在解析表达式时通过里面的getFunc()
方法来获得将实参代入的函数表达式。其代码如下:
public class Definer {
private HashMap<String, ArrayList<String>> paraList;
private HashMap<String, String> exprList;
public void processFunc(String func) {...}
public void addFunc(String name, String expr, ArrayList<String> para) {...}
public String getFunc(String name, ArrayList<Factor> factorList) {...}
其中,paraList
用来储存每个函数参数列表,exprList
用来储存每个函数对应的表达式,在加入函数时,我采用了正则表达式的方法来对函数名、参数列表、函数表达式进行识别与储存。在getFunc()
方法中,需要注意的是,如果replaceAll()
方法来函数的形参换成实参会出现意想不到的bug,可能会出现e(表达式)p的尴尬情况,所以在此方法中我采用了遍历整个表达式字符串,在遇到’e’时便将’exp’全跳过的方式避免此情况。
if (expr.charAt(count) == 'e') {
count += 2;
exprGet.append("exp");
continue;
}
再有,如果参数列表为(y,x),而传进来的y的实参中含有x,则在替换x时又会出现错将替换的x再次替换的bug,此bug可以采用将形参全换为a,b,c的方法,不过我采用的是只遍历一次表达式字符串,若发现形参,则将实参传进另一个字符串exprGet
中这样也可以避免以上的bug出现。
解析
通过Definer
类,我们便可以将自定义函数转换为将实参代入后的表达式字符串。
//Parser.java
public Factor parseFactor(int signOfFactor, int expSignal) {
//...
ArrayList<Factor> factorList = new ArrayList<>(); //实参列表
factorList.add(parseFactor(2, 0));
while (lexer.peek().equals(",")) {
lexer.next();
factorList.add(parseFactor(2, 0));
}
lexer.next();
String exprGet = definer.getFunc(name, factorList);
return new SelfFunc(exprGet, definer, signOfFactor);
//...
}
之后再在SelfFunc
类中对exprGet
进行解析即可:
//SelfFunc.java
public class SelfFunc implements Factor {
private String expr; //自定义函数表达式
private Expr exprGet; //转换为之后得到的表达式
//...
public Expr getExprGet(int sign) {
Lexer lexer = new Lexer(expr);
Parser parser = new Parser(lexer, this.definer);
return parser.parseExpr(sign, 0);
}
}
至此,我们便将自定义函数的难题解决。
指数函数的解析
指数函数的解析相对简单,只需要检测到’e’时便可解析exp函数,之后再把其括号里面的因子解析出来即可。
//Parser.java
//...
public ExpFunc parseExp(int signOfFactor) {
//...
Factor factor = parseFactor(2, 0);
//... 检测指数是否存在
return new ExpFunc(signOfFactor - 1, factor, index);
}
ExpFunc
类中包括三个属性,分别sign
(符号),factor
(括号内的因子),index
(指数)
//ExpFunc.java
public class ExpFunc implements Factor {
private int sign;
private Factor factor;
private int index;
//...
}
表达式运算
本次作业最终的输出形式不再是多项式相加减的形式,而是加入了指数函数,最终输出的一般形式可以表示为:
因此需要对Single
进行相关的调整:
public class Single {
private int type;//1为数字,2为x的多项式,3为exp,4为exp和多项式相乘
private int sign;
private BigInteger num;
private String name;
private BigInteger index;
private Value expFactor;
private BigInteger expIndex;
//...
public String toString() {...}
public Single add(Single single) {...}
public Single sub(Single single) {...}
public Single mul(Single single) {...}
可以看到,我将Single分为了4类,1和2与作业1相同,3为仅有指数函数而没有多项式,4为指数函数和多项式相乘的形式,这样储存,在之后的加减乘操作能进行有效的简化。再有,与第一次作业不同的还有index由int
变成BigInteger
类型,防止指数在处理时超出int
范围。
接下来,在处理加减乘法时,Single
类中相关的方法都需要进行一定的修改,主要为在Value
增加判断两个Single
是否为可以合并的equal()
方法,此时利用type
变量便可轻松判断,若type
不同,则两项一定不可以合并,type
相同,则需要进一步判断多项式的指数或指数函数的因子都要相等。
在乘法操作,只需要遵守整数相乘、x的指数相加以及指数函数的因子相加这个原则就可,具体细节不再展开,同样是根据type
类型分类讨论。
调整完之后,只需要在SelfFunc
和ExpFunc
类中加入toValue()
方法即可对表达式进行计算。
代码复杂度分析
可以看到,总体复杂度还可以接受,方法复杂度较高的有toString
、valueAdd
、parseFactor
、toType
,其中toString
和toType
与上次作业的原因相同,经过分析,其他方法原因在于加入指数函数的运算,使得parseFactor
的解析单元的类型变多,加入ExpFunc
和SelfFunc
,导致复杂度变高,valueAdd
则是因为本次作业对该方法进行了重构,由第一次作业的改变类内部属性改为保持类不变,返回一个新类的形式。
类的复杂度中依然是Lexer
、Parser
、Value
的运算复杂度较高,很难避免,好在并没有超出太多。
第二次作业体验
在第一次作业的基础上,第二次相对简单些,但还是很有难度,难点主要在于对**Single
类加减乘法的重构以及在最后对指数函数的优化**(原来真的有人在卷这个),在编写加减乘法时需要充分考虑深拷贝与浅拷贝的问题,不然便会出现bug。同时,在重构toString()
方法时,切记,不要修改类的属性,如果想来看看调试器灵异事件也可以尝试一下(别问,问就是掉发的教训)。
第三次作业分析
第三次作业在前两次作业的基础上,增加了求导因子,同时,本次作业函数支持调用其他“已定义的”函数,但不会递归调用。
代码类图
本次作业的UML图如下:
架构分析
本次作业增加了求导以及函数嵌套调用,但由于我之前的架构已经支持了函数的嵌套调用(利用parserFactor
即可实现),所以本次作业只需实现单变量求导即可,相对前两次作业,第三次作业比较简单,前后增加代码量在50行左右。
求导因子分析
求导因子的解析(dx(表达式))其实跟指数函数(exp(因子))类似,只需要调用parserExpr
即可,在Derivative
类中定义了expr
(求导的表达式)、sign
(正负),这里不在赘述。
之后在Single
和Value
类中实现求导方法derivation()
即可,思想就为链式求导的方法。
//Value.java
//...
public Value derivation() {
Value result = this.single.get(0).derivation();
for (int count = 1; count < this.single.size(); count++) {
result = result.valueAdd(this.single.get(count).derivation());
}
return result;
}
//...
其余部分便不需要修改,本次作业到此为止问题便已解决(幸福来的太突然)
代码复杂度分析
跟上次作业相比变化不大,毕竟也只增加了一丢丢代码…
优化方面
对化简结果如何缩减到最小,有以下几种思路:
- 首先肯定是能合并的项一定要合并。
- 让输出的第一项尽量为正,这样可以减少一个首项为负的’-'。
- 提取exp里面因子的公因数,使其长度变短,但注意,此做法有可能会使字符长度变长,如
exp((4*x+2))
变成exp((2*x+1))^2
,因此,需要增加判断语句或者进行多次提取分别比较长度取最短。 - 对exp()进行拆分,倘若我们遇到以下情况:
exp((12365485*x^2+12365485*x+45678213))
如此长的字符串,我们可以将其拆分为exp((x^2+x))^12365485*exp(45678213)
这样便可以将原来39个字符变为34个。(不过可能使代码复杂度很大,遂摆烂)。
测试方面
在互测时我主要采用人工与评测机相结合的方式,评测机方面主要分为两个文件:data.py
和test.py
,前者负责生成数据,后者则负责测试,不过鉴于本人能力有限,只能搭出第一次作业的评测机(大部分代码也不是我写的),后面两次作业的都是通过别人搭建的评测机(真的非常感激)来进行测试。
当然,其他同学的代码大都也是经过评测机锤炼沉淀的结果,在互测过程中也会人工编造一些数据,这部分数据主要针对边界情况,例如输入大数字或者卡超时情况(有幸成功过),或者函数嵌套的情况。由于时间关系,我并不会仔细阅读被测程序的代码,更多是一种黑盒的形式,毕竟,连自己的代码都不一定能读懂。
BUG分析
第一单元的作业我总共挂了3个强测数据点以及两次的互测,强测数据点的bug主要为1.指数超过int范围 2.优化过度导致输出表达式不符合形式化定义(exp(-x)
),互测所发现的bug为Lexer
若碰到输入表达式最后为空格的情况便会越界访问的情况,主要为Lexer内部代码的问题。
其中对于exp(-x)
,是在判断exp里面的因子是否为表达式因子发生了错误,没有考虑到负号的情况:
public boolean ifNotExpr() {
if (this.type == 1) {
return true;
} else if (this.type == 4) {
return false;
} else {
return this.num.compareTo(BigInteger.ONE) == 0;//少加了 && this.sign == 1;
}
}
因此,这告诉我们,优化是有代价的,可能产生无形的bug,需要小心、小心、再小心。
架构设计体验
这三次作业中我并没有进行重构,因为我的架构中采用了递归下降法,并不会在整体架构有问题,只是在一些细节方面需要重构,例如Single
类中add
、sub
等方法在第二次作业便进行了相应的调整,这告诉我,前期一个好的架构能使后面的工作事半功倍,在后面的作业中,我会将大部分的时间精力放在架构上,以保证程序的简洁性与可扩展性。
缺点
优点前面已经说很多了,现在来说一下架构的缺点:
- 对于嵌套括号,会出现树状结构过于复杂,可能会TLE或爆掉,例如
(((((((((x)))))))))
,本质就是x
,但在解析时会不断嵌套expr-term-factor
,导致代码性能下降 Single
中的type
变量如果加入多个因子,会出现多种排列组合情况,如果采用添加类的形式进行计算,效果会比现在的架构代码可读性更强。
新的迭代情景
如果在三次作业的基础上,增加三角函数因子(Sin(因子)
,Cos(因子)
),对于我的架构,可以在Factor
接口中增加新的Sin
和Cos
类,在类内定义所包含的因子与指数。
//Sin.java
public class Sin{
private Factor factor;
private int index;
//...
@Override
public Value toValue(){...}
}
//Cos.java
public class Cos{
private Factor factor;
private int index;
//...
@Override
public Value toValue(){...}
}
在解析时与ExpFunc
的过程类似,这里便不再展开。下面对于表达式运算,对于顶层的Value
并不需要进行修改,只需对底层的Single
中的add()
,sub()
,mul()
进行增量开发即可,同时在Single
中添加属性SinFactor
和CosFactor
。其一般形式如下:
优化方面,可以考虑二倍角公式以及三角函数的性质,在条件允许的情况下可以采用积化和差、和差化积公式,但教训告诉我,不要尝试过度优化。
心得体会
第一单元作业层层迭代,三次作业让我更加深入地了解了面向对象的编程思想,进一步巩固了java语法基础,依稀记得刚开学的我面对第一次作业的大工程,从零开始,学习相关知识,搭建自己的架构,编写自己的代码,一步一步走到第一单元作业结束,这过程有迷茫,有无力,有疲惫,有遗憾,但最重要的还是自己在这过程中真正学到了东西并可以应用于之后的作业中,我们之前都是面向过程编程,可我们现在若以面向对象的思想看待生活,过程纵使艰苦,但不断成长的自己更值得珍惜与鼓励,希望自己之后的OO之旅能使自己功能更加丰满,可扩展性更强。
未来方向
第一单元课程总体上非常好,层层递进,但很明显第三次作业相比于前两次作业任务量急剧下降,我更希望这三次作业任务量能均衡一点,因为据我所知,有很多不幸的uu们未能成功提交第一次作业,除此之外都设“寄”得很合理。
致谢
- hyggge学长的博客,对我的架构帮助很大。
- cxc大佬的评测机,帮助我有效debug(膜拜)。