BUAA-OO-24-Unit1总结

孙延范-22371521 学生 2024-03-20 23:04:34

Unit1总结


前言

在第一单元中,我们使用java实现了表达式的括号展开与化简。在三次的迭代开发中,我们熟悉了面向对象的思想,初步体会了层次化设计的思想的应用和工程实现。三次作业的内容包括——

  1. 包含加减乘的单层括号单变量表达式的展开化简
  2. 加入指数函数和自定义函数的多层括号单变量表达式的展开化简
  3. 加入求导算子和可嵌套自定义函数的表达式的展开化简

每次的作业都是不小的挑战,但同时也提供了不可多得的练习机会。如今第一单元结束,借这次的博客总结,现将本人三次作业的代码架构进行详细讲解,并记录本人的学习体会心得。


第一次作业

题目分析

第一次作业的要求是通过对数学意义上的表达式结构进行建模,完成单变量多项式的括号展开:

读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的单变量表达式,输出恒等变形展开所有括号后的表达式。

从题目中,我们很容易看出表达式是由不同类型的部分组成的,包括常数、自变量、幂函数等因子,由*连接起来的因子即为一个项,而几个项之间的加减即为表达式。

特殊的是由()包括的表达式部分,也可看做因子,作为组成项的部分。根据面向对象的思想,我们可以建立相关的类来处理不同种的表达式部分,并形成表达式树的关系。我注意到幂函数类(包含系数,指数)可以同时包括常数项(指数为0)和自变量(指数为1),于是合并常数、自变量、幂函数为一个Pow,将所有因子统一,同时将+ -符号下放到Pow的系数中,简化了代码实现,对之后的化简处理更有利。然后分别实现储存和处理表达式、项的类ExprTerm,并都以Factor作为接口。表达式树的关系如下图所示。

接下来分析如何处理读入的表达式字符串,首先要进行预处理,用正则表达式去除字符串内的空白字符。然后,我们可以使用正则表达式拆分读入的字符串,分解成常数、自变量、运算符等不同类型的字符,也可以使用递归下降法分析。由于正则表达式分析对于表达式因子的实现比较麻烦,同时拓展性较差,在未来两次的作业迭代中很可能需要大改,甚至重构,于是我选择了递归下降法分析,并借鉴了课题组提供的第一单元训练中的结构,去实现Lexer,Pasrser类,前者用于读取表达式字符串的语法单元并转化为token,如+,*等运算符、123,x等因子和标志进入表达式因子的(等,后者负责将Lexer分析出的token存储到我所实现的Expr类中,基于递归下降的思想,形成树形的储存结构。

最后是化简储存好的表达式并输出,为了尽可能减少输出的字符串长度(卷性能分),需要涉及到合并同类项和特殊情况的化简。

代码分析

UML类图

代码架构的UML类图与类的解释如下:

img

(1)表达式解析

Lexer

在本次作业中,表达式的基本语法单元的类型有数字,变量,运算符和括号,我们在Lexer中判断这些语法单元并使用curToken存下来,在Parser中使用。代码如下:

char c = input.charAt(pos);
if (Character.isDigit(c)) {
    curToken = getNumber();
} else if (c == '+' || c == '-' || c == '*' || c == '(' ||
           c == ')' || c == '^' || c == 'x') {
    pos += 1;
    curToken = String.valueOf(c);
} else {
    throw new RuntimeException("Unexpected character: " + c);
}

将表达式中所有语法单元都解析出来,就轮到Parser发挥作用了

Parser

Parser类中将表达式的解析分成了三部分——parseExpr, parseTerm, parseFactor,每一部分的解析都遵循形式化文法。

由于Term前可以带符号,于是添加parseSign对可能的符号先做解析,若为负号则向Term中多乘一个-1,将符号的处理转化成Term内数字的相乘。

parseExpr为例,因为第一项之前可能带有符号,于是我们就先将符号(+或者-)解析出来,然后解析第1项。解析完第1项后,我们就可以直接使用while循环对后面的项依次进行解析。代码如下:

public Expr parseExpr() {
    int sign = parseSign(1);
    Expr expr = new Expr();
    expr.addTerm(parseTerm(sign));
    while (lexer.peek().equals("+") || lexer.peek().equals("-")) {
        sign = parseSign(1);
        expr.addTerm(parseTerm(sign));
    }
    return expr;
}

parseTermparseFactor也是同理,并注意在parseFactor中读到(是,递归调用parseExpr,这样就把含括号的表达式解析出来了。

(2)表达式的展开与化简

不难发现,本次作业中的Term,如果不含表达式因子,就可以最终化简成coe * x^{exp},也就是将所有常数相乘,变量的指数相加。于是给Power类中添加储存系数(coe)和指数(exp)的变量用来储存。可是含表达式因子的Term就没那么容易搞定了,最终一定是化简成Expr的形式。为了能统一方法,降低代码量与复杂度,我决定给ExprTermPower都实现toExpr方法,作为Factor的抽象方法。在转化成Expr的同时做化简,包括合并同类项等等。这样一来,Term就最终化简成含有一个或多个形如coe * x^{exp} Power对象的Expr。最终,只要给解析出来的Expr调用toExpr方法,在其中递归调用TermFactortoExpr方法,将Term中的Factor相乘,将Expr中的Term相加,就能获取最终的化简表达式。

//Expr.java
public Expr toExpr() {
    for (Term term : this.terms) {
        //先调每个term的toExpr,再调用addExpr
    }
}

public Expr addExpr(Expr expr) {
    //this和expr的exp相同的项合并
}
//Term.java
public Expr toExpr() {
    for (Factor factor : this.factors) {
        //先调每个factor的toExpr,再调用MultExpr
    }
}

public Term MultTerm(Term term) {
    //系数相乘,指数相加
}
//Power.java
public Expr toExpr() {
    Expr expr = new Expr();
    expr.addTerm(new Term(this));
    return expr;
}

获得最简的Expr结构后,就是调用toString()转化成字符串的时候了,这个环节比较简单,只需要注意几个化简的地方:

  • 如果单项式系数为0,则最终结果为0,
  • 如果单项式系数为1,则可以省略系数,简化为x^n
  • 如果单项式系数为-1,则可以省略系数,简化为-x^n
  • 如果单项式x的指数为0,则可以省略x^0,简化为a
  • 如果单项式x的指数为1,则指数部分可以省略,简化为a*x
  • 如果表达式中有正项,将正项放在第一个会比将负项放在第一个节省一个-

(3)代码复杂度分析

方法复杂度:

methodCogCev(G)iv(G)v(G)
Parser.parseFactor(int, Term)23.05.013.013.0
expr.Power.toString()14.010.06.010.0
expr.Expr.toString()11.04.07.08.0
expr.Expr.addExpr(Expr)9.01.06.06.0
Lexer.next()5.04.03.010.0
expr.Expr.MultExpr(Expr)3.01.03.03.0
Lexer.getNumber()2.01.03.03.0

利用MetricsReload工具分析方法复杂度,我发现parserFactor的复杂度较高,这是因为我将所有因子的解析都写在此方法中。为了降低复杂度,在第二次作业迭代时,我将每种因子的解析分别实现方法,在parserFactor进行调用。

(4)强测与互测BUG分析

无,人均满分。

第二次作业

题目分析

第二次作业的要求是通过对数学意义上的表达式结构进行建模,完成多项式的括号展开与函数调用、化简:

读入一系列自定义函数的定义以及一个包含幂函数、指数函数、自定义函数调用的表达式,输出恒等变形展开所有括号后的表达式。

第二次作业在第一次的基础上增加了自定义函数指数函数,并支持了多层括号。相比于第一次作业,第二次作业的难度提升十分明显,不但允许了嵌套,还有自定义函数,大大增加了数据的复杂度,也让性能分的获取变得十分具有挑战性。由于第一次作业采用的梯度下降法自然支持多层括号嵌套,我将本次作业拆分成了两部分,分别设计其实现,并分别测试其正确性。

  • 增加指数函数
  • 支持自定义函数

考虑到自定义函数中也会出现指数函数,为防止潜在的bug(如替换x时把exp中的x也替换了),我先实现指数函数,进行充分测试,确保正确后,再实现自定义函数。

(1)指数函数

第一次作业中,我们把Term化简成了由n个Pow类组成的ExprPow形如coe * x^{exp},而本次作业新增了指数函数,那么最终Term会化简成了由n个形如coe * x^{exp}*exp(Factor)的形式。这种情况下,第一次采用的方法就不适用了,可以用新的类Unit代替第一次作业中的Pow,实现类似的储存效果。

得益于递归下降法的可扩展性,LaxerParser的部分比较简单,逻辑上就是读到'exp'后就进入parserExp,再调用parserExpr,将得到的Expr存入ExpFunc对象,如果是exp(Expr)^n就化简成exp(n*Expr)

(2)自定义函数

实现自定义函数,可以建立一个类来集中处理,用于储存函数的形参和函数体,并实现函数读取、替换的方法。我采用字符串替换的方法,对输入的字符串进行预处理,处理结束后保证没有自定义函数再调用已经实现的解析函数,降低耦合度。

代码分析

UML类图

代码架构的UML类图与类的解释如下:

img

(1)指数函数的解析与化简

ExpFunc

新增ExpFunc类,用于储存exp(Factor)​​,为了方便调用方法,我直接用Expr储存指数部分。由于第一次已经定下了整体架构,ExpFunc也实现Factor接口的toStringtoExpr方法,统一所有FactorExpr再化简,这样就能沿用第一次作业的化简方法。

// ExpFunc.java
public Expr toExpr() {
    ExpFunc expFunc = new ExpFunc(base.toExpr());
    Expr expr = new Expr();
    expr.addTerm(new Term(expFunc));
    return expr;
}

Unit

新增Unit类,储存一个Pow和一个ExpFunc,这样Unit类的作用就类似第一次作业中的Pow,可以储存不含表达式因子的化简后的Term。在第一次作业中,toExpr是化简的方法,在Term.toExpr()中会把每个因子相乘化简出来的结果用一个Pow储存,本次作业就需要使用Unit储存。同时,需要让Expr.MultExpr()支持指数函数相乘,也就是对两个指数调用Expr.addExpr()

化简

为了合并同类项,在Expr.addExpr()中需要判断每个Term化简后unit的x的指数和exp的指数是否相同,x的指数很好比较,但是exp的指数是Expr,很难用常规的方法比较。我联想到Expr.addExpr()就是将相同的项合并,也就是系数相加,那就可以反其道而行之,将相同的项系数相减,如果最后项都消完了,就说明两个Expr相同。于是修改Expr.addExpr(),增加系数相减的功能,在Unit类内实现equals方法。

// ExpFunc.java
public boolean equals(ExpFunc expFunc) {
    Expr expr = this.exp.toExpr().addExpr(expFunc.exp.toExpr(), -1);
    return expr.getTerms().isEmpty();
}
// Expr.java
public Expr addExpr(Expr expr, int sign) {
    Expr result = new Expr();
    ArrayList<Unit> units = new ArrayList<>();
    for (Term term : this.terms) {
        //调用unit的equals方法,当sign=1合并相等的项,sign=-1则相消
    }
    for (Term term : expr.terms) {
        //调用unit的equals方法,当sign=1合并相等的项,sign=-1则相消
    }

    for (Unit unit : units) {
        //相当于删除系数为零的项
        if (unit.isZero()) {
            continue;
        }
        Term term = new Term(unit);
        result.addTerm(term);
    }
    return result;
}

toString也可以对一些特殊情况进行化简:

  • 如果指数为0,则最终结果为1,
  • 如果指数为单个因子,则可以省略一层括号,简化为exp(Factor)
  • exp((a*x))化简成exp(x)^a可以节省两个字符,可以判断如果是这种形式的指数,就把指数提出来

(2)自定义函数的替换

Func

读入函数的方法很简单,不多赘述。替换函数也较为简单,依次读取传入的字符串,如果读取到f g h,就按,分出每个实参,然后按顺序对应替换函数体内的形参。需要注意的点就是字符串替换要注意exp中的x不能替换,我选择将x都换成#(特殊字符),这样就不会和exp产生冲突。

(3)代码复杂度分析

methodCogCev(G)iv(G)v(G)
expr.Expr.addExpr(Expr, int)19.09.010.011.0
expr.Func.expandFunc(String, ArrayList, String, char)17.06.09.09.0
expr.ExpFunc.toString()16.06.09.09.0
expr.Power.toString()14.010.08.010.0
expr.Func.addFunc(String)13.01.05.011.0
Parser.parseFactor(int, Term)12.06.010.010.0
expr.Expr.toString()11.04.07.08.0

利用MetricsReload工具,我发现addFunc方法复杂度较高,因为要判断同类项,并且为了同类项相减复用了这个方法,使得其复杂度提升。expandFunc方法则是要读取实参,并把形参替换,可以把这两部分拆成两个方法。

(4)强测与互测BUG分析

第二次作业中,我的代码出现了bug,就是类似exp((-x^2))化简成了exp(-x^2),忽视了因子前面不可带符号的文法规定。由于我直接将exp的指数用Expr储存,无法直接判断是否是表达式因子,在输出判断时简单的将仅有一个项且不带*的表达式当作非表达式因子,忽略了在先前的输出优化时将-1*x优化成了-x,导致出现缺少括号的情况。再加上对-的特判即可修复,但是很不优雅,更好的做法还是在转成字符串前先判断是不是表达式因子。

事实证明,化简有风险,优化需谨慎

hack出来别人的bug也有我犯的这个错误,以及指数函数的0次方的处理出现bug。

第三次作业

题目分析

第三次作业的要求是通过对数学意义上的表达式结构进行建模,完成多项式的括号展开与函数调用、化简:

读入一系列自定义函数的定义以及一个包含幂函数、指数函数、自定义函数调用、求导算子的表达式,输出恒等变形展开所有括号后的表达式。

第三次作业增加了求导算子,并支持了自定义函数的嵌套。实验课上给了我们一定的参考,加上要求比较简单,本次作业的工作量不大:

  • 增加求导算子
  • 支持自定义函数嵌套

(1)求导算子

求导算子不算因子,而是一种运算,所以其实现具有低耦合度的特点。其可以分解成dx+表达式因子。只要在Laxer中添加dxtoken,在Parser中利用ParserExpr先读进来dx()中的表达式,再对所有Factor实现求导方法,按照求导的法则,如链式法则、乘法法则等实现即可。

(2)自定义函数嵌套

自定义函数嵌套也完全可以沿用已实现的方法,即在读入函数后对函数进行一次预处理,将每个函数体当作一串表达式,调用替换函数的方法即可将函数之间的调用全部展开。

代码分析

UML类图

代码架构的UML类图与类的解释如下:

img

本次的代码修改量较少,且没有建新的类,整体与第二次作业差异较小。

(1)求导的实现

无新增类,在Parser中调解析表达式因子的方法,化简后求导,最后返回。

// Parser.java
...
} else if (lexer.peek().equals("dx")) {
    Expr expr = parseExprFactor();
    expr = expr.toExpr();
    expr = expr.derive();
    return expr;
}

求导方法derive()的实现非常简单,因为表达式已经经过化简,每一项都化成了一个Unit:coe * x^{exp}*exp(Factor),只要求导变成
coe*exp* x^{exp-1} *exp(Factor)+coe*dx(Factor)* x^{exp} *exp(Factor)
即可,其中要对Factor再调一次求导,递归调用

(2)自定义函数嵌套

直接把函数体里的其他函数替换即可

public static String expandAll(String input) {
    if (fFunc != null) {
        fFunc = expandFunc(fFunc, gVars, gFunc, 'g');
        fFunc = expandFunc(fFunc, hVars, hFunc, 'h');
        input = expandFunc(input, fVars, fFunc, 'f');
    }
    // g,h同理
    return input;
}

由于本次作业量比较小,就不分析太多了

(3)强测与互测BUG分析

在第三次作业中,由于我是一次替换一个形参,所以自定义函数的替换出现了BUG,如:
2; f(y,z)=y+2*z; g(z,y)=f(z,y); g(1,2)

g(z,y)展开时先把形参y换成实参z,变成z+2*z,然后把形参z换成实参y,变成z+2*z,就出现了问题。这个bug是第二次埋下的隐患,只不过第二次作业实参只会有x,而x被换成了#,就没有体现出这个bug,这也跟我第三次作业写的太快,测试太少有关。修复这个bug也很简单,先把所有形参替换成特殊符号(如A、B、C),再去换成实参。

总结

架构设计体验

良好的架构为迭代带来了诸多优势,让我印象深刻的就是第三次作业实现完全按原有的架构展开,实现十分清晰,在短时间内就完成了要求(虽然出bug了,但是也算是第二次埋的坑)。递归下降法递归调用的方法完美契合了要求,否则只是人脑解析那些嵌套了一层层的表达式都会CPU烧了。

递归下降法在新增因子扩展性很好,只需要添加部分代码即可,如果未来需要增加新因子,直接在Parser里加一个判断,然后新实现一个相关的类。同时,新增运算也只需沿用原来的解析方法,再添加对于的计算方法即可,如果未来需要增加新运算,也是在Parser里加一个判断,给所有可能的类增加该运算相关的实现。

事实证明,递归下降法很好的完成了它的使命,没有让我重构过代码,感谢课程组一开始的实验题就把递归下降的架构给了出来,赞美课程组。

同时,我将所有Factor都转化为Expr再进行合并和化简也让我轻松不少,统一格式之后就更好进行处理,如两个Factor相加,直接转Expr然后调addExpr。当我需要Factor实现什么方法时,就无脑全实现,并按照实际的类去层层调用。

发现别人bug的策略

一是可以用评测机去尝试测出别人的bug,这种数据的特点就是完全随机,指不定哪个数据就碰到bug了。问题是写的不好过不了互测的要求(

二是使用自己开发时试过的数据,特别是测出自己bug的数据,这种数据的特点是疯狂试探边界条件,如((2)^8)^8,exp(exp(exp(exp(exp(exp(exp(exp(x))))))))。但是容易因为没想到某个要点而错过hack成功的机会。

自己的优化

  • 长度方面

基本上就是常规的输出优化,顺便做了一个减少括号的特判。

  • 时间方面

在实现第二次作业时,群里出现了一个巨大数据,而我的程序无法运行。为了优化,我进行了很多剪枝优化,如0*(一堆东西),理论上后面的都不用解析了,就是0。再如(一堆东西)^0,是1。此外,我将指数函数的次方处理做了优化(exp(一堆东西)^n),本来我秉着能复用就复用的心态,是有几次方就调addExpr加几次,但是addExpr需要合并同类项,要比较很多东西,导致很慢,所以改成直接把n乘到括号里,快了不少。

我的优化比较难保证准确性和简洁性,毕竟性能和优雅难以兼得,长度方面的特判就要写一坨的if-else,很不简洁。优化后也可能出现bug,不过毕竟优化前的版本肯定是正确的,只要优化的范围不大,debug还是比较容易的。

心得体会

OO课程的难度还是不低的,相较于过去所写的代码,OO的作业算是在写一个较大的项目了,这对个人的技术要求不低。我平均每次作业都要花上6-7小时左右的时间,但也积累了很多经验。以往写的代码量少,没什么架构,能跑就完了,现在写项目,对好架构的需求就很大了,否则重构就是家常便饭。同时我也意识到写代码前一定要深思熟虑,构思好再写,否则人和代码总得跑一个,人和学分总得没一个。

OO的互测也让人印象深刻,当发现自己有bug时的懊恼不解,到发现某地方犯蠢,属实是不可多得的体验。

未来方向

这一单元整体质量很好,让人感受到了写OO的快乐,实打实提升了个人的OO能力。三次作业循序渐进,不过第三次作业确实有点简单了,希望加大力度,当然也可能是考虑到希望一些人能花时间填前面的坑,让通过的人数更多。

然后就是希望性能分不单单以输出长度为指标,也可以从时长来评判,毕竟处理的速度更快也是代码优化得好的体现。为了不增加负担,可以作为加分项,比如额外设立几个大数据点,用时时长短的可以补一点因为强测所失的分,对于大佬可以作为救场的方向。

...全文
118 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧