Loading

2021-面向对象设计与构造-第一单元总结

第一次作业

UML 类图

homework1UML

架构与实现

第一单元仅包含幂函数,因此我只创建一个类叫 Polynominal (严格来说是“广义”上的,毕竟多项式不能出现负指数),并利用 HashMap 存储每一项的系数。同时,为求导编写了一个方法,它将返回一个求导后的 Polynominal 对象。

另外,我当时没怎么用过 toString(),直接写了一个 print() 函数进行输出(简直是人类迷惑行为),作用是相同的,这一点在后面的作业中有所改进。

可以看到这次作业除了没有全在 main 函数内实现以外,一点儿也不面向对象。但笔者认为第一次作业的架构意义不是很大,毕竟如果完全不知道后两次作业内容大概率是要重构的,其主要的作用在于练习使用正则表达式,因为它将贯穿三次作业。

本次作业不存在 WRONG FORMAT!,因此我选择替换掉输入中所有的空格与 \t 来简化正则表达式的设计难度:

第一次作业中的表达式没有括号,可以直接利用指导书给出的形式化表述,构建正则表达式。

其中表达式和项的定义形如 \(\text{ABABA}\) ,其中 \(\text{A}\)\(\text B\) 是正则表达式,两者交替出现,\(\text A\)\(\text B\) 多出现一次。(例如 \(\text A\) 是项,\(\text B\)\(+\)

  • 一种可行的方案是使用如下的正则表达式 \(\text {A(BA)*}\)

因子的定义非常简单,直接用 \(\mid\) 进行连接就可以。

将表达式中的每一项匹配后,再用类似的方法对每项对应的子串进行操作,将每个因子匹配即可。

另外,本次作业性能分也是唯一一次存在标准答案的:

  • 合并同类项。
  • 优先将正系数项放在首位。
  • x**2x*x 长。

测试与 bug 分析

本次作业没有在强测与互测中出现 bug,并获得了全部的性能分。

本地测试和互测时均采取 python (sympy + xeger + subprocess 评测机) 暴力对拍。

互测中还阅读了部分同学的代码,共找出 2 位同学的 bug,其中一位同学在一个方法中将指数的 BigInteger 类强转为 long(这强测没有爆 long 的指数,就离谱),另一位同学在符号处理过程中将 BigInteger 类强转为 long。

度量分析

方法复杂度分析

homework1CMM

类复杂度分析

homework1CCM

类代码行数分析

homework1LOC

度量分析总结

复杂度分析中,红色部分对应的方法/类是代码复杂度过高的,会提高代码维护与扩展的难度。

其中,print() 是输出表达式的部分,为了最短化输出,逻辑可能过于复杂。Polynomial 作为一个唯一的类,除了输入功能几乎都囊括了,势必会造成复杂度过高。

类的代码行数上,因为没有考虑任何扩展性和 OO 特点,自然行数很少。

第二次作业

UML 类图

homework2UML1

homework2UML2

架构与实现

先不谈这架构有多乱,为什么这玩意儿是对称的?镜像实体?下面我将一步步阐述这次失败的作业:

我们先聚焦于类图左侧部分:首先,我按照指导书建立了三个类,分别为表达式 Expression,项 Term,因子 Factor。其中,Factor 是一个抽象类,它被 PowerFactor 和 NestedFactor 所继承,前者即为幂函数因子,后者为允许嵌套的因子,也是一个抽象类,被 SinFactor 和 CosFactor 两者所继承。

对于 Factor,我直接将指数保存在其中,而对于 NestedFactor,还有一个 Expression 类型的成员变量 variable,因为根据链式法则,对因子求导时需要用到其自变量的导数。(这里直接按照允许嵌套的标准编写的,相当于直接实现了第三次作业的部分功能)

对于 Term,我使用一个 ArrayList 保存其中所有的 Factor,同时还有一个 BigInteger 的成员变量代表这个项的系数。Term 内实现了项与项之间的加法与乘法,加法必须保证两个项完全相同。

对于 Expression,我同样使用一个 ArrayList 保存其中所有的 Term。Expression 内实现了表达式与表达式的加法与乘法,乘法即为双重循环的“多项式乘法”。

对于上述三个类,都可以进行求导,求导返回的是一个 Expression。求导按照数学上的公式实现即可,本质上是在调用 Term 和 Expression 类的加法与乘法。

和第一次作业一样,我将输入中的空格和 \t 删掉以简化正则表达式的复杂度。基于上述架构,我的字符串匹配算法如下:

单一的正则表达式是无法处理嵌套问题的,因为你无法指定哪两个括号进行匹配。

但是我们可以对整个字符串进行一次 \(O(n)\) 的括号匹配来解决这个问题,考虑找出所有的外层括号,最终串一定形如下列形式:

\[A_1(B_1)A_2(B_2)\cdots A_{n-1}(B_{n-1})A_n \]

其中 \(A_1,A_2,\cdots,A_n\) 不包含括号,我们只需对 \(B_1,B_2,\cdots,B_{n-1}\) 递归处理,再对下列字符串使用正则表达式进行匹配:

\[A_1()A_2()\cdots A_{n-1}()A_n \]

而正则表达式匹配是按照顺序来的,因此直接按顺序将每个 \(B_i\) 放到对应位置的括号即可。

相比第一次作业,表达式和项的定义完全相同,而因子的可能多了 sin()**xcos()**x() 这三种,因此正则表达式多加几个 \(\mid\) 即可。

每个位置只会被包裹它的括号所扫到,最多有 \(O(n)\) 次,因此括号匹配的复杂度为 \(O(n^2)\)。同时每个位置最多被正则表达式匹配到一次,串长 150 的情况下不会造成 TLE。

可以看到,如果止步于此,整个架构还是比较清晰的,但是还需要进行优化算法,此时这个架构就非常不好用了。

注意,一些基本的优化,例如合并同类项,删掉零次方的因子等等,太过于基础,不在本博客赘述。

首先,我没有选择 HashMap 而是用了 ArrayList,使得在寻找相关项(例如 \(\sin^2(x)\)\(\cos^2(x)\),以及合并同类项)时需要遍历整个 ArrayList,当优化遍历次数增大,很容易造成 TLE,因此优化遍历次数不能太大,效果会大打折扣。

对于这一点,起初我天真的认为串长只有 150,只要实现优雅一点应该不会太慢,于是我做了以下两个优化:

  • 对于任意一个表达式,如果同时存在项 \(A\sin^2(E)\)\(B\cos^2(E)\),则利用 \(\sin^2(x)+\cos^2(x)=1\) 的性质消掉其中一项,得到一个更短的常数。实现方法即为遍历所有 ArrayList 找到所有 \(A\sin^2(E)\) 再去遍历是否存在 \(B\cos^2(E)\)
  • 对于一个表达式,进行数次随机分组,对于每组提取公因式,再对提取公因式后的表达式递归调用该算法。当然,需要设置一个递归层数。其中提公因式的部分,我通过为 Term 编写了一个获取最大公因式的方法来实现。

如果基于这个架构,上述的第二个优化调用的次数非常少,效果并不是很好。此时我受到讨论区的启发,发现如果因式分解能将一个表达式大幅缩减,那么选择不拆括号大概率就能达到很好的效果。

事实上,拆括号所带来的影响可能是致命的,很容易就可以构造出一些样例,使得不拆括号输出的表达式长度超过 \(10^4\),例如:(虽然强测并没有卡这个

(x+sin(x)+cos(x))*(x+sin(x)**2+cos(x)**9)*(x+sin(x)**3+cos(x)**8)*(x+sin(x)**4+cos(x)**5)*(x+sin(x)**6+cos(x)**7)*(x+sin(x)+cos(x))*(x+sin(x)+cos(x))

但是,上述的架构并不支持不拆括号的写法,因为 Term 只包含常数和非表达式因子,此时已经可以看出架构不是很适合,是时候重构了。但是我没有选择重构,而是选择直接对现有架构进行调整,也就是上图中右侧的部分。

我在 Term 中使用了另一个存放 Expression 的 ArrayList 去存这个项中的表达式因子,同时 Expression 类的乘法方法不再使用双重循环的“多项式乘法”,而是将两个多项式都视为一个表达式因子组成一个新的项。(当然这里还有一些优化细节,例如对于一个单项式可以直接保留其因子,这里不再赘述)

修改过程中我几乎将所有相关部分都进行了调整,包括上述极其复杂的优化算法,而正是这里出现了致命的 bug,后面会提到。

然而,改完之后,在室友的提醒下,我发现也有很多情况是拆括号更短的(第二三次作业强测中有所涉及),比如下面两个例子:

(1+x+x**2+x**3+x**4+x**5+x**6+x**7+x**8+x**9)*(x-1)
(x*(x*(x*(x*(x*(x*(x*(x*(x*(x+1)+1)+1)+1)+1)+1)+1)+1)+1)+1)

而讨论每个括号是否要拆显然是指数级别复杂度,因此只能采用一个弱化版的方案:全拆或全不拆。

显然,上述架构无法兼容两种,然而我因为偷懒还是没有选择重构,而是选择了一个离谱的方法,把上述两个架构全跑一遍,然后取最小值。我把两个版本各自放在 breakbrackets 包和 keepbrackets 包下,在 Main 函数中各自调用一遍。

可以看到这个架构几乎没有拓展性,如果不重构几乎很难进行维护,甚至一些重复的代码都要写两遍,而且两遍甚至因为细节的不同甚至还不一样,太容易出 bug 了。我想进行合并,但是因为架构的问题过于困难,例如拆不拆括号就不是很好进行标记,因此我尝试先进行对拍,如果没 bug 就不改动了。

测试与 bug 分析

很不幸,不拆括号的版本的提公因式部分存在如下 bug:

该 bug 存在于 keepbrackets.Expression 的 merge 函数中,行数较多,圈复杂度极高(可以看后面的度量分析)。

该 bug 的具体内容是提取公因式时其中一个部分没有将 Term 中的 Expression 也算上,导致对应的表达式直接缺失。

该 bug 的产生原因是因为这个函数是魔改过来的,后续的改动并没有很好继承先前设计的思路,很容易造成细节上的遗漏。

这一次,本地测试依旧用的是 python 评测机,使用了 sympy + xeger + subprocess,并编写了递归函数生成括号内的表达式,缺点 是数据生成比较随机。我的输出取了两者输出更短的那个,由于数据生成的随机性,没有生成出命中上述 bug 但是不拆括号版本更短的样例,因此没有拍出这个 bug。

于是,我没有选择再进行改动,最终的结果自然是惨淡的,互测中,我因为这个 bug 被 hack 干爆。

此外,因为上述算法常数实在是太大了,最终强测有一个点被卡常 TLE 了,虽然拿满了其它点的性能分,但实在是得不偿失。

可以看出因为我没有及时重构,无论做出什么样的优化都受到各种各样的限制。而且,在和室友对比性能分结果后,发现最终主要发挥作用的部分还是不拆括号的优化,让我 TLE 的那些优化可能只占了 0.001 分,由此可得优化的方向也是非常重要的。(充分了解得分机制也很重要,后面会说

互测时也使用上述评测机去对拍其它同学的程序,hack 了 3 个同学,另有 1 个同学有 bug 但没测出来,因为我默认大家都是删掉空格和 \t,而该同学用的是递归下降,在处理空格的部分出现了 bug。

这提示我们一味暴力对拍并不是万能的,一定程度上阅读代码后再针对性构造有时能出奇效。

度量分析

方法复杂度分析

因为方法过多,这里仅截取复杂度过高的部分方法进行分析。

homework2CMM

类复杂度分析

homework2CCM

类代码行数分析

homework2LOC

度量分析总结

方法复杂度分析中,merge、simplify、getGreatestCommonFactor 等方法即为上述提到的优化算法所使用的方法,如此复杂的逻辑结构,最终导致了 bug 的产生与难以修复。matchTermsAndFactors 方法用于字符串匹配,虽然采取的匹配算法十分简单,但是因为因子种类较多,无法避免分类讨论,从而造成逻辑过于复杂,或许可以再多开几个方法分别处理,这样也使得代码更加可维护。

类复杂度分析中,主要是 Expression 和 Term 这两个类包括的东西太多了,这在代码行数上也有所体现。这与架构的复杂性有着一定的关系,另外其实可以将 Expression 中字符串匹配的部分移到一个工厂类中,这里在编写过程中略欠考虑了。

代码行数比较多,但实际有一半都是完全相同,不过即便如此去重后也有约 700 行代码,这与第三次作业的代码量形成鲜明对比。

第三次作业

UML 类图

homework3UML1

homework3UML2

架构与实现

吸取第二次作业失败的教训,我选择直接重构,事实上,重构起来是非常轻松的,因为很多东西之前已经写过一遍,而且有着明确的目标,并为后续优化主动留后路。可以看到,重构后上面的类图明显清晰多了。

本次重构相比第二次的架构主要的变化如下:

  • Factor 及其全部子类、Term、Expression 这几个类的继承关系和含义均不变。

  • 为所有类构建了 hashCode() 方法,得以将所有 ArrayList 替换为 HashMap,复杂度一下子就降下来了。

  • Term 内部有两个 HashMap,分别存放 Factor 和 Expression,前者存放项中的非表达式因子,后者存放项中的表达式因子,Value 均为对应因子的指数。

  • Expression 内部有一个 HashMap,存放表达式中的项, Value 为对应项的系数。

  • Factor 内部不再存储指数,Term 内部不再存储系数。

  • Expression 乘法中默认不拆括号,实现方法类似于第二次作业。

以上为基本的架构,可以看到架构变化后优化上就很好下手了,这里再提一下有关字符串匹配的部分:

注意到第二次作业的字符串匹配完全适用,除了要在对应位置加上空白项,还需要注意的一点是 \(\sin(E),\cos(E)\) 中的 \(E\) 必须是一个因子,如果是一个表达式那必须再加个括号才合法。这也非常好处理,我们在递归过程中传入一个参数,表示当前串是一个表达式或是一个因子就可以了。

基于上述算法,WRONG FORMAT! 也很好判定,递归过程中出现括号不匹配或整个串无法匹配对应的正则表达式即可认为格式错误,此时直接抛出一个异常 WrongFormatException,在主函数内将其 catch 即可。

好了,接下来我直接开始进行优化,新的架构立刻展现出了它的威力所在:

首先是拆括号的优化,每个 Expression 和 Term 都有一个 breakBrackets() 方法,可以将对应的表达式/项中的括号全部拆开,实现方法即为递归拆括号,并在 Expression 中新增了一个 multiplyBreakBrackets() 方法,该方法即为双重循环的“多项式乘法”。

而对于每个表达式,输出时将拆括号的版本与不拆括号进行对比,选择较短的那一个作为其输出结果。同时为了避免 TLE,当拆括号的展开项数过多时,直接抛出 TooLongException,终止拆括号的进程。

此外,可以利用 \(\sin(0)=0\)\(\cos(0)=1\) 进行优化,基于此架构,也是非常容易实现的,这里即为 Term 中的 simplify() 方法。

当然,上面这些优化都是和基础功能高度 解耦 的,这也是一个好架构所带来的优势。

只需做这么简单的优化,就可以在第三次作业中获得满分,第二次作业中也是如此(可能会少个 0.001),为什么呢,这正是第二次作业 bug 分析中所提到的 了解得分机制 的重要性所在,如下两张图是指导书中第二三次作业的得分曲线:

第二次作业得分曲线

2-性能分

第三次作业得分曲线

3-性能分

可以看到,即使你的输出比最优解长了 1.5 倍左右,依旧是近乎满分。只有当你的输出比最优解长了两倍以上时,才会严重失分,而课程组在第二三次作业的强测所给出的数据中,大体上决定输出长度的只有一点,那就是 正确选择 拆括号还是不拆括号。如果你只写了一个,那么当另一种方案是最优解时,你就会失去近乎全部性能分。

因此可见优化方向的重要性,对于其它优化,包括因式分解和三角恒等式,受限于时间复杂度,在非特定构造数据的情况下,都只能起到锦上添花的作用,而非雪中送炭。

这里的特定构造,指的是必须通过因式分解和三角恒等式才能缩短输出长度,例如使用 \(n\) 倍角公式构造的三角函数和式,或是给出两个表达式乘积展开后的形式。

对于这类数据,笔者想不到有什么高效方法处理,恳请 dalao 们赐教。

测试与 bug 分析

本次作业没有在强测与互测中出现 bug,并获得了全部的性能分。

本地测试时,使用的是和第二次作业类似的评测机,并加入了一些随机插入空格的数据,以及一些 \(\sin(),\cos()\) 括号中是表达式的数据。

判断结果是否正确时,对于 WRONG FORMAT! 的数据,我选择方法是和室友一起进行对拍;对于格式正确的数据,除了使用 sympy 随机取点验证外,还额外编写了 checker(其实就是将本次作业的格式判定部分拿出来进行魔改一下),确保输出结果不是一个 WRONG FORMAT! 的表达式。

互测中,也是用上述评测机进行对拍,共找出 2 位同学的 bug。其中一位同学的程序有很大问题,在随机数据面前输出了大量括号都不匹配的表达式,不知道为什么能过强测。另一位同学的 bug 很隐蔽,拍了一晚上终于发现,在面对一些输入时,输出的表达式 \(\sin(E)\) 中的 \(E\) 不是因子而是一个表达式。

另外,因为互测的数据限制实在是过于严格,还有一位同学的程序会将 \(\cos(E)^{-50}\) 误判为 WRONG FORMAT!,可惜无法提交这个数据。

度量分析

方法复杂度分析

因为方法过多,这里仅截取复杂度过高的部分方法进行分析。

homework3CMM

类复杂度分析

homework3CCM

类代码行数分析

homework3LOC

度量分析总结

Expression 中 matchExpression 和 matchTermsAndFactors 这两个方法是实现字符串匹配的方法,过于复杂的问题和第二次作业相同,因为完成第三次作业的过程中并没有度量分析的意识,所以这个问题遗留了下来,解决问题已在第二次作业的部分提到,同时以后的设计过程中也要尽量避免这种过于“面向过程”的方法出现。另外,这两个方法还是没放到工厂类中,这也是第二次作业的一个遗留问题。

Expression 和 Term 中的 toString 方法,为了保证输出长度的最短,导致条件判断过多。笔者认为这一段的逻辑是非常紧密的,拆分会大幅破逻辑性,只能牺牲一定的代码可维护性。

代码长度上,仅有 582 行,相比第二次作业少了很多,重构后之所以能全方位吊打第二次作业,靠的正是一个合理的架构。

总结与收获

  1. 增强了对 Java 和 Python 语言的运用,练习了如何使用正则表达式。
  2. 初步理解了面向对象的思想,明白了代码不能仅仅能实现功能,更要有可拓展性和可维护性,这正是面向过程相比面向对象所不具备的。
  3. 切身体会了一个合适架构的重要性,第二次作业不仅在功能、可拓展性逊色于第三次作业,代码的实现难度也是远高于第三次作业,这完美体现了架构的真正意义所在。
  4. 重构真香!当发现架构不适用,又无法很好调整的时候,不要犯懒,赶快重构。不然只会像泥潭一样越陷越深,最终死于自己的怠惰,就像第二次作业一样,令我十分遗憾。
  5. 测试数据的生成一定要广而全,千万不要受限于自己的实现方式,例如前两次 hack 他人的数据完全没考虑过加入空格,因为潜意识里所有人都把空格删去了。另外,数据的强度也很重要,本次作业个人认为生成的数据都不算强,并没有覆盖很多情况,以后需要额外注重一下构造方法。
  6. 希望有更快的评测机更强的 System Testing,和数据限制同强测的互测环节
posted @ 2021-03-26 20:53  JJLeo  阅读(238)  评论(1编辑  收藏  举报