OO第一单元总结

本文记录了作者在学习OO编程中的经历,从第一单元的表达式括号展开开始,详细描述了面对不同作业的挑战,如代码复杂度分析、类设计与优化,以及作者的心得体会和对未来方向的思考。
摘要由CSDN通过智能技术生成

OO第一单元总结

前言

多年以后,面对产品经理深夜发来的需求变动时,周泽同总会想起他完成OO作业的那个下午。

第一单元的主题是表达式括号展开,主要的学习目标是熟悉面向对象的思想,掌握用类来管理对象的方法以及模块化设计的能力。本单元的三次作业分别为——单变量多项式展开含指数函数、自定义函数的多项式展开含多层嵌套及求导因子的多项式展开。这三次作业中,第二次作业的难度较为突出。第一次作业在掌握递归下降法,第三次作业在前两次作业架构基本完成的基础上仍有一定挑战性,但通过细心思考可以较好地解决。第二次作业我思考与设计耗费的时间更长,尤其是在判断表达式相等的地方,我通过较为复杂的处理才将其解决。

然而不论如何,OO第一单元终究落下了帷幕。回顾这三个星期的历程,我心中不由得涌起一阵喜悦与感动。当然,这一方面是因为我学到了许多Java基础知识和面向对象思想;而另一方面,是因为我本身就很享受这段时光:在深夜中聆听思维流淌,在通过测试时与舍友分享喜悦,这些点点滴滴总让我体会到活着的实感。我从未想过,写OO作业,竟会成为我平淡6系生活中的光。

谨以此文献给Hyggge。尽管我们素不相识,但陈学长的博客给我许多启发与帮助。在此献上我诚挚的谢意。

基于度量的程序结构分析

代码规模

代码复杂度

通过分析可知,Definer, Mono, Parser, Poly, Processer五个类复杂度较高。其中Definer, Parser, Processer这三个类是由于我对于正则表达式不够熟悉,同时贪图简便,直接使用分支控制语句遍历所有情况进行处理,导致复杂度偏高,这部分是在进一步打磨代码时应该着重加以改进的。而Mono, Poly这两个类,更多是类中操作的多样性和为提高性能而增加的特判导致的。在不对架构进行大的调整的前提下,这样的高复杂度在某种程度上是不可避免的。

最终UML图

根据类图可以看出,程序中各个类的功能性突出,从输入到输出有明确的路径,在进行拓展时较为简便。但从另一方面来说,程序的层次性不够突出,除了不同因子共同实现Factor接口外,其他的类仿佛“散落”在周围,只有需要完成具体功能时才会实例化对象并调用其中方法。在此后的程序设计过程中,我认为这一点是可以着重改进的。

架构设计体验

第一次作业

第一次作业分为解析表达式将表达式树输出为标准格式两个部分。解析表达式的代码架构我基本复用第一单元training的代码,将输入构建为表达式树。而将表达式输出为标准格式部分,我一开始打算遍历表达式树生成后缀表达式,再利用栈来计算后缀表达式的值。然而经过进一步的思考,我认为这种方法求解过程复杂,没有摆脱面向过程程序设计的桎梏;除此之外,如果后续引入其他因子,对于后缀表达式的计算逻辑还需要大幅修改,可拓展性不高。因此,在深入思考和参考学长博客后,我选择采用Hyggge学长的架构,设立单项式Mono类和多项式Poly类,递归调用toPoly方法得到标准输出。接下来我将就几个重要的类进行分析。

Parser类 

public class Parser {
     private final Lexer lexer;
 ​
     public Parser(Lexer lexer) {
         this.lexer = lexer;
     }
 ​
     public Expr parseExpr() {
         Expr expr = new Expr();
         int sign = 1;
         if (lexer.peek().equals("+")) {
             ...
         } else if (lexer.peek().equals("-")) {
             ...
         }
         expr.addTerm(parseTerm(sign));
 ​
         while (lexer.peek().equals("+") || lexer.peek().equals("-")) {
             if (lexer.peek().equals("+")) {
                ...
             } else {
                 ...
             }
         }
         return expr;
     }
     ...
 }

Parser类的属性仅有一个lexer,即词法解析器,依次读入字符并加以解析。根据形式化表述,我们将表达式以+, -号为分隔,把表达式的解析下降为项的解析,并依此类推将项的解析下降为因子的解析。对于符号的处理是我自认为相对独特的一个设计:由于表达式前的正负号表示第一个项的正负,项前的正负号又表示第一个因子的正负,因此我将符号随递归向下传递到因子一级统一处理,简化了正负号处理的复杂程度。

Poly类

public class Poly {
     private ArrayList<Mono> monoList = new ArrayList<>();
 ​
     public Poly addPoly(Poly other) {
         ...
     }
 ​
     public Poly mulPoly(Poly other) {
         ...
     }
 ​
     public Poly powPoly(int exp) {
         ...
     }
 ​
     public Poly sortPoly(Poly tmp) {
        ...
     }
 ​
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
         if (monoList.size() == 0) {
             ...
         }
         if (monoList.get(0).getCoe().compareTo(BigInteger.ZERO) < 0) {
             ...
         }
         for (int i = 0; i < monoList.size(); i++) {
             ...
         }
         Processer processer = new Processer(sb.toString());
         return processer.getOutput();
     }
     ...
 }

Poly类的属性变量为一个装有mono的ArrayList,同时提供add, mul, pow三种对poly进行运算的方法。sortPoly为对poly进行多项式合并和降幂排序的方法,toString为调用Mono类toString方法,并将mono的字符串表示连接起来以表示poly字符串的方法。值得注意的有两个地方:一是在add, mul, pow三种方法返回前,我都对结果调用了一次sortPoly进行多项式合并,从而避免了长多项式高阶展开时项数的指数级增长,降低了TLE的风险;二是在toString方法中调整项的顺序以尽可能保证第一项为正,从而缩短一个字符长度。

第二次作业

第二次作业增加的内容包括自定义函数因子的解析和指数函数因子的解析。自定义函数因子通过实参替换形参完成解析,指数函数因子通过解析exp括号内因子并改变标准项形式完成解析。判断exp括号内表达式相等以完成同类项合并的过程较为复杂,我最初有三种想法:一、在Poly类的层次进行判断,用ArrayList存储mono,使用双重循环进行比较。虽说听起来很简单,但比较过程中涉及递归,且我在完成该方法后感觉与大一的C语言编程太过类似,没有面向对象的“美感”,故放弃之;二、在Poly类的层次进行判断,用HashSet存储mono,利用HashSet里元素的无序性进行“一键比较”。然而,“一键比较”的前提是改写Mono类的equals和hashCode方法,而这一过程又会反过来影响到属性值相同的mono项无法同时加入HashSet中。经过与同学的讨论,我发现可以将HashSet改为更为复杂的HashMap,设计映射关系以解决该问题。但是这样的做法对我第一次作业的架构改动较大,我想寻求一种更加符合已有架构的方法。三、在字符串层次进行判断,对Mono类实现Comparable接口,将mono按照字典序进行排序,然后对poly调用toString方法,直接使用字符串的equals方法进行比较。这种方法与第一种其实本质上相差不大,在转化为字符串时也涉及递归调用,但我感觉更加“优雅”,需要添加的代码也更少。

Poly

  public Poly sortPoly(Poly tmp) {
         Poly resultPoly = new Poly();
         ArrayList visitedList = new ArrayList();
         for (int i = 0; i < tmp.monoList.size(); i++) {
             Mono baseMono = tmp.monoList.get(i);
             Mono cloneMono = new Mono(baseMono.getCoe(), baseMono.getExp(),
                     baseMono.getExpPoly());
             if (!visitedList.contains(baseMono)) {
                 visitedList.add(baseMono);
                 for (int j = 0; j < tmp.monoList.size(); j++) {
                     Mono mergeMono = tmp.monoList.get(j);
                     boolean flag1 = mergeMono.getExpPoly().equals(baseMono.getExpPoly());
                     boolean flag2 = !(visitedList.contains(mergeMono));
                     boolean flag3 = (mergeMono.getExp().equals(baseMono.getExp()));
                     if (flag1 && flag2 && flag3) {
                         cloneMono.setCoe(cloneMono.getCoe().add(mergeMono.getCoe()));
                         visitedList.add(mergeMono);
                     }
                 }
                 if (!cloneMono.getCoe().equals(BigInteger.ZERO)) {
                     resultPoly.monoList.add(cloneMono);
                 }
             }
         }
         return resultPoly;
     }
 ​
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
         if (monoList.size() == 0) {
             return "0";
         }
         Collections.sort(monoList);
         if (monoList.get(0).getCoe().compareTo(BigInteger.ZERO) < 0) {
             ...
         }
         int cnt = 0;
         for (Mono mono : monoList) {
             ...
         }
         Processer processer = new Processer(sb.toString());
         return processer.getOutput();
     }
     
     public boolean equals(Object obj) {
         if (this == obj) {
             return true;
         }
         if (obj == null || getClass() != obj.getClass()) {
             return false;
         }
         Poly other = (Poly) obj;
         if (monoList.size() == 0 && other.monoList.size() == 0) {
             return true;
         } else if (monoList.size() != other.monoList.size()) {
             return false;
         } else {
             return this.toString().equals(other.toString());
         }
     }

以上为我在Poly类中对多项式合并所做的操作。关键是重写Poly类中的equals方法,使其调用toString判断相等,以及在toString的最开头用Collections.sort()进行排序。

第三次作业

第三次作业相比前两次作业难度明显降低,相比第二次作业,我加的代码行数可能不超过30行,尤其是选择直接对标准项统一求导,可以极大地简化求导的处理逻辑。具体而言,标准项形式为mono=ax^{n}exp(poly),在这一前提下如何对其求导就不言而喻了。

Mono

 public Poly getDer() {
         Poly basePoly = new Poly();
         basePoly.getMonoList().add(this);
         Poly poly1 = basePoly.mulPoly(expPoly.getDer());
         BigInteger otherCoe = coe.multiply(exp);
         BigInteger otherExp = exp.subtract(BigInteger.ONE);
         Mono otherMono = new Mono(otherCoe, otherExp, expPoly);
         Poly poly2 = new Poly();
         poly2.getMonoList().add(otherMono);
         return poly1.addPoly(poly2);
     }

我们拿常数的导数来检验一下该方法是否需要增加特判。常数的指数为0,求导时出现指数为-1的项,其实是不符合文法的,但otherCoe被提前置为0,在sortPoly的过程中不会加入到ArrayList中,故可以正确解决这种情况。

重构与扩展

很幸运的是,我的程序并未经历重构。如果要考虑拓展的话,我的假设是增加学长们曾经做过的需求:三角函数。我的架构可以较好地兼容这一需求,具体分析如下:

  • 标准项方面,区别于指数函数可以先把幂乘进括号内以进行统一处理,三角函数在大多数时候无法完全合并。因此,标准项的构成为mono=ax^{n}exp(poly)\prod_{i=1}^{t}sin(poly_{i})\prod_{i=1}^{k}cos(poly_{i}),需要额外设计两个容器存储sin与cos内部的值。

  • 合并同类项方面,在字符串层面判断表达式相等的优势就体现出来了。我们几乎不需要改动代码,仍然使标准项按字典序进行排序就好。只要标准项的顺序是按照确定顺序排列的,相同的表达式就一定对应相同的字符串,对于相等的判断也就一定正确。

  • 优化方面,毫无疑问,三角函数的优化技巧相比于指数函数更加多元,诱导公式、多倍角公式、平方关系等内容都可加以利用。之后有时间的话,我打算对这部分加以研究,提高自己的编程能力。

自己与他人的bug

很幸运的是,我的程序在公测,强测与互测中均未出现bug(当然,编写代码过程中的不计入考虑,否则bug多得我自己都记不清)。在互测中,我成功hack 3次,分别针对的问题是:指数函数内少加一层括号形参与实参顺序相反时函数替换出错每层嵌套都进行优化导致时间复杂度过高。通常来说,我会先将房间里所有人的代码打包为jar,然后放到评测机上跑,然后简化出现错误的数据点。具体简化步骤为:将函数带入以确定是否在函数替换步骤出错;单独拿每一项运行以观察是哪一项出错;每次修改一个可能出错的地方以观察是在具体哪个点出错。我一般没有去具体分析他人源代码以寻找错误的习惯,但若我已经发现了他人的错误点,我会去分析源代码来了解其为何出错。例如对于第二个问题,出错者未将函数表达式中的x先行替换为其他不会在实参中出现的字母,同时使用replaceAll进行替换,导致在第一个实参替换中出现的x可能被第二个实参替换重新覆盖。

自己所做的优化

  • 第一次作业:

    • 指数为0时输出1

    • 指数为1时不输出指数

    • 系数为+-1时省略正负号

    • 第一项为负时尽量调整顺序以使第一项为正

  • 第二次作业:

    • 遍历指数函数括号内出现的所有情况以去括号

    • 计算指数函数括号内各项的系数最大公因数并将其提出。比较各项提出最大公因数前后的长度大小,选择长度较小的形式输出。

  • 第三次作业:

    • 同第二次作业,未做其他的优化。

每次优化前,我都会本地保存副本,保证在优化出错后可以尽快恢复。除此之外,每次优化我都会尽可能考虑所有情况,测试边界数据,力求优化的正确性。当然,对特殊情况进行特判有损代码的简洁性,但这或许也是提高性能所需付出的必要代价。

心得体会

其实在大一下时我们就用C语言写过表达式解析,只不过当时题目的复杂度远远不及这次所做的项目。我记得当时的我写得并不大顺利,而这次的项目虽然花的时间久,思考的时间长,但过程并不坎坷,这也是多亏面向对象的思想,使得设计架构更加清晰,编写代码也更加得心应手。我并不大想在这里长篇大论地讨论我具体收获了哪些知识,一方面很多思想很难用语言准确表达,另一方面过多的知识罗列又会把这个栏目变成无趣的指导书。所以我只想在此抒发我最为真实的体验感受:有些困难,也有收获,虽然很累,但都值得。

未来方向

  • 第三次作业的工作量设置不合理。我在当晚就完成了代码,总添加代码仅在30行左右。

  • 互测构造数据点的代价限制希望可以提高一些。尽管我能理解这是为了防止恶意hack以及锻炼我们精准定位bug的能力,但我经常感觉,5000的代价什么数据都构造不出来(

  • 希望能够在互测时提供统一的jar包,每次打包总是浪费时间,而浪费的时间又不够迫使我去编写脚本(

  • 希望衡量程序性能时将运行时间也纳入考虑。本地测试的时候有部分代码在面对某些数据时会TLE,但这些数据的长度是远远超过互测甚至强测的数据限制的。尽管没有必要将这些数据纳入正确性考察的范畴,但作为衡量性能的依据我认为是有一定道理的。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值