文章目录
OO第一次作业
本次作业需要完成的任务为:读入一个包含+、-、*、^、()的多变量表达式,输出恒等变形展开所有括号后的表达式。
其中括号的深度至多为 1 层
多变量只涉及x
最终输出的表达式中不能含有()
UML类图
代码架构
存储形式
首先,我们可以对表达式、项、因子做如下的文法分析
Expr := Term | Term [+|-] Expr
Term := Factor | Factor [+|-] Term
Factor := Expr | NumFactor | PowerFactor
那么,我们可以比较自然地想到各个部分的存储形式,分析Factor时,我认为它行为抽象层次重合度高,因此这里我们引入接口Factor,来方便使用Expr
,NumFactor
,PowerFactor
:
public class Expr implements Factor {
private ArrayList<Term> terms; //以项数组存储
}
public class Term {
private final ArrayList<Factor> factors; //以因子数组形式存储
}
public interface Factor {}
public class Number implements Factor {
private final BigInteger num;
}
public class Power implements Factor {
private int exp;
private String var;
}
流程分析
我把整个代码的流程分为三个部分,分别是预处理、表达式解析、优化输出
第一部分:预处理
预处理部分主要包括三个内容,我专门建立了一个Processer
类用于预处理,在其中实现了三种方法,作用是删除空白符、处理连续的±和开头的+、在所需位置补上0并删去多余的+。
public void delBlank() {
//for循环删去空白符,包括''和'\t'
}
public void delPlusAndMinus() {
//双指针遍历
char current = input.charAt(i);
char next = input.charAt(i + 1);
//连续减号变成加号
// +-变成-
// 删除开头+
}
public void adjust() {
//开头是-加上0,(和-中间插入0
//删去多余的+
}
这个部分实现并不困难,主要是要综合考虑多种情况。
第二部分:表达式解析
有大致两种主流方法进行表达式解析,分别是递归下降算法和正则表达式算法,正则表达式算法的迭代性中比较差,我在这里使用递归下降算法。
递归下降算法的主要组成部分分别是——Lexer
(词法分析)和Parser
(语法解析)。相关代码在训练项目advance和实验课中有给出部分代码,在这基础上迭代开发即可。
Lexer
部分严格遵守词法的构成即可。
public class Lexer {
private int pos = 0;
private String curToken;
public Lexer(String input) {
//......
this.next(); //这是为了一开始就定位
}
private String getNumber() {
//读取一个数字,我在这部分处理前导0
}
public void next() {
if (pos == input.length()) {
return;
}
char c = input.charAt(pos);
if (Character.isDigit(c)) {
//调用getNumber
} else {
//分多种情况处理符号,如+-等
}
}
public String peek() {
return this.curToken;
}
}
Parser类的设计主要是沿用了本单元训练项目中的写法,分为三部分——parseExpr(), parseTerm(), parseFactor(),每一部分的解析都遵循形式化文法。
public class Parser {
//......
public Expr parseExpr() {
//......
while (lexer.peek().equals("+") || lexer.peek().equals("-")) {
//读入项,记得设定项的符号
}
//......
}
public Term parseTerm() {
//解析时记得项的开头可能是-
}
public Factor parseFactor() { //这里面单独一个因子也可能是负数
if (lexer.peek().equals("(")) { //表达式因子后面可能有指数
//......
}
else if (lexer.peek().equals("-")) { //因数是数字前面有个-
//......
}
else if (lexer.getType()) { //因子是数字
//......
}
else { //碰到幂函数,现在的是x
//......
}
}
}
值得一提的是,parseFactor部分中,如果是数字和幂函数,那么我们就已经到达了递归的终点,但是如果仍然是个表达式的话,我们就要继续递归,直到到达递归的终点。
第三部分:优化输出
观察可以发现,本次作业最后的输出形式并不复杂,就是 P o l y = ∑ a i x n i Poly = \sum a_ix^{n_i} Poly=∑aixni
那么我们在存储最后结果时候就可以把所有项和因子都化成单项式 U n i t = a i x n i Unit = a_ix^{n_i} Unit=aixni,然后用这个单项式进行计算,最终化为多项式Poly。
那么怎么把我们存储好的语法树给变成Poly呢?其实和我们解析的道理差不多,也是对Expr,Term,Factor进行递归下降处理。
//Expr.java
public Poly toPoly() {
//定义为0*x^0的Poly
//...
for (Term it : terms) {
//把每个Term的Poly加起来即可
}
}
//Term.java
public Poly toPoly() {
//定义为1*x^0的Poly
for (Factor it : factors) {
//调用mulPoly()
}
if (sign == -1) {
//调用negate()
}
}
//对于剩下两种Factor,相应构造Poly即可
Unit类的实现比较简单,要实现的运算也只有一个
public class Unit { //处理单项式相关的部分
private BigInteger coe; //系数
private int exp; //x的指数
//......
public Unit mulUnit(Unit unit) {
return new Unit(this.coe.multiply(unit.coe),this.exp + unit.exp);
}
//......
}
Poly类的实现也不困难,因为在合并同类项时候,我们只需考虑两个Unit的指数是否相同就可以了。
public Poly addPoly(Poly poly) { //多项式相加
for (Unit unit : poly.units) {
if (unitMap.containsKey(unit.getExp())) { //是同类项
//......
}
else {
//......
}
}
//返回值必须深克隆
}
public Poly mulPoly(Poly poly) { //多项式乘法
for (Unit unit : poly.units) {
for (int i = 0;i < this.units.size();i++) {
//一个个相乘再加起来
}
}
//返回值必须深克隆
}
在最后输出时候也要注意一定的性能优化,包括系数是1,-1,0(可以不输出系数),指数是0,1(可以不输出指数甚至x)这几种情况。还一个可以优化的点就是要把开头项是负项可以提到后面去。
OO第二次作业
新增了嵌套括号(已经解决),exp函数,自定义函数。很大程度上提升了程序的复杂度。
UML类图
代码架构
存储形式
Expr := Term | Term [+|-] Expr
Term := Factor | Factor [+|-] Term
Factor := Expr | NumFactor | PowerFactor | ExpFactor | FuncFactor
通过分析表达式结构,我们发现可以给Factor新增两种类——exp函数因子,自定义函数因子。
public class ExpFunc implements Factor {
private Factor factor; // exp函数的因子,括号内的内容
private BigInteger exp;
}
public class FuncFactor {
private String newFunc; //将函数实参带入形参位置后的结果(字符串形式)
private Expr expr; //将newFunc解析成表达式后的结果
}
为记录并调用自定义函数,参考了学长的博客,新建了两个类——数据类FuncFactor
、工具类Definer
Definer
主要处理自定义函数的定义和调用。该函数的成员和方法都是静态的,意味着我们不需要实例化对象,直接通过类名即可调用。
Definer
类拥有两个方法,一个是addFunc()
,一个是callFunc()
:
-
前者是在函数调用时使用,将终端输入的函数表达式传入该函数并进行解析(为避免先后替换出现问题,要把xyz换成uvw),并将该函数的定义式和形参列表分别加入funcMap和paraMap。这里要注意的是,本次作业中出现了exp,好巧不巧其中出现了x,所以我们在替换时要先把exp替换成e,之后再换回来。
-
后者是在自定义函数解析的时候使用的,传入的参量是函数名
name
和实参列表acturalParas
。该函数首先根据name
获得函数定义式和行参列表,然后遍历字符串,将函数定义式中的形参替换成实参的字符串形式,这个函数有个缺点,因为是字符串操作,所以要特别注意其正确性。public static String callFunc(String name,ArrayList<Factor> actualParas) { String func = funcMap.get(name); for (int i = 0;i < paraMap.get(name).size();i++) { //replaceAll替换 } return func; }
我们只需要在ParseFactor
时候调用这个类的函数就可以了,对于函数的参数,它也是一个Factor,我们把它当作一个普通Factor读入就可以了。
流程分析
我把整个代码的流程仍然分为三个部分,分别是预处理、表达式解析、优化输出。
第一部分:预处理
为了简化之后的步骤我修改了部分预处理,增加了两个部分——删除逗号后面无必要的+;给逗号后面紧接着的-前面加上0。
public void adjust() {
//开头是-加上0,(和-中间插入0,-和,之间插入0
//删去多余的+
}
第二部分:表达式解析
Lexer部分新增了识别exp、f、g、h、,
(逗号)的功能
//lexer.java
else if (c == ',') {
pos += 1;
curToken = String.valueOf(c);
numToken = false;
}
else if (c == 'e') {
pos += 4;
curToken = "exp";
numToken = false;
}
else if (c == 'f' || c == 'g' || c == 'h') {
pos += 1;
curToken = String.valueOf(c);
numToken = false;
}
Parser部分新增了parseExpFunc() ,parserFuncFactor()两个方法,
-
前者具体解析的逻辑是:先将exp函数括号内的因子进行解析(在该方法中调用parseExpr()方法即可),然后解析exp函数的指数。
public Factor parseExpFunc() { //... Factor inside = parseExpr(); //... Factor expFunc = new ExpFunc(inside,"1"); if (!lexer.peek().equals("^")) { //没指数 //... } else { //如果表达式后面有指数 } }
-
后者按照自定义函数的形参个数,解析实参并代入就可以了
public Factor parserFuncFactor(String name) { //解析自定义函数 //... for (int i = 1;i <= (Definer.getParaMap().get(name).size()); i++) { //解析实参 } FuncFactor funcFactor = new FuncFactor(name,actualParas); Factor newFuncFactor = funcFactor.getExpr(); //构造函数 //若有指数,读入分析 }
这里我都用的ParseExpr
,无论是函数的实参还是exp函数的内容,其实只要注意lexer
的位置,无论是ParseFactor
或是ParseExpr
应该都可以。
第三部分:优化输出
观察可以发现,本次作业的最小因子可以认为是 a x n e x p ( F a c t o r ) ax^nexp(Factor) axnexp(Factor),多项式就可以认为是 P o l y = ∑ a i x n i e x p ( F a c t o r i ) Poly = \sum a_ix^{n_i}exp(Factor_i) Poly=∑aixniexp(Factori)
我改动了Poly类和Unit类以适应新的需求。我没有使用HashMap
,而是遍历ArrayList
来判断,这样显著增加了时间成本,但好处是不需要重写hashcode方法。
public class Unit { //处理单项式相关的部分
private BigInteger coe; //系数
private BigInteger exp; //x的指数
private Poly poly; //exp函数里面的内容,在处理时先把指数乘进去了
public Unit mulUnit(Unit unit) {
//深克隆实现的最小因子相乘,这是基于exp(Factor1)*exp(Factor2) = exp(Factor1 + Factor2)实现的
}
@Override //不能在像第一次作业那样简单地判断同类项了,必须重写equals方法
public boolean equals(Object o) { //模板改的
//自反性
//任何对象不等于null,比较是否为同一类型
//强制类型转换
//最后比较属性值
return Objects.equals(coe,unit.coe) &&
Objects.equals(exp, unit.exp) &&
Objects.equals(poly, unit.poly);
}
public Boolean hasSameExp(Unit unit) { //判断是否是同类项的
return exp.equals(unit.getExp()) && poly.equals(unit.getPoly());
}
}
Poly部分要尤其注意,最好都使用深克隆,有的同学专门在其中实现了一个序列化克隆方法,我相当于是在每个要用的方法都写了一遍深克隆,确实复杂不少,好在测试仔细,没有出现很大问题。
public class Poly { //处理多项式相关的部分
private ArrayList<Unit> units;
public void addUnit(Unit unit) { //最小因子相加
for (Unit term: units) {
//遍历找同类项相加即可
}
}
public Poly addPoly(Poly poly) {
//遍历,调用addUnit
}
public Poly mulPoly(Poly poly) { //多项式乘法
//这里我把Poly是空的当成是0,作为递归的终点,所以相乘时候要特别判断每一个Unit里面是不是空的
}
}
这样我们就得到了Poly,在输出时候我主要做了去括号方面的优化,就是判断exp括号里面是否是因子,为此,我在Poly类里面写了三个方法,判断是否是三种因子(自定义函数已经被处理完了)
public boolean isNumber() {
//判断是否只有一项,不是就false
Unit unit = units.get(notZero);
String str = unit.toString();
//判断是不是数字
}
public boolean isVar() {
//判断是否只有一项,不是就false
Unit unit = units.get(notZero);
String str = unit.toString();
//判断开头是不是x
}
public boolean isExp() {
//判断是否只有一项,不是就false
//判断开头是不是exp
}
输出时还是会遇到要把正的项提前到最前面的问题,开始时候我仿照上次作业的做法,但是出现了Bug。原因是假如遇到这样的式子-x-exp(x+1)-2+x^2
时候,按照上次方法我会按遇到的第一个加号把字符串左右对换,这样就把exp里的加号换出去了,所以这次我做了一个类似于栈的操作来保证+不在括号里面。
if (ans.charAt(0) == '-') {
int flag = 0;
for (int i = 0; i < ans.length(); i++) {
if (ans.charAt(i) == '(') {
flag += 1;
}
if (ans.charAt(i) == ')') {
flag -= 1;
}
if (ans.charAt(i) == '+' && flag == 0) {
return ans.substring(i + 1) + ans.substring(0, i);
}
}
}
由于时间问题,本次作业我没能来的及进行提取最大公因数的操作。
OO第三次作业
本次作业共有两大需求,递归调用自定义函数(hw2已实现)和求导,几乎没有什么架构上的改动。
UML类图
代码架构
存储形式
把Term也加进Factor就行了
Expr := Term | Term [+|-] Expr
Term := Factor | Factor [+|-] Term
Factor := Expr | NumFactor | PowerFactor | ExpFactor | FuncFactor | Term
流程分析
给Expr,Term,Factor等写求导方法就可以了,在这里为了统一性,所有求导返回的都是Term
//Expr.java
public Factor derive() { //(expr)^n -> n*(expr)^(n-1)*(expr)'
//把每个项求导完加进去,记得存符号
//有指数处理指数
}
//Term.java
public Factor derive() { //前导后不导 + 后导前不导
//乘法原理求导
}
//Number求导直接变0
//Power.java
public Factor derive() { //x^n -> n*x^n-1
//BigInteger减一要用subtract方法
}
//ExpFunc.java
public Factor derive() { //exp(f)^n -> n*exp(f)^n*f'
//一项求导后变成三项
}
具体流程如下图
接着为了避免求导影响我们本来的存储,我对每个部分都实现了深克隆。深克隆部分和toPoly()
原理类似,这里就不多赘述了。
复杂度分析
方法复杂度
选取复杂度最高的几个方法列在这里:
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Processer.adjust() | 23 | 1 | 12 | 18 |
Poly.toString() | 20 | 4 | 10 | 13 |
Unit.powerToString() | 18 | 6 | 4 | 6 |
Unit.toString() | 17 | 2 | 13 | 14 |
Parser.parseFactor() | 13 | 8 | 11 | 11 |
Processer.delPlusAndMinus() | 12 | 1 | 8 | 12 |
Definer.addFunc(String) | 11 | 3 | 7 | 8 |
Expr.toPoly() | 11 | 2 | 7 | 7 |
Lexer.next() | 10 | 2 | 6 | 18 |
在Processer.adjust()
和Processer.delPlusAndMinus()
中使用了大量的循环和分支语句判断输入的字符串的各种情况。
在Poly.toString()
中为了简化输出,也使用了比较大量的分支语句,判断是否是0,可否删去一些括号等操作。
我把Unit类的toString()
拆成了好几个部分来简化复杂度。
在parseFactor()
部分,为了判断是哪种因子,以及因子是否有指数,也进行了比较多的分支判断。
在Definer.addFunc(String)
部分计算形参个数和函数内容,没有使用正则表达式,使用了比较多的分支语句,这使得复杂度显著提升了。
类复杂度
Class | OCavg | OCmax | WMC |
---|---|---|---|
Definer | 3.33 | 7 | 10 |
ExpFunc | 1.14 | 2 | 8 |
Expr | 2.78 | 7 | 25 |
FuncFactor | 1.00 | 1 | 4 |
Lexer | 3.00 | 8 | 15 |
MainClass | 2.00 | 2 | 2 |
Number | 1.00 | 1 | 8 |
Parser | 3.50 | 9 | 21 |
Poly | 4.12 | 11 | 66 |
Power | 1.29 | 2 | 9 |
Processer | 4.60 | 11 | 23 |
Term | 1.78 | 5 | 16 |
Unit | 2.36 | 9 | 33 |
可以看出,负责预处理部分的Processer
部分由于其复杂度过高的adjust()
和delPlusAndMinus()
方法导致其复杂度也很高,但是由于这两个方法分支语句虽然多,但是逻辑不复杂,在调试时候没有遇到什么Bug。
我发现这些复杂度高的类之中,不少是由于其中的toString
方法过于复杂导致的,为了简化输出和方便调试,在toString
方法中进行了比较多的分支判断,所以提升了不少复杂度。Poly类的总循环复杂度甚至达到了令人汗颜的66。
Bug分析
强测
好消息是我在三次强测中都没有出现Bug,但是在第二次作业和第三次作业中因为没有写提取最大公因数损失了一些分数,这里写一点我在中测时候以及自己测试时候遇到的Bug。
第一次作业里面,我在优化输出时候做了一个把正项提前的操作,但是一开始我在优化输出时并没有判断开头项是否是负数,我的正项提前又是基于字符串对换的,这样就会出现以下问题x+x^2-1 -> x^2-1x
,好在自己测试时发现了这个问题。
第二次作业里面,也是在优化输出时候,我判断exp函数内部是否是因子,这样可以少输出一对括号,但是由于我对是否是因子的判断有误,简单来说就是我认为ArrayList
只有一项且是第一项就是因子,但这样是不全面的,有可能第一项是0,后面还有一项,这样就使得一些部分没有化简甚至出现问题。
第三次作业里面,由于函数在定义时可以调用已经定义的函数,这使得我在判断函数有几个形参时候出现了一些问题,在第二次作业中,我是用函数定义这行字符串里有几个,
,那么,如果遇到f(x) = g(x,x)
这种情况,我就会认为f(x)
有三个参数,进而产生RE的问题。还有在处理嵌套求导时候,我一开始把Term.derive
求导完返回的类型设置为Expr
,这样会在嵌套求导时产生类型错误,所以我把返回值也改为了Term
。
互测
在三次互测中也没有出现Bug,这可以算是我的课下测试做的比较充分吧。比较遗憾的是没有自己写自动评测机,很多时候都是手动捏造数据。在Hack方面,我只成功Hack了一次,那位同学的问题应该是在处理自定义函数嵌套调用的时候,没能很好地划分函数的边界,在嵌套调用完函数之后就不能接着往下读入了,可能是从函数实参开始把整个式子都当作Expr
读完了。
规模分析以及架构设计体验
总的来说,本单元代码量不小,尤其是第一次作业的万丈高楼平地起和第二次作业的复杂度飞跃,我都增加了不少代码。但是还是尽量保持了单一职责原则,尽量使每个类之间功能不交叉 ,在例如Unit
和Poly
类里体现的彼此调用,功能互不干扰。
本单元作业比较幸运,仅仅在第二次作业时对Poly
做了一部分结构上的修改,并没有进行原则上的完全重构。其中主要还是参考了比较多的学长博客,并且每次迭代之前都做了比较充分的架构设计,并且在大部分需求上都使用了递归下降原则。
心得体会
有惊无险地通过了第一单元,也没有什么特别的体会。说说在代码方面吧,第一个是在每次迭代的时候,最好能够综合考虑下次迭代的需求,预留下一些空间,对于一些重复的代码,最好能够提取共同的方法。第二个是考虑性能分数时候,也要兼顾正确性。在时间安排方面,要注意提前留出时间写作业,在第二次作业时候,差点因为构思架构太久,而来不及完成作业。
未来方向
互测时候的数据要求的十分严格,希望下次能够在数据不合法给出比较可能的原因,不需要很精确,只要给出大概的方向,例如自定义函数cost太大等。