第一单元总结性博客
1 架构设计思路和程序结构
1.1 总体设计思路
第一单元的基本任务是拆掉表达式中的非必要括号,保留必要括号。布置方式采用迭代式,让我体验到了作为”乙方“不断被”甲方“胁迫训练的感受。针对这个单元的基本任务,我将整个项目拆解成如下的许多小任务,我将从总体设计和布局上介绍每个小任务的设计思路,针对不同的作业,介绍具有特点的部分。
解析
解析是指对于输入的表达式进行逐层解析。由于表达式采用逐层形式化定义,因此可以在文法的基础上建立递归解析的想法。
用于表达式进行解析的类包括 Lexer
和 Parser
。Lexer
其实就是 tokenizer,用于将表达式这个字符串分解成为一个个基本”词语“。Parser
通过对字符串各个基本”词语“的识别构建出一个表达式树。
表达式树的顶层为 Expr
类,其中由 Term
组成。各个 Term
之间是加减运算关系,在 Expr
中以 ArrayList
保存(第一次作业使用 HashSet
)。Term
由 Factor
组成,Factor
之间是乘法运算关系,在 Term
中以 ArrayList
保存(第一次作业中使用 HashSet
)。Factor
是一个接口,由 Constant
,Expr
,Variable
实现。
逐层解析结构:
public Expr parserExpr() {
Expr expr = new expr();
expr.addTerm(parserTerm()); // 递归解析的关键
/* Do something */
return expr;
}
public Term parserTerm() {
Term term = new Term();
term.addFactor(parserFactor()); // 递归解析的关键
/* Do something */
return Term;
}
public Factor parserFactor() {
Factor factor = new Factor();
/* Do something */
return factot;
}
在主函数中调用解析表达式方法,即可获得一颗建好的表达式树。
在第一单元的任务中,建树是十分关键的。树上的各个”节点”—— Factor
构建和解析的是否清晰,决定了后面计算、化简、输出等过程能否正确地符合心意。其中需要考虑的点包括但不限于:
符号问题:遇到符号连续出现的情况,在哪一层“吃”符号,如何进行符号“压缩”,符号保存到哪里?
指数问题:将指数保存到哪里?
计算
计算就是提取节点的”特征“和”状态“。与表达式解析相同,总表达式的计算结果也可以利用递归计算来获得——在每个类中实现 getAns()
函数。具体思路上,利用 Expr
中 Term
的关系计算得到 Expr
的结果;利用 Term
中 Factor
的关系计算得到 Term
的结果。在这里需要注意,xxx的结果表示总表达式树上,以这个节点为根节点的子树的计算结果。如图所示:
可以看到计算的过程由如下特点:
- 计算结果与表达式树的递归深度无关,这意味着将表达式树”扁平化“——”压缩“
- 但是计算时间与树深度有关(无法避免)
- 计算结果与表达式树节点无关,这意味着将节点间的特殊性”抹去“,保留其中的普遍性——”车同轨,书同文“
计算是化简和输出的基础,当计算完成后,我们会发现化简就可以自然而然地完成。
化简
化简就是对计算结果合并同类项,所以就要实现同类项的判等。因此,我们可以研究什么导致了结果的不同。出于对表达式的分析,我们可以得出如下模型
可以看到正是 sin
cos
因子和 x
的幂次是决定项的不同的关键因素,所以我需要实现判断 sin
cos
相同的方法。而 sin cos
内部的 Expr
又是决定 sin
cos
不同的关键因素,所以我需要实现判断内部 Expr
相同的方法。正如上文所说,”计算结果和表达式树节点无关”的特性,我们可以直接使用计算结果判断 Expr
是否相等,从而实现判断项是否相等。
输出
输出其实是一个翻译的过程。是将整个表达式树计算结果进行符合文法的输出。其中可能涉及到诸如”负系数的项调整到正系数的项后面输出“、”x的平方优化成 x*x
输出“等等策略。但是不可否认的是,到了这一步我们的任务基本完成了。原来的表达式已经从”有形“化为”无形“,现在就差从”无形“化为”有形“了,就差这”临门一脚“了。可以放松了。
1.2 第一次作业
化简——对多项式的处理
这次作业不涉及三角函数,只需要判断 x 的幂次合并它的系数即可。从无到有永远是最难的,我把大量的时间思考如何构建一个可以表达结果,同时适用于所有节点的数据结构。最终我选择了 HashMap<Integer, BigInteger>
。根据从 CO 课程学习到的形式化验证,我可以保证这种架构是完全没问题的。
UML 图和复杂度图
UML 图
BasicClass
是提取 Term
,Expr
,Constant
,Variable
共同的部分写就的一个类,同时也有加法乘法的运算方法。这样做的优点是便于修改,可以集中改动共同的部分,适应不同的架构。缺点是将因子内部的属性和本身隔离开,导致架构抽象难懂。
类复杂度图
OCavg
= Average operation complexity(平均操作复杂度)
OCmax
= Maximum operation complexity(最大操作复杂度)
WMC
= Weighted method complexity(加权方法复杂度)
Class | OCavg |
OCmax |
WMC |
---|---|---|---|
expression.BasicClass |
2.17 | 5.0 | 13.0 |
expression.Constant |
4.0 | 6.0 | 8.0 |
expression.Expr |
5.2 | 8.0 | 26.0 |
expression.Term |
2.67 | 5.0 | 8.0 |
expression.Variable |
2.0 | 2.0 | 4.0 |
Lexer |
2.5 | 6.0 | 10.0 |
MainClass |
1.0 | 1.0 | 2.0 |
Parser |
3.25 | 7.0 | 13.0 |
Total | 84.0 | ||
Average | 3.0 | 5.0 | 10.5 |
Parser
类和 Expr
类会有很大的复杂度是在我意料之中的,毕竟主要解析和计算过程都涉及到这两个类的递归。但是 Constant
类的平均操作复杂度也会达到如此高是我没有预料到的。在 Constant
类中,只有 getans()
这一方法,通过内部保存的指数和符号计算该节点最终结果。我认为方法操作难度并不高,后来看到名称中”平均“二字和加权方法复杂度才明白,其实很有可能就是因为 Constant
内部没有多少操作,才导致其平均复杂度高。
方法复杂度图(部分)
CogC
:cognitive complexity 认知复杂度,表征代码可读性
ev(G)
:Essential cyclomatic complexity 基本圈复杂度
iv(G)
:Design complexity 设计复杂度
v(G)
:cyclonmatic complexity 圈复杂度
method | CogC |
ev(G) |
iv(G) |
v(G) |
---|---|---|---|---|
expression.Expr.appendX(int) |
10.0 | 1.0 | 10.0 | 10.0 |
expression.Expr.toString() |
13.0 | 6.0 | 7.0 | 8.0 |
... | ... | ... | ... | ... |
Total | 81.0 | 41.0 | 82.0 | 89.0 |
Average | 2.89 | 1.46 | 2.93 | 3.18 |
可以看到 toString()
方法中基本圈复杂度较高,说明其中的判断条件有些复杂。
appendX()
中圈复杂度较高,说明其中分支可能过高,导致代码正确率低。我个人感觉也是如此,这个方法主要是输出一项,其中涉及到 x 的幂次、系数等多方面因素,可能确实导致圈复杂度提高和可读性降低。
1.3 第二次作业
难点、痛点
从第一次到第二次的设计更新是痛苦而曲折的。难点如下:
难点 | 含义 | 挑战 |
---|---|---|
增加了三角函数 | 这意味着传统的 <Integer, BigInteger> 不能有效区分不同的项了,因为 sin 和 cos 的出现导致 sin(x) * x 与 cos(x) * x 称为了两个完全不同的项 |
首先,必须构思出全新的结果表达方法;其次,与以往的直接判断 x 的指数不同,项之间的判断需要根据全新的数据结构设计。(占用我大量时间设计) |
增加了自定义函数和 sum 函数 | 在设计时我考虑到了自定义函数递归调用,sum 函数递归定义的问题。这意味着我必须找到将一个表达式树拼接到已有的树上的操作。 | 首先,必须决定使用哪种方法解析 sum 函数和自定义函数,一种是直接 replaceAll() ,另一种是建树再替换;其次,如果选择第一种我必须考虑如何递归,如果选择第二种,我必须考虑如何替换(克隆) |
解析——如何处理新增的函数?
自定义函数:我在第一次作业中就考虑到之后很可能出多个自变量的化简,所以在 Variable
类中留下了 name
的域,用来保存这个变量的名字。在第二次迭代开发的时候证明了这种方式是正确的。我可以使用解析最终表达式的方法解析自定义函数调用。最终生成一个含有多变量的表达式树。当主表达式需要计算求值的时候,我可以使用递归的方法替换这个表达式的变量,并且及时进行 clone()
最终生成一个只含 x
的表达式。由于采用了 clone()
方法,所以函数调用不会影响原本函数定义时产生的表达式树。这样保证了递归调用的可行性。
sum
函数:sum
函数的调用比自定义函数简单,同样需要使用替换(replace(ArrayList, ArrayList)
)这个方法,将形参换成实参即可。
计算——哪种更优?
在处理时,我们不可避免的需要考虑哪种方法对于计算更优。一种是 replaceAll()
,一种是 replace(.,.)
方法 | 优点 | 缺点 |
---|---|---|
replaceAll() |
入门快,容易实现 | 分类讨论十分复杂,设计递归调用时与第二种方法相差无几; |
replace(.,.) |
助教推荐必属精品;使用递归,与整个单元大主题相仿 | 代码写起来稍微有些复杂,需要搭配 clone() 方法 |
化简——合并同类项?符号问题
化简就是合并同类项的过程。对于两项,必须有一种有效的机制判断这两项是否相同。这个过程中其实最重要的是判断三角函数连乘项是否相同。(原因:x 的幂次可以直接比较,三角函数的幂次也可以直接比较,唯一难点在于如何判断两个三角函数是否相同。)
就像之前提到的那样,要判断三角函数是否相同必须判断三角函数内部的表达式因子是否相同,由此就可以生发出三种思路。
思路 | 优点 | 缺点 |
---|---|---|
递归比较 | 递归比较,简单易懂 | 符号的嵌套可能导致表达式树的加深,从逻辑上感觉只能比较表达式树相同的结构,有局限 |
toString() 结果比较 |
比较更加简单,可以直接调用已有方法 | 不能确定 toString() 结果是否可以保持一样,不同的表达式树生成结果不一定一样 |
HashMap<Key, BigInteger> ans 比较 |
在我的架构中,这就是所有节点表达结果的通行证,树可以消失、节点可以消失。但是只要 ans 在,就可以解读 |
HashMap 的比较不透明,复杂度也比较高,具体实现上,需要复写 HashCode() 和 equals() 方法 |
UML 图和复杂度图
UML 图(部分方法缺省)
相比于作业1增加的
- 抽象出两种运算
Add & Multi
- 关键类实现了
clone()
,实现深拷贝 - 关键类覆写
hashCode() & equals()
,可以判断是否相同
类复杂度图
Class | OCavg |
OCmax |
WMC |
---|---|---|---|
deconstruct.Lexer |
3.5 | 10.0 | 14.0 |
deconstruct.Parser |
3.125 | 9.0 | 25.0 |
expression.BasicClass |
1.33 | 3.0 | 8.0 |
expression.Constant |
2.33 | 5.0 | 7.0 |
expression.Expr |
4.3 | 12.0 | 43.0 |
expression.Function |
1.25 | 2.0 | 5.0 |
expression.Key |
2.11 | 8.0 | 19.0 |
expression.Sum |
2.0 | 3.0 | 4.0 |
expression.Term |
3.4 | 8.0 | 17.0 |
expression.Tri |
1.58 | 6.0 | 19.0 |
expression.Variable |
1.5 | 3.0 | 9.0 |
MainClass |
1.5 | 2.0 | 3.0 |
operation.Add |
3.0 | 3.0 | 3.0 |
operation.Multi |
3.0 | 3.0 | 3.0 |
Total | 179.0 | ||
Average | 2.45 | 5.5 | 12.78 |
可以看到 Expr 类的 WMC 非常高,这个指标是反映该类内部方法复杂度之和的。如此”一骑绝尘“说明 Expr 承载了大部分功能,这不利于之后的开发和维护。
可以看到 Parser 和 Lexer 两个类的复杂度进一步提高,这和第二次作业新增了表达式的许多情况是分不开的。这两个类判断了大量情况,其实这和面向对象的思路不太相符。按照我上课的理解,新增功能不应该改变原有功能,换句话说,不应该用“在原有功能的基础上增加分支判断”这种方法做增量开发。
方法复杂度图(部分)
method | CogC |
ev(G) |
iv(G) |
v(G) |
---|---|---|---|---|
expression.Expr.toString() |
13.0 | 6.0 | 7.0 | 8.0 |
expression.Expr.equals(Object) |
4.0 | 4.0 | 2.0 | 5.0 |
expression.Tri.getAns() |
6.0 | 4.0 | 5.0 | 6.0 |
expression.Expr.getAns() |
13.0 | 3.0 | 10.0 | 11.0 |
deconstruct.Parser.parseFactor() |
12.0 | 1.0 | 10.0 | 11.0 |
expression.Expr.appendX(Key) |
24.0 | 1.0 | 16.0 | 16.0 |
expression.Key.addKey(Key) |
14.0 | 1.0 | 10.0 | 10.0 |
expression.Term.replace(ArrayList, ArrayList) |
16.0 | 1.0 | 8.0 | 8.0 |
... | ... | ... | ... | ... |
Total | 167.0 | 101.0 | 178.0 | 199.0 |
Average | 2.28 | 1.38 | 2.43 | 2.72 |
这里仍然是 appendX(Key)
这个方法认知复杂度最高,我分析原因是本次作业中增加了三角函数,导致输出的逻辑更加复杂
其次是 Term
类中的 replace
方法,它用于替换自定义函数和 sum 函数中的形参和 i。针对 Factor 类的不同内容,我做了分类讨论,这可能是导致方法复杂度高,可读性差的根本原因。
1.4 第三次作业
分析——递归?如何保证
虽然第三次作业增加了函数嵌套函数的要求,但是对于我们的架构,可以天然的支持递归调用。因为所有的实参、形参都被看做成表达式因子,而在解析实参的时候,也会使用 parseExpr()
的方法,因此可以支持递归调用。
优化——边际收益权衡
第三次作业的优化几乎是没有尽头的。最基础的三角优化是 sin(0) & cos(0)
,进一步是三角函数内部的符号提取,平方和,二倍角等等。在本次作业中,我做到了平方和,1-sin(x)**2
也没有做。其实在第二次作业的时候我就可以实现基本的平方和,但是苦于没有进行充分测试,导致基础功能有些问题。在周六下午测试的时候暴露了。我无奈只能放弃优化版本,选择一个基础版本进行修改,最后 19:59
极限提交。这样惨痛的经历让我意识到基础功能需要全力测试保证正确率,然后才能谈优化问题,而且每做一步优化都需要强测。从六系命运共同体的角度来说,最好的优化是不优化,让所有人都达到一种精妙的平衡。
UML 图和复杂度图
UML 图
和第二次类图几乎一样,下面介绍增加的部分
Expr
类:needBrackets()
:判断结果(sin & cos
内部)是否需要加括号simplify()
:能否化简(如果可以则继续化简)reverse()
:将结果所有的系数取相反数
Key
类:canMerge()
:能否合并 KeyhsEquals()
:两个HashSet
是否相同
Tri
类:checkInsideExpr()
:检查sin & cos
内部表达式符号(符号压缩)
类复杂度图
Class | OCavg |
OCmax |
WMC |
---|---|---|---|
deconstruct.Lexer |
3.5 | 10.0 | 14.0 |
deconstruct.Parser |
3.625 | 13.0 | 29.0 |
expression.BasicClass |
1.33 | 3.0 | 8.0 |
expression.Constant |
2.33 | 5.0 | 7.0 |
expression.Expr |
4.3125 | 13.0 | 69.0 |
expression.Function |
1.25 | 2.0 | 5.0 |
expression.Key |
3.0 | 12.0 | 39.0 |
expression.Sum |
2.0 | 3.0 | 4.0 |
expression.Term |
2.71 | 8.0 | 19.0 |
expression.Tri |
2.0 | 12.0 | 28.0 |
expression.Variable |
1.5 | 3.0 | 9.0 |
MainClass |
2.0 | 3.0 | 4.0 |
operation.Add |
3.0 | 3.0 | 3.0 |
operation.Multi |
3.0 | 3.0 | 3.0 |
Total | 241.0 | ||
Average | 2.77 | 6.64 | 17.21 |
表达式类和 Key 类开始趋于复杂,我分析原因是我在其中实现了三角函数的化简,期间依赖关系增加,条件判断趋于复杂导致。然后仍然是 Parser 类和 Lexer 类,由于第二次作业第三次作业增加的部分不多,所以复杂度仍然很高。
方法复杂度图(部分)
method | CogC |
ev(G) |
iv(G) |
v(G) |
---|---|---|---|---|
expression.Expr.needBrackets() |
16.0 | 7.0 | 7.0 | 9.0 |
expression.Expr.simplify() |
30.0 | 7.0 | 14.0 | 15.0 |
expression.Expr.toString() |
13.0 | 6.0 | 7.0 | 8.0 |
expression.Key.canMerge(Key) |
54.0 | 6.0 | 14.0 | 14.0 |
expression.Expr.equals(Object) |
4.0 | 4.0 | 2.0 | 5.0 |
expression.Expr.getCoefficient(BigInteger, BigInteger) |
8.0 | 4.0 | 4.0 | 4.0 |
expression.Tri.getAns() |
17.0 | 4.0 | 11.0 | 12.0 |
expression.Expr.getAns() |
13.0 | 3.0 | 10.0 | 11.0 |
deconstruct.Parser.parseFactor() |
22.0 | 1.0 | 13.0 | 15.0 |
expression.Expr.appendX(Key) |
27.0 | 1.0 | 17.0 | 17.0 |
expression.Key.addKey(Key) |
14.0 | 1.0 | 10.0 | 10.0 |
expression.Term.replace(ArrayList, ArrayList) |
16.0 | 1.0 | 8.0 | 8.0 |
... | ... | ... | ... | ... |
Total | 313.0 | 135.0 | 242.0 | 269.0 |
Average | 3.59 | 1.55 | 2.781 | 3.09 |
这里比较重要的是 canMerge(Key) 方法和 simplify() 方法,这两个方法构成了我第三次作业化简的核心。前者判断两个 Key 能否进行 sin(x)**2 + cos(x)**2
的合并判断,而前者则遍历结果寻找可能可以合并的两项。由于三角函数的合并我没有想出快捷有效的方法,所以只能逐个比较项,以判断能否合并,需要大量时间复杂度和空间复杂度。然而,所幸除极个别针对性 Hack 样例需要大于 10 秒之外,其余检验合并样例都可以在比较短的时间内完成,说明了我的方法具备一定合理性。
2 bug 分析和测试
2.1 第一次作业
溢出:一个比较可能的 bug 是系数的溢出问题。可以通过构造超过 int 的大常数的方法进行测试。如果在设计时考虑到了,使用了 BigInteger
就基本不会出现问题,属于很简单的 bug。
空指针:出现空指针的原因可能直接将系数为 0 的项删除,调用 toString()
输出空串,导致 BigInteger
计算的时候报错。只需要特判空串时输出 0 即可,属于简单 bug。
本次强测互测没有发现任何 bug,但是互测也没有发现他人的 bug。
2.2 第二次作业
符号:sin & cos
内部符号提取的问题。需要搞清楚 sin
内部的符号和 Factor
外部的符号是不是一类东西。仔细思考可以发现,Factor
前的正负号是整体的正负号,但是 sin & cos
内部的符号是这一部分的符号,需要搭配它的指数部分共同判断。我在自测的时候混淆了这两个概念,属于隐蔽的 bug。
解决方案:1. 修改架构,将吃符号放到外层,不要堆到 Factor
层判断 2. 想清楚符号的含义,不要混淆
sum 函数:这里同样是符号的问题,在代入 i 的时候没有考虑清楚内部的符号和外部的符号的含义。
本次强测被发现一个 bug,互测发现他人一个 bug,就是 sin(-1)**2
错误提取符号的问题。
2.3 第三次作业
递归:函数的递归调用,如果调用很深有可能出错。
本次强测互测没有出现问题,互测发现他人两个 bug,一个是 sum 函数内部形参没有考虑指数,可以提交 hack;一个是调用深度加深导致出错的问题,无法 hack。
2.4 测试方法
数据构造
与 wxg 同学合作,他负责数据构造器部分,我负责自动测评机。
测评机
使用 python 库,自动比较结果。
测试时选择先粗看一遍被测者代码,找到类似 replaceAll()
和正则表达式相关内容,如果有则针对此构造强测用例,检验其合理性。如果没有,则再看被测者有没有进行 sin & cos
符号提取和三角函数的优化,如果有则可以构造强测用例。如果没有则再看被测者的输出逻辑是否有问题,这可能导致输出格式的问题。如果选择直接扔进测评机磨练。
3 改进方面和心得体会
-
格式错误:测评机目前不能支持判断格式错误相关问题,有些遗憾第三次作业没有要求判断 WF。
-
着急下笔?审题是关键!:形式化表述具有严谨规范的特点。因子、项、表达式前的符号都有特定的位置,不能图省事混乱了原本整齐的结构。
-
策略问题——测试?优化?:凡涉及到优化的时候要额外注意基本功能是否满足,已有的功能是否有 bug。优化的时候及时 commit,做好版本控制,便于及时回退。
-
心态问题——崩溃 -> 适应:本次作业中,我有两个晚上近乎崩溃。原因是需要考虑的点太多,没有理清轻重关系。首先,本单元作业的关键部分是结果的表达,在构思结果应该用哪种数据结构表达时常常需要大量时间,导致测试和 debug 时间不足。解决方法:心态必须调整,可以降低完美设计的要求,减少自我内耗。选择一种可以实现的思路先去实现,解决并且总结这条路上的问题;同时及时进行技术沟通和思路交流,该重构时不要犹豫;保持信心,相信没有什么克服不了的难题,多给积极的心理暗示;劳逸结合,每天进行适量运动,保持睡眠休息。
-
博客撰写难题:首当其冲,就是这些复杂度的定义比较混乱,难以在 Google、百度查找到相关定义,从而难以分析复杂度高和复杂度低的原因。