2022-面向对象设计与构造-第一单元总结
2022-面向对象设计与构造-第一单元总结
第一次作业
程序结构分析
UML类图
其中各个类的含义作用如下:
|-- expression
| |-- Expression: 表达式类
| |-- ExpressionFactor: 表达式因子类
| |-- Factor: 因子接口
| |-- PowerFunction: 幂函数类
| |-- Reducible: 可化简接口,其中有将对象转化为多项式的方法
| |-- SignedInt: 带符号整数类
| `-- Term: 因子类
|-- parser
| |-- Extracter: 用于提取词的类
| |-- Parser: 用于解析的类
| |-- Token: 词类
| `-- TokenType: 枚举类型,枚举Token的种类
|-- simplify
| |-- Monomial: 单项式类
| `-- Polynomial: 多项式类,用于化简
`-- Main: 主类
度量分析
方法复杂度分析
类复杂度分析
从上面几张图可以看到大部分方法的复杂度都是很低的,只有少部分方法复杂度较高,这主要是因为其中有很多if-else
或case
分支,查看代码发现这些方法中确实需要用到这么多判断。此外,各个类之间也基本遵循高内聚低耦合的原则。
整体结构
本次作业中有三个包,一个是用于对表达式建模的expression
包,里面的Expression, Term, Factor
用于对表达式自顶向下进行建模;parser
包中有词法解析器Extracter
和文法解析器Parser
,用于解析输入;simplify
包中有多项式Polynomial
和单项式Monomial
,用于最后的化简、合并和输出
整个程序的执行过程大体分为三部分:解析、展开、合并,下面分别介绍。
解析
通过Extracter
类从输入字符串提取当前的Token
对象,交给Parser
来解析,Parser
采用递归下降的方法进行解析,得到Expression
对象,需要注意的是文法中存在多递归,需要消除左递归,具体方法如下:
约定符号:
: 表达式
: 项
: 因子
: 空白项
: 空字符串
根据原文法:
消除左递归得到:
展开
注意到本次作业中只有表达式因子存在括号,因此实际上只有ExpressionFactor
需要展开。实现展开的方法是先调用内层的Expression
的toPolynomial
方法将其转化为Polynomial
对象,再根据ExpressionFactor
的次数利用Polynomial
的mul
方法得到最终的Polynomial
对象,实现展开。
合并
本次作业合并较为简单,只要合并单项式即可。在多项式中有一个以单项式系数为键、单项式为值的HashMap
,合并时只需要看新增的单项式的次数是否在HashMap
中,若是,调用相应的多项式的merge
方法进行合并;否则向容器中新增一个键值对。
优缺点
对于我本次作业的架构,我认为优点主要是层次清晰:表达式层次结构为Expression->Term->Factor
,对于多项式层次结构为Polynomial->Monomial
;缺点是部分方法我认为设计的不够合理,如应该把展开的相关方法给Expression
类而不是Polynomial
,后者应该是专注于化简的。
性能
本次作业最终结果只是多项式形式,因此化简相对简单,主要有以下几点:
-
合并同类项
-
如果可以,将系数为正的项放到最前面以少输出一个
-
-
1*x**n -> x**n (n != 0,1,2)
-
x**0 -> 1
-
x**1 -> x
-
x**2 -> x*x
-
0*x**n -> 0
实现了上述几点,便可在强测中性能分拿到满分
Bug分析
自己的bug
本次作业较为简单,我在强测和互测中均未被发现bug
他人的bug
互测中hack他人的基本策略思路是测评机暴力测试+手动构造特殊的数据,未结合被测程序的代码(主要是代码看起来太费劲了)。测评机根据形式化表述生成符合要求的输入,然后对全房内其他人进行测试,并生成相应的测试日志。手动构造数据主要根据我最这次作业中的坑点的理解来构造,如测试是否使用了BigInteger
、测试x**8
的边界情况、测试结果为0
时是否会输出空串、测试对空格和制表符的干扰处理、测试多个连续符号的问题、测试前导零等等。测评机和手动构造的数据都有效地hack了他人。使用测评机的过程中,发现测评机很难控制输入的合法性,往往会生成很复杂的输入数据,造成程序无法给出结果或输出很长,因此后来就没用测评机造数据了,手搓yyds
互测中,发现了同房间的两个bug:
- 当出现带前导零的
0
时,抛出异常,例如输入(00000)** +00000
,这主要是因为在处理时将读到的数字去除前导零后再转化,由于000
去掉前导零后成为了空串,在调用BigInteger
的构造方法时抛出异常,其实完全不用管前导零,BigInteger
的构造方法会帮我们完成这件事,吓唬人罢了 - 当出现
x**8
时,这一项被忽略,是将次数大于8的项忽略时判断条件写错了。虽然题目说了次数不会超过8,但是也不必把大于8的变成0吧,关键是还搞错了
第二次作业
程序结构分析
UML类图
其中各个类的含义作用如下(从第一次作业迭代而来的不再赘述):
|-- expression 用于展开
| |-- ConstantFactor: 常数因子类
| |-- CustomFunction: 自定义函数类
| |-- Expression: 表达式类
| |-- ExpressionFactor: 表达式因子类
| |-- Factor: 因子接口
| |-- PowerFunction: 幂函数类
| |-- SumFunction: 求和函数类
| |-- Term: 因子类
| |-- TrigFunction: 三角函数类
| |-- TeigType: 三角函数的类型(sin, cos)
| `-- VariableFactor: 变量因子接口
|-- parser 用于解析
| |-- Extracter: 用于提取词的类
| |-- Parser: 用于解析的类
| |-- Token: 词类
| `-- TokenType: 枚举类型
|-- simplify 用于合并化简
| |-- BaseFactor: 基本项接口
| |-- Constant: 常数类
| |-- Cos: cos函数类
| |-- Monomial: 单项式类,不包含系数
| |-- SimpleTerm: 简单项类,是最简的形式
| |-- Sin: sin函数类
| `-- Polynomial: 多项式类,用于化简
`-- Main: 主类
度量分析
方法复杂度分析
类复杂度分析:
本次作业中有部分方法复杂度过高,原因主要是这些方法内部条件判断很多,比如toString
方法需要判断多种情况给出输出,addFactor
方法需要根据因子的类型和当前的状态使用不同的策略达到化简合并的目的,nextFun
需要根据下面的三个字符判断函数类型来保证安全(不然)。虽然这些方法复杂度较高,但是我认为我的设计是合理的,如果拆分成更小的方法会导致等复杂的调用、传参,维护起来反而更复杂。类中cox
就会被时别成cos
Token
,Parse
和Extractor
是用来解析的,复杂度较高,主要是词法分析和文法分析本身复杂度就较高。Simple
和TrigFunction
的复杂度较高应当加以改进,阅读起来也有些困难。
整体结构
本次作业在上次作业基础上增加了自定义函数调用和求和函数,我针对这两个函数创建了各自的类——CustomFunction
和SumFunction
。除此之外我还修改了合并化简部分的结构——采用Polynomial->SimpleTerm->BaseFactor
的层次进行存储。最后还解决了第一次作业遗留的问题——把展开的工作交给了expression
包中相关的类,simplify
包中的类专注于化简。下面具体谈谈:
自定义函数和求和函数
由于我一开始没想到比较好的解决函数这两个问题的方式,因此采用了非常暴力的字符串替换的方法(这也为我第三次作业重构埋下了伏笔)。在存储自定义函数和求和函数时,将形参存储为大写的(避免实参x
和sin
中的i
被字符串替换)在调用自定义函数时,将对应的形参的字符替换为要带入因子的字符串形式并加上括号;在调用求和函数时,将所有的i
替换为对应的因子并加上括号得到一个表达式,然后根据begin
和end
循环累加,然后返回一个字符串形式的表达式,依次用Extractor
和Parser
处理得到一个Expression
对象,再转化为表达式因子对象返回即可。这种方法的好处就是简单,字符串替换然后调用已有的方法再解析,很方便;缺点主要是容易出现错误和效率低,尤其是在第三次作业的更深层的嵌套情况下。容易出现错误主要指需要加括号、替换时要注意是形参还是实参以及sin
中的i
;效率低主要是每次调用都要重新解析一遍,尽管很多部分和原来一样,这就做了很多无用功。(所以在第三次作业抛弃了这种做法)
合并
本次作业因为加上了三角函数,原来的多项式和单项式的合并方式已经失效了,于是我使用了Polynomial->SimpleTerm->BaseFactor
的存储结构。其中BaseFactor
包括常数Constant
、三角函数SIn, Cos
、单项式Monoimal
,不包含表达式因子(因为它不Basic
),他们都有getBase
方法,用于返回一个标志性的字符串表明它的”基“;也有getDeg
方法用于返回其次数,如sin(x**2)**3
的即基为sin(x**2)
,次数为3
。SimpleTerm
中有一个使用HashMap<String, Integer>
存储BaseFactor
的字段factors
,即以BaseFactor
的基为键,以其次数为值,同时它还有一个用于存储系数的字段coe
。Polynomial
中的terms
以SimpleTerm
的factors
为键,以SimpleTerm
的coe
为值进行存储。在合并时,只需要看新的部分的键是否存在,如果存在则合并,否则把它加入HashMap
实现合并即可。
展开
相较于上次作业,本次作业我将展开的功能交给了expression
包中的类的unfold
方法,给了Expression
和Term
相应的加法和乘法,unfold
方法可通过这些方法将一个有多余括号的表达式展开成没有括号的表达式,然后交给Polynomial
化简,这样一来,展开和合并的工作实现分离,降低了类之间的耦合。
优缺点
优点
- 层次结构更加清晰
- 自定义函数和求和函数实现简单
缺点
- 采用字符串替换实现的自定义函数和求和函数,效率低下
- 采用了
HashMap<HashMap<String, Integer>, BigInteger>
的存储结构,用起来很麻烦 - 将
BaseFactor
转换为了<String, Integer>
放入SimpleTerm
中,难以实现更深层次的优化
性能
本次作业我除了实现了上次作业的性能优化之外,还做了以下的优化:
sin(0) -> 0
sin(-n) -> -sin(n)
cos(-n) -> cos(n)
cos(0) -> 1
由于没有做二倍角和平方和优化,在强测中性能分只有15分
Bug分析
自己的bug
本次作业我最终提交的版本在强测和互测中均未发现bug,但最终提交版本形成前我自己发现过两个bug:
- 当求和函数的
begin
大于end
时发生错误,主要是没考虑到这种情况,加特判即可解决 sin(-1)**2 = -sin(1)
,在用诱导公式化简时没有考虑三角函数的次数对正负号的影响,加上条件判断即可
他人的bug
互测中hack他人的基本策略思路是测评机辅助测试+手动构造特殊的数据,但这次我阅读过其中一个人的代码。与上次不同,这次我没有使用测评机构造数据,仅使用测评机一次测试多个同学。在手动构造数据时,主要思路还是考虑边界和特殊情况,如可构造a*sin(b**c)**d
,让a, b, c, d
取{-3e10, -02, -01, -00, +00, +01, +02, +3e10}
这些数并进行组合,全面排查。同时我还根据自己的理解造了一些可能容易出问题的数据,进行尝试,hack了许多同房的同学。值得注意的时,测试中,发现了一位同学在输入较大时需要数秒甚至数十秒才能给出结果,因此我阅读了它的代码,并结合调试器发现其程序效率极差,复杂度大约是O(n!)
(n和输入中的+, -, (, )
数量正相关。我精心构造了一个+
很多的数据,让n
达到30
左右,通过TLE
完成了hack。
互测中发现了如下的bug:
- 输入
cos(-1)**3
输出cos( - 1) ** 3
,-
和1
之间有空格,格式不符合规范(不太理解这位同学为什么加空格,加空格百害而无一利啊!) - 使用诱导公式时符号错误:
cos(-1)**3 = -cos(1)*cos(1)*cos(1)
,sin(-1)**4 = -sin(1)**4
- 三角函数中对
sin(0)
指数为0时的处理不正确:sin(0)**0 = 0
,可能是对0次幂的理解出现了偏差 - 对于
sin(0)
处理不当:sin(0)**2 = 1
- 一位同学由于设计不合理导致复杂度过高,在输入
+++1++1++1++1++1...++1
时长时间无法给出结果
第三次作业
程序结构分析
UML类图
其中各个类的含义作用如下(从前两次作业迭代而来的不再赘述):
|-- expression 用于展开
| |-- ConstantFactor: 常数因子类
| |-- CustomFunction: 自定义函数类
| |-- Expression: 表达式类
| |-- ExpressionFactor: 表达式因子类
| |-- Factor: 因子接口
| |-- PowerFunction: 幂函数类
| |-- SumFunction: 求和函数类
| |-- Term: 因子类
| |-- TrigFunction: 三角函数类
| |-- TeigType: 三角函数的类型
| |-- ParaMap: 参数映射类,用于函数调用时的参数对应
| `-- VariableFactor: 变量因子接口
|-- parser 用于解析
| |-- Extracter: 用于提取词的类
| |-- Parser: 用于解析的类
| |-- Token: 词类
| `-- TokenType: 枚举类型
|-- simplify 用于合并化简
| |-- BaseFactor: 基本项接口
| |-- Constant: 常数类
| |-- Cos: cos函数类
| |-- Monomial: 单项式类
| |-- SimpleTerm: 简单项类
| |-- Sin: sin函数类
| `-- Polynomial: 多项式类
`-- Main: 主类
方法复杂度分析
类复杂度分析
相比于上次作业,这次作业中复杂度高的方法和类主要是有关三角函数化简的,即平方和和sin
的二倍角化简。由于没有想到更好的用于合并的容器,因此使用了ArrayList
,这使得在合并时要多次便利容器,带来了很高的复杂度,这样的方法有doubleMerge, combine
,类有Polynomial
和SimpleTerm
。虽然不是很合理,但暂时没有想到好的方法,所以暂时只能这样了。其余的部分在第二次基本相同,这里不再赘述。
整体结构
本次作业相较上次作业变化不大,但是由于我第二次作业函数调用时使用的字符串替换,容易产生错误,这次作业进行了较大规模的重构,采用递归次调用的方式来完成函数调用和求和函数。此外,这次作业我还在Polynomial
和SimpleTerm
中新增了有关三角函数的一些化简的方法。其余部分和第二次作业变化不大。
函数调用求和函数
在函数调用和计算求和函数的值时,建立一个形参和实参的映射ParaMap
,传递给要调用的函数的表达式,然后递归下降到因子,最终只有一种因子要完成真正的形参替换:幂函数因子。对于该因子,我们根据实参的类型处理调用:
- 实参是常数因子,计算对应的乘方并返回
- 实参是幂函数因子,将两个幂函数的指数相乘得到新的幂函数返回
- 实参是表达式因子,将表达式因子的指数乘以当前幂函数的指数返回
- 实参是三角函数因子,将三角函数的指数乘以当前幂函数的指数返回
三角函数化简
本次三角函数化简实现了sin(x)**2+cos(x)**2=1
和sin((2*x))=2*sin(x)cos(x)
的化简。对于前者,我的做法时遍历Polynomial
中的项,若找到一个三角函数项的因子大于2,则在剩下的项中尝试找一个对应可以使用平方和进行合并的因子,若找到则合并并返回,然后再尝试合并直到找不到这样的可合并项。对于后者,只需要看项中是否有sin(x)**n*cos(x)**n
的形式,若有,则还需要检验其系数能不能被整除,若能,则合并并返回,然后重复这些步骤至不能再合并为止。注意如果sin
和cos
的次数不相等,合并可能导致性能下降,如6*sin(x)*cos(x)**3 = 3*sin((2*x))*cos(x)**2
,结果反而更长了,方便起见只考虑次数相等的情况。
优缺点
优点
我认为我这次的结构更加清晰了,尤其是递归的进行函数调用部分,充分利用了Expression->Term->Factor
的层次来递归的调用相应的方法。此外,expression
和simplify
包中的类的职责也更分离:前者负责展开,后者负责化简,分工明确。
缺点
这次的主要缺点是在三角函数优化部分逻辑复杂,条件判断和循环很多,方法也很长(能不出错真是个奇迹),不利于理解和维护。
性能
本次作业实现了平方和和关于sin
的二倍角优化,在强测中性能分得到了17分
扣分的主要原因是没实现以下化简:
1 - sin(x)**2 = cos(x)**2 , 1 - cos(x)**2 = sin(x)**2
2*cos(x)**2 - 1 = 1 - 2*sin(x)**2 = cos(x)**2 - sin(x)**2 = cos((2*x))
Bug分析
自己的bug
本次作业我最终提交的版本在强测和互测中均未发现bug,但最终提交版本形成前产生过一些不影响正确性的优化bug
他人的bug
互测中hack他人的基本策略思路是测评机辅助测试+手动构造特殊的数据,阅读了少量的代码。hack思路与上次基本一致,新增了对sum函数相关的测试,发现以下错误:
int
侠:在sum函数中使用int
而不是BigInteger
,hack数据:sum(i,2147483647,2147483648,0)
sum
函数遇到负数计算错误:输入sum(i, -1, 1, i**2)
输出0
- 化简
x**2 = x*x
时使用字符串替换导致的错误:输入x**20
输出x*x0
- 玄学错误1:输入
x**10
输出0
- 玄学错误2:输入
--x**4+10*x**4
输出1
- 玄学错误3:输入
+sum(i,-13,-13,cos(i**0)**2)*x
输出x*cos(-13)**2
(这些玄学错误太玄学了,我看不懂但我大受到震撼)
架构设计体验
这三次作业时关于表达式展开的。
在第一次作业中,我先建立了Expression->Term->Factor
的表达式层次结构,完成了对表达式的初步建模。同时,还通过采用递归下降的文法解析方式感受到了层次结构带来的优势:写出来的代码逻辑十分清晰,形式简洁,递归调用带来了极大的方便。此外还建立了Polynomial->Monomial
的化简层次结构,可以方便地合并同类项。
在第二次作业中,由于“偷懒”,想复用已有的方法,采用了字符串替换+重新机械完成函数调用,使得相关的结构比较混乱。但我也修复了第一次作业中职责分配不合理导致的类之间耦合度高的问题。此外,由于新增了内容,我对Factor
进行了扩充,并泛化了Polynomial
,形成了Polynomial->SimpleTerm->BaseFactor
的层次结构进行化简。在Polynomial
中我使用了HashMap<HashMap<String, Integer>, BigInteger>
的复杂容器结构,为后面的三角函数进一步化简带来了很多麻烦,但当时只觉得好用,没有考虑过这个问题。
第三次作业本来应该是只需要在第二次作业上进行简单的修改的,但由于第二次作业中不合理的字符串替换和HashMap
,我进行了较大规模的重构,采用了更加合理的递归调用结局函数调用问题,充分利用了层次设计的优越性,使得相关部分逻辑更加清晰。抛弃了双重HashMap
的设计,以ArrayList
中存放类来代替,虽然损失了查找效率,但可以解决和很多在三角函数化简中棘手的问题。还有最重要的一点是对函数调用中的实参因子进行了扩展,使得其可以支持所有的因子类型,使得整个程序更加完整。
心得体会
对我来说,前两次作业难度巨大,第一次作业我花了很长时间学习递归下降法解析表达式,第二次作业我想了很多也尝试了很多方法来解决函数调用和合并的问题。第三次作业本应该是很轻松地在第二次作业基础上迭代,但是由于我对第二次作业的函数调用的做法不满意,花了很多时间进行重构,还花了很多时间在化简上。总之,这三个星期的作业都做得很艰难,但是收获也很大,能力提升了不少,现在再回过头去看第一第二次作业,感觉简单了许多。具体的心得体会有以下几点:
- 要多交流:在这几次作业中,我在和同学交流思路、看讨论区的帖子中收到了很多启发,得到了很多帮助。
- 要注重可扩展性:写作业时不能局限于当前的作业要求,还应该为下次作业做准备,应该注重程序的可扩展性,避免作业迭代时发生大规模重构。
- 要做好充分的测试:这一单元我提交的作业在强测和互测中均没有发现bug,而且互测时hack地很爽,一个重要的原因自己在下面做了充足的测试,在测试过程中也发现过很多问题,在最种提交的版本中都得以解决。
- 正确性第一,性能次之:互测时发现了很多同学在优化上出了bug,这是得不偿失的,因为正确性占了大部分分。同时互测时也遇到了“佛系”的同学,完全不优化,怎么测都hack不到,虽然损失了性能分,但是得分也不会低。我认为这样设计是很合理的,因为一些重大的事故可能就是因为程序的正确性出了问题,现在在学习,出了bug不要紧,如果是在工作中,出了bug很有可能是致命的,因此必须要对程序的正确性加以重视
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库