面向对象课程Unit1总结
前言
第一单元作业是实现复杂多项式化简,第一次作业实现较为基础的运算,第二次作业加入了自定义函数与指数函数等,第三次作业则加入了表达式求导。第一单元的思想、算法更加偏向面向对象、复杂迭代,与上学期的OOpre课程题目难度相去甚远,因此在构建整体架构时确实消耗了不少精力。本篇总结主要是记录一下三次作业的架构设计、各种优缺点分析,以及自身一点心得体会。
一、作业架构分析
Homework1
题目重点重述
第一次作业主要是实现较为简单的基础运算,运算有加(+)、减(-)、乘()、乘方(^),并涉及到带括号的优先运算(无多括号的迭代)。主要概念涉及到表达式、项、*因子。
程序UML图
主要方法是梯度下降法。流程大致为通过Lexer
类将字符串转换为一个个Token
,接着使用Parser
类构建出表达式树(结点为Expr
、Term
、Factor
类,其中Factor
类可能含有Expr
类元素,因此构建表达式树过程中会出现迭代过程)。通过各类的getVars()
函数将各元素转换为Var
的列表类,并进行合并化简计算,最终将表达式树转换为目标字符串。
复杂度
类复杂度表格如图所示:其中OCavg
指代平均操作复杂度,OCmax
指代最大操作复杂度,WMC
指代加权方法复杂度
Class | OCavg | OCmax | WMC |
---|---|---|---|
Expr | 2.5 | 6 | 15 |
Lexer | 3 | 11 | 15 |
Main | 1 | 1 | 1 |
Num | 1 | 1 | 3 |
Operation | 2.75 | 4 | 11 |
Parser | 3.25 | 7 | 13 |
Term | 3.67 | 6 | 11 |
Token | 1 | 1 | 3 |
Var | 2 | 5 | 16 |
可以观察到,Operation
、Lexer
、Parser
、Expr
、Term
类复杂度较高,这是意料之中的。这几个类作为整个程序最重要的操作类,方法众多。但仍应注意到,有些类最大操作复杂度较高,这证明程序设计中出现的部分方法代码长度大、实现功能过程复杂,仍与高内聚,低耦合的设计原则有出入,仍需要优化。
Homework2
题目重点重述
第二次作业与第一次作业主要区别有三点:自定义函数(无自定义函数的嵌套)、以e为底的指数函数(可多层指数函数嵌套)、多次括号迭代化简。自定义函数函数名限定为
程序UML图
在第一次作业的基础上,增加了Function
类,使用字符串替换的方法,在预处理阶段将表达式中形参函数替换为实参函数。对于指数函数,则通过新建Exp
类存储。在第一次作业中,由于Parser
可以实现迭代处理字符串表达式为表达式树,而多层括号则可以理解为Expr
嵌套,因此实际上梯度下降法对该要求有着天然优势。
复杂度
类复杂度表格如图所示:其中OCavg
指代平均操作复杂度,OCmax
指代最大操作复杂度,WMC
指代加权方法复杂度
Class | OCavg | OCmax | WMC |
---|---|---|---|
Exp | 1.25 | 2 | 5 |
Expr | 2.67 | 6 | 8 |
Function | 5.33 | 11 | 16 |
Lexer | 3.8 | 15 | 19 |
Main | 2 | 2 | 2 |
Num | 1 | 1 | 2 |
Operation | 3.29 | 8 | 23 |
Parser | 3.75 | 8 | 15 |
Term | 2.33 | 5 | 7 |
Token | 1 | 1 | 3 |
Var | 2 | 5 | 18 |
可以观察到,Operation
、Function
、Lexer
、Parser
、Expr
、Term
类复杂度较高(这仍是意料之中的,毕竟代码量只加不减)。这几个类作为整个程序最重要的操作类,方法众多,逻辑交叉复杂。但仍应注意到,新加类Function
类操作复杂度尤其高,这是因为该类使用字符串替换方法,而这种方法更加面向过程而非面向对象,平替方案见下文优化策略。
Homework3
题目重点重述
第三次作业与第二次作业的区别主要有两点:表达式求导、函数迭代。其中,对于任意表达式,均对自变量x进行求导,求导可以迭代。函数迭代过程不能出现循环迭代,即函数定义时,引用函数只能引用已经出现的函数。
程序UML图
在第二次作业的基础上,增加了derive()
方法,在parser的过程中,直接对Token.Type == DER
的元素求导。这是一个迭代求导的过程,因此很容易解决对含有导数表达式的式子再次求导。在第二次作业中,处理函数表达式的方法是字符串替换,并且替换停止的判断条件是遍历替换后表达式不再出现函数名,因此也可以很好的处理函数嵌套的问题。
复杂度
类复杂度表格如图所示:其中OCavg
指代平均操作复杂度,OCmax
指代最大操作复杂度,WMC
指代加权方法复杂度
Class | OCavg | OCmax | WMC |
---|---|---|---|
Exp | 1.2 | 2 | 6 |
Expr | 2.5 | 6 | 10 |
Function | 5.33 | 11 | 16 |
Lexer | 3.25 | 11 | 26 |
Main | 2 | 2 | 2 |
Num | 1 | 1 | 3 |
Operation | 3.43 | 8 | 24 |
Parser | 3.75 | 7 | 15 |
Term | 2.5 | 4 | 10 |
Token | 1 | 1 | 3 |
Var | 2 | 5 | 20 |
可以观察到,Operation
、Function
、Lexer
、Parser
、Expr
、Term
类复杂度较高(这当然还是意料之中的)。可以发现,Lexer
类的最大复杂度有所降低,这是因为在第三次作业中,将部分Lexer
构造方法中复杂的函数部分提出来作为了新的方法,降低了耦合度。但仍应注意到,优化后的Function
、Lexer
类操作复杂度仍然很高,两类中部分方法对字符串进行了详细解析,这样显然也是更加面向过程而非面向对象,可以通过提取出新方法来优化。
二、架构重点实现分析
Homework1
第一次作业程序中主要有三个实现的重点:
表达式预处理: 主要处理了以下三类特殊情况:
多个正号负号
变量前出现负号
数字前仅有负号,没有连接的正号
具体过程如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Pattern 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("\\(\\+", "(");
- 梯度下降法构建表达式树: 主要使用
Expr
、Term
、Factor
三个类型的处理方法,在Parser
类中主要对应以下三种方法:parserExpr
:主要对Expr
类处理,以加号作为分隔界限,调用parserTerm
方法获取该表达式内的各项;parserTerm
:主要对Term
类处理,以乘号和乘方符号作为分割界限,调用parserFactor
方法获取该项内的各因子;parserFactor
:主要处理Factor
类型的对象,按照对象类型,构建新对象Num
、Exp
、Var
存入列表中,或者对Expr
类再迭代调用parserExpr
方法并存入列表中。
getVars()
统一接口: 将表达式树中结点计算合并,重新变为字符串。为统一接口,方便化简,定义了这种方法。在程序中,我们将Var
类定义为形如的变量,这样各类都可以很方便地转化为 Var
类,从而达到统一接口的需求。
Homework2
第二次作业程序中主要有三个实现重点:
自定义函数: 本次作业中,主要使用字符串解析替换的方法,建立
Function
类,属性有函数名,变量名列表,函数形式表达式。使用时将实参导入,替换所有对应形参,并返回替换后字符串。函数处理步骤位于预处理与表达式建树两步之间。Exp
类指数函数: 本次作业中,为指数函数建立Exp
类,将指数作为属性传入,并在构造函数中将指数作为表达式,按照流程化简。如此,当指数中仍含有Exp
类时,迭代调用化简;当指数中不含Exp
类时,直接化简。具体过程如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public 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
因子主要分为Num
、Var
、Exp
、Expr
四类;为统一接口,在获得求导结果后,将其全部转换为Term
类。Expr
: 求导方式如上所示;Num
: 求导之后数值为0,类型仍为Num
类;Var
: 求导之后按照指数分为两类:- 求导后指数等于0:求导之后数值为
,类型变为 Num
类; - 求导后指数大于0:求导之后
为 , 为 ,类型仍为 Var
类;
- 求导后指数等于0:求导之后数值为
Exp
: 求导之后整体形式变为,类型变为 Term
类。
三、类设计介绍与目标
本次设计总共建立了11个类,1个接口,现介绍如下:
Main
:主类,按照流程进行读入、预处理、展开、化简。Operation
:操作类,内含预处理方法、函数替换方法、函数解析方法、展开方法、化简方法。设计其是为了各操作最上层的耦合调用。Lexer
:字符串解析类,面向过程解析字符串,将整体表达式解析为多个Token
。设计其是为了按照字符特征将字符串切割为各个Token
包,方便解析。Parser
:解析类,使用递归下降法将经过Lexer
处理的Token
串建立为表达式树。设计其是为了建立表达式树,方便展开时按照计算顺序获取最终字符串表达式。Function
:函数类,内含函数各种属性和处理方法。设计其是为了方便在Lexer
里遇到形式函数的解析以及形式函数的替换操作。Token
:Lexer
解析里的最小单元,含有多种类型。Expr
:表达式类,内含表达式各种属性和处理方法。设计其是为了方便在Parser
里获取表达式,在展开时其各项统一处理。Term
:项类,内含项各种属性和处理方法。设计其是为了方便在Parser
里获取项,在展开时其各因子统一处理。Factor
:因子接口,统一接入各因子类。Num
:数字类,内含常数各种属性和处理方法。设计其是为了方便在Lexer
里遇到常数的解析以及各种涉及常数的操作。Var
:变量类,内含变量各种属性和处理方法。设计其是为了方便在Lexer
里遇到幂函数变量的解析、各种涉及变量的操作以及表达式树在展开使用方法getVars()
时统一各类接口。Exp
:指数函数变量类,内含指数函数各种属性和处理方法。设计其是为了方便在Lexer
里遇到指数函数的解析以及各种涉及指数函数的操作。
四、Bug分析及程序优化
Homework1
Bug分析
第一次作业暂无Bug
优化策略
设计最初时,出于对统一性的思考,缺省了对于性能的追求。现对作业过程中所做优化进行记录:
多余0项未化简: 最初设计为保障统一性,因此在任何表达式开头前侧均增加了一项“0+”,这会增加在计算化简过程中需要存储的项数,从而大大增加空间需求。为降低资源浪费,不选择该种方法,而是选择将以正号开头的表达式第一项符号去除,以达到统一性。
Term
对象连接符号: 最初设计将连接各项的符号提出,储存为一个列表,单独处理。这大大增加了设计的复杂度,因此在更改后,将减号作为负号存入数值中,各项之间只用加号连接,降低了设计难度。- 乘方运算: 最初设计将乘方使用循环转化为多项同一项相乘,这增加了化简后表达式的长度,更改了
Var
类的属性,存入系数和指数 ,对于 Expr
类的元素,则仍按照循环转化相乘的方法计算,提高了化简后表达式的性能。
Homework2
Bug分析
第二次作业无逻辑bug,而是集中在性能问题上:
- 单项式多次乘方运算: 第二次作业中,单项式的高阶次方形如
,由于括号的出现,在parser过程中会将其解析为仅含一项的 Expr
,这样运算时,会将其展开为多项。当乘方运算过多时,会出现表达式树层数过深,结点过多的问题,这会大大影响程序的性能乃至程序的可使用性和结果的正确性。
优化策略
设计最初时,出于对统一性的思考,缺省了对于性能的追求。现对作业过程中所做优化进行记录:
- 单项式多次乘方运算: 最初设计时,没有考虑多次乘方对于程序性能的影响。调整时在
parserTerm()
函数中添加特判,当Expr
对象中仅含有一个项,且这个项中仅有一个Num
、Exp
、Var
因子,那么将这一个Expr
对象取出,加入一个相应因子。 - 函数处理面向过程: 最初设计时,将每一个函数解析为
Function
类,利用解析出的形参在使用中和实参替换,从而达到效果。这样的字符串解析显然过于面向过程,实际上可以将自定义函数式也当作表达式,使用Parser
类解析为表达式树(并且形参必定处在叶结点位置),化简表达式时,只需要在对应位置将形参结点替换为实参表达式树,最终将整棵表达式树转换为字符串即可。 - 多项式合并: 最初设计时,是将
Exp
函数中指数作为字符串储存,当字符串中各项相同但位置不同时,就会出现无法合并的问题。这个问题有一个简单的解决方案,即将字符串按照正号拆开,利用字符串处理函数replace()
进行字符串减法,来判断两字符串是否相等。笔者才疏学浅,没有想到这一个方法,而是想到了另一个异曲同工的方法:即将Var
改为多变量相乘的形式,存储整体系数,各变量变量名,各变量指数。实际上这两种方法是一样的,第一种方法相当于整体存储所有Exp
指数之和,第二种方法则相当于按项存储各Exp
指数。
Homework3
Bug分析
第三次作业存在一些笔者自身的失误导致的bug,现分享为其他找bug的朋友提供一个思路:
方法调用时对象错误: 形如
,笔者在添加 Var.derive()
函数时,建立新的Var
对象,但在调用方法时选错对象,导致改变属性时发生错误,从而导致了整个程序未能通过测试。1
2
3
4
5
6
7if (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学姐的帮助。也希望在之后的学习中能再接再厉,继续进步。