2024春-OO课程Unit1总结


前言

第一单元作业是实现复杂多项式化简,第一次作业实现较为基础的运算,第二次作业加入了自定义函数与指数函数等,第三次作业则加入了表达式求导。第一单元的思想、算法更加偏向面向对象、复杂迭代,与上学期的OOpre课程题目难度相去甚远,因此在构建整体架构时确实消耗了不少精力。本篇总结主要是记录一下三次作业的架构设计、各种优缺点分析,以及自身一点心得体会。


一、作业架构分析

Homework1

题目重点重述

第一次作业主要是实现较为简单的基础运算,运算有加(+)、减(-)、乘(*)、乘方(^),并涉及到带括号的优先运算(无多括号的迭代)。主要概念涉及到表达式因子

程序UML图

hw1-1 UML图

主要方法是梯度下降法。流程大致为通过Lexer类将字符串转换为一个个Token,接着使用Parser类构建出表达式树(结点为ExprTermFactor类,其中Factor类可能含有Expr类元素,因此构建表达式树过程中会出现迭代过程)。通过各类的getVars()函数将各元素转换为Var的列表类,并进行合并化简计算,最终将表达式树转换为目标字符串。

复杂度

类复杂度表格如图所示:其中OCavg指代平均操作复杂度,OCmax 指代最大操作复杂度,WMC 指代加权方法复杂度

ClassOCavgOCmaxWMC
Expr2.5615
Lexer31115
Main111
Num113
Operation2.75411
Parser3.25713
Term3.67611
Token113
Var2516

可以观察到,OperationLexerParserExprTerm类复杂度较高,这是意料之中的。这几个类作为整个程序最重要的操作类,方法众多。但仍应注意到,有些类最大操作复杂度较高,这证明程序设计中出现的部分方法代码长度大、实现功能过程复杂,仍与高内聚,低耦合的设计原则有出入,仍需要优化。

Homework2

题目重点重述

第二次作业与第一次作业主要区别有三点:自定义函数(无自定义函数的嵌套)、以e为底的指数函数(可多层指数函数嵌套)、多次括号迭代化简。自定义函数函数名限定为 f f f g g g h h h,形式参数名限定为 x x x y y y z z z

程序UML图

hw1-2 UML图

在第一次作业的基础上,增加了Function类,使用字符串替换的方法,在预处理阶段将表达式中形参函数替换为实参函数。对于指数函数,则通过新建Exp类存储。在第一次作业中,由于Parser可以实现迭代处理字符串表达式为表达式树,而多层括号则可以理解为Expr嵌套,因此实际上梯度下降法对该要求有着天然优势。

复杂度

类复杂度表格如图所示:其中OCavg指代平均操作复杂度,OCmax 指代最大操作复杂度,WMC 指代加权方法复杂度

ClassOCavgOCmaxWMC
Exp1.2525
Expr2.6768
Function5.331116
Lexer3.81519
Main222
Num112
Operation3.29823
Parser3.75815
Term2.3357
Token113
Var2518

可以观察到,OperationFunctionLexerParserExprTerm类复杂度较高(这仍是意料之中的,毕竟代码量只加不减)。这几个类作为整个程序最重要的操作类,方法众多,逻辑交叉复杂。但仍应注意到,新加类Function类操作复杂度尤其高,这是因为该类使用字符串替换方法,而这种方法更加面向过程而非面向对象,平替方案见下文优化策略。

Homework3

题目重点重述

第三次作业与第二次作业的区别主要有两点:表达式求导、函数迭代。其中,对于任意表达式,均对自变量x进行求导,求导可以迭代。函数迭代过程不能出现循环迭代,即函数定义时,引用函数只能引用已经出现的函数。

程序UML图

hw1-3 UML图

在第二次作业的基础上,增加了derive()方法,在parser的过程中,直接对Token.Type == DER的元素求导。这是一个迭代求导的过程,因此很容易解决对含有导数表达式的式子再次求导。在第二次作业中,处理函数表达式的方法是字符串替换,并且替换停止的判断条件是遍历替换后表达式不再出现函数名,因此也可以很好的处理函数嵌套的问题。

复杂度

类复杂度表格如图所示:其中OCavg指代平均操作复杂度,OCmax 指代最大操作复杂度,WMC 指代加权方法复杂度

ClassOCavgOCmaxWMC
Exp1.226
Expr2.5610
Function5.331116
Lexer3.251126
Main222
Num113
Operation3.43824
Parser3.75715
Term2.5410
Token113
Var2520

可以观察到,OperationFunctionLexerParserExprTerm类复杂度较高(这当然还是意料之中的)。可以发现,Lexer类的最大复杂度有所降低,这是因为在第三次作业中,将部分Lexer构造方法中复杂的函数部分提出来作为了新的方法,降低了耦合度。但仍应注意到,优化后的FunctionLexer类操作复杂度仍然很高,两类中部分方法对字符串进行了详细解析,这样显然也是更加面向过程而非面向对象,可以通过提取出新方法来优化。


二、架构重点实现分析

Homework1

第一次作业程序中主要有三个实现的重点:

  • 表达式预处理: 主要处理了以下三类特殊情况:

    • 多个连续符号

    • 变量前出现负号

    • 数字前仅有负号,没有连接的正号

    具体过程如下:

    		Pattern pattern = Pattern.compile("\\+\\+|--|-\\+|\\+-|\\*\\+|\\^\\+|-x|-\\(|-exp|-dx");
            while (pattern.matcher(preLine).find())
            {
                preLine = preLine.replaceAll("\\++|--", "+");
                preLine = preLine.replaceAll("-\\+|\\+-", "-");
                preLine = preLine.replaceAll("\\*\\+", "*");
                preLine = preLine.replaceAll("\\^\\+", "^");
                preLine = preLine.replaceAll("-x", "-1*x");
                preLine = preLine.replaceAll("-exp", "-1*exp");
                preLine = preLine.replaceAll("-dx", "-1*dx");
                preLine = preLine.replaceAll("-\\(", "+-1*(");
            }
            preLine = preLine.replaceAll("-", "+-");
            preLine = preLine.replaceAll("\\*\\+-", "*-");
            preLine = preLine.replaceAll("\\(\\+", "(");
    
  • 梯度下降法构建表达式树: 主要使用ExprTermFactor三个类型的处理方法,在Parser类中主要对应以下三种方法:

    • parserExpr:主要对Expr类处理,以加号作为分隔界限,调用parserTerm方法获取该表达式内的各项;
    • parserTerm:主要对Term类处理,以乘号和乘方符号作为分割界限,调用parserFactor方法获取该项内的各因子;
    • parserFactor:主要处理Factor类型的对象,按照对象类型,构建新对象NumExpVar存入列表中,或者对Expr类再迭代调用parserExpr方法并存入列表中。
  • getVars()统一接口: 将表达式树中结点计算合并,重新变为字符串。为统一接口,方便化简,定义了这种方法。在程序中,我们将Var类定义为形如 R a t i o ⋅ ∏ i = 1 n V a r i P o w e r i Ratio\cdot\prod\limits_{i=1}^{n}{{Var}_i}^{Power_i} Ratioi=1nVariPoweri的变量,这样各类都可以很方便地转化为Var类,从而达到统一接口的需求。

Homework2

第二次作业程序中主要有三个实现重点:

  • 自定义函数: 本次作业中,主要使用字符串解析替换的方法,建立Function类,属性有函数名,变量名列表,函数形式表达式。使用时将实参导入,替换所有对应形参,并返回替换后字符串。函数处理步骤位于预处理与表达式建树两步之间。

  • Exp类指数函数: 本次作业中,为指数函数建立Exp类,将指数作为属性传入,并在构造函数中将指数作为表达式,按照流程化简。如此,当指数中仍含有Exp类时,迭代调用化简;当指数中不含Exp类时,直接化简。

    具体过程如下:

    	public Exp(String con)
        {
            this.power = BigInteger.ONE;
            this.content = con.substring(1, con.length() - 1); //这里只用中间段字符串是因为con传入时带两侧括号
            this.unfoldContent();
        }
        public void unfoldContent()
        {
            Operation operation = new Operation();
            operation.setBeforeStr(this.content);
            operation.preDeal();
            operation.unfold();
            operation.merge();
            this.calculateRes = operation.getResult(); //content代表计算前表达式,caculateRes代表化简计算后表达式
        }
    

Homework3

第三次作业程序中主要有一个实现重点:

  • 任意表达式求导: 本次作业中,遵循递归求导的法则,建立derive()方法,逐步实现表达式因子的最终结果。
    • Expr对象求导:Expr对象由多个Term相加组成,因此只需要对各Term求导,将所有结果均放入容器储存即可。
    • Term对象求导:Term对象由多个Factor相乘组成,按照多项式求导的乘法法则和链式法则,对一因子求导,保持其他因子不变,组成新项。将多个新项均放入容器存储即可。应注意,如不加处理,Term对象求导之后应为Expr对象。
    • Factor对象求导:Factor因子主要分为NumVarExpExpr四类;为统一接口,在获得求导结果后,将其全部转换为Term类。
      • Expr 求导方式如上所示;
      • Num 求导之后数值为0,类型仍为Num类;
      • Var 求导之后按照指数分为两类:
        • 求导后指数等于0:求导之后数值为 R a t i o ⋅ P o w e r Ratio \cdot Power RatioPower,类型变为Num类;
        • 求导后指数大于0:求导之后 R a t i o Ratio Ratio R a t i o ⋅ P o w e r Ratio \cdot Power RatioPower P o w e r Power Power P o w e r − 1 Power-1 Power1,类型仍为Var类;
      • Exp 求导之后整体形式变为 exp ⁡ ( c o n t e n t ) ⋅ d x ( c o n t e n t ) \exp(content) \cdot dx(content) exp(content)dx(content),类型变为Term类。

三、类设计介绍与目标

本次设计总共建立了11个类,1个接口,现介绍如下:

  • Main:主类,按照流程进行读入、预处理、展开、化简。
  • Operation:操作类,内含预处理方法、函数替换方法、函数解析方法、展开方法、化简方法。设计其是为了各操作最上层的耦合调用。
  • Lexer:字符串解析类,面向过程解析字符串,将整体表达式解析为多个Token。设计其是为了按照字符特征将字符串切割为各个Token包,方便解析。
  • Parser:解析类,使用递归下降法将经过Lexer处理的Token串建立为表达式树。设计其是为了建立表达式树,方便展开时按照计算顺序获取最终字符串表达式。
  • Function:函数类,内含函数各种属性和处理方法。设计其是为了方便在Lexer里遇到形式函数的解析以及形式函数的替换操作。
  • TokenLexer解析里的最小单元,含有多种类型。
  • Expr:表达式类,内含表达式各种属性和处理方法。设计其是为了方便在Parser里获取表达式,在展开时其各项统一处理。
  • Term:项类,内含项各种属性和处理方法。设计其是为了方便在Parser里获取项,在展开时其各因子统一处理。
  • Factor:因子接口,统一接入各因子类。
    • Num:数字类,内含常数各种属性和处理方法。设计其是为了方便在Lexer里遇到常数的解析以及各种涉及常数的操作。
    • Var:变量类,内含变量各种属性和处理方法。设计其是为了方便在Lexer里遇到幂函数变量的解析、各种涉及变量的操作以及表达式树在展开使用方法getVars()时统一各类接口。
    • Exp:指数函数变量类,内含指数函数各种属性和处理方法。设计其是为了方便在Lexer里遇到指数函数的解析以及各种涉及指数函数的操作。

四、Bug分析及程序优化

Homework1

Bug分析

第一次作业暂无Bug

优化策略

设计最初时,出于对统一性的思考,缺省了对于性能的追求。现对作业过程中所做优化进行记录:

  • 多余0项未化简: 最初设计为保障统一性,因此在任何表达式开头前侧均增加了一项“0+”,这会增加在计算化简过程中需要存储的项数,从而大大增加空间需求。为降低资源浪费,不选择该种方法,而是选择将以正号开头的表达式第一项符号去除,以达到统一性。

  • Term对象连接符号: 最初设计将连接各项的符号提出,储存为一个列表,单独处理。这大大增加了设计的复杂度,因此在更改后,将减号作为负号存入数值中,各项之间只用加号连接,降低了设计难度。

  • 乘方运算: 最初设计将乘方使用循环转化为多项同一项相乘,这增加了化简后表达式的长度,更改了Var类的属性,存入系数 R a t i o Ratio Ratio和指数 P o w e r Power Power,对于Expr类的元素,则仍按照循环转化相乘的方法计算,提高了化简后表达式的性能。

Homework2

Bug分析

第二次作业无逻辑bug,而是集中在性能问题上:

  • 单项式多次乘方运算: 第二次作业中,单项式的高阶次方形如 ( ( F a c t o r ) i ) j ((Factor)^i)^j ((Factor)i)j,由于括号的出现,在parser过程中会将其解析为仅含一项的Expr,这样运算时,会将其展开为多项。当乘方运算过多时,会出现表达式树层数过深,结点过多的问题,这会大大影响程序的性能乃至程序的可使用性和结果的正确性。
优化策略

设计最初时,出于对统一性的思考,缺省了对于性能的追求。现对作业过程中所做优化进行记录:

  • 单项式多次乘方运算: 最初设计时,没有考虑多次乘方对于程序性能的影响。调整时在parserTerm()函数中添加特判,当Expr对象中仅含有一个项,且这个项中仅有一个NumExpVar因子,那么将这一个Expr对象取出,加入一个相应因子。
  • 函数处理面向过程: 最初设计时,将每一个函数解析为Function类,利用解析出的形参在使用中和实参替换,从而达到效果。这样的字符串解析显然过于面向过程,实际上可以将自定义函数式也当作表达式,使用Parser类解析为表达式树(并且形参必定处在叶结点位置),化简表达式时,只需要在对应位置将形参结点替换为实参表达式树,最终将整棵表达式树转换为字符串即可。
  • 多项式合并: 最初设计时,是将Exp函数中指数作为字符串储存,当字符串中各项相同但位置不同时,就会出现无法合并的问题。这个问题有一个简单的解决方案,即将字符串按照正号拆开,利用字符串处理函数replace()进行字符串减法,来判断两字符串是否相等。笔者才疏学浅,没有想到这一个方法,而是想到了另一个异曲同工的方法:即将Var改为多变量相乘的形式,存储整体系数,各变量变量名,各变量指数。实际上这两种方法是一样的,第一种方法相当于整体存储所有Exp指数之和,第二种方法则相当于按项存储各Exp指数。

Homework3

Bug分析

第三次作业存在一些笔者自身的失误导致的bug,现分享为其他找bug的朋友提供一个思路:

  • 方法调用时对象错误: 形如 d x ( x n ) dx(x^n) dx(xn),笔者在添加Var.derive()函数时,建立新的Var对象,但在调用方法时选错对象,导致改变属性时发生错误,从而导致了整个程序未能通过测试。

    		if (newPower.compareTo(BigInteger.ZERO) > 0) //当幂函数求导后指数大于零,则仍为变量
            {
                Var var = new Var("x");
                var.changeRatio(newRatio, "*"); //笔者最初并未使用var,导致方法调用不是所希望的对象,出现错误
                var.changePower(newPower, "*");
                termFactors.add(var);
            }
    
优化策略

第三次的程序基于第二次的程序,在性能上并未做过多调整。

  • Var类存储Exp指数方式更换: 在第二次作业中,存储字符串形如exp((指数)),在第三次作业中则改为指数。这是一个小改动,但对程序复杂性有一定程度的优化。在迭代处理Exp对象时,更改前,需要对字符串进行处理,提出其中的指数,改动后则不必再对字符串属性做其他处理,可直接使用。

五、心得体会

挺过开学的三板斧,终于有时间整理一下在前三周的所学所想。不得不说,虽然经过先导课程和各位学长学姐的提醒,对本课程已经有了足够的心理准备,但一上来还是被打懵了。可能从当初的c语言学习开始,字符串处理就一直不是一个我喜欢的问题,毕竟涉及到多次迭代,各种栈的相关操作,以及各种奇怪不统一的输入数据。但现实中的问题输入都是多样而复杂的,因此统一化的预处理能力是极其有必要的。当我们不能改变环境时,那就努力的去适应环境吧。

小小发泄之后,就谈谈获得的一些经验吧:

  • 预习的重要性。 在寒假时,课程组就将梯度下降法的算法介绍发到公众号中。笔者之所以觉得第一周压力极大,还是因为没有提前进行详尽的预习,对梯度下降法理解还不够深刻。
  • 讨论的重要性。 三次作业中加入了一节讨论课,在这节课和不同同学讨论之后,对梯度下降法等算法有了更深刻的认识,对于字符串处理的方法也有了更多的想法,对开阔视野和思路极有帮助。
  • 测试的重要性。 课程组鼓励自建评测机,通过多组数据测试才能更加完全验证程序的正确性,这也是未来工程所要求的能力。笔者对于这部分的思考较为匮乏,还需要向大佬们学习。

至于对课程的建议,也没什么想法。硬要说的话,应该是正课课程与先导课程的跳度过大吧。不过公众号上算法的介绍与代码的给出,倒是帮助减少了不少难度。

最后,还是要感谢助教nr学长和gc学姐的帮助。也希望在之后的学习中能再接再厉,继续进步。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值