架构 && 迭代

应用最显著的是递归下降的设计思路。

我的代码分为三块儿大陆:Parser 负责解析,ast 负责整理,algebra 负责输出。三块儿大陆各司其职,立志于不做重复的工作。Parser 里的 Expr 类是它们的交通枢纽。

定义两个数据结构:ast 语法树(以递归下降的形式存储表达式),和 algebra 多项式(以标准输出结构的形式存储表达式)。

如图:尾缀带 Factor 都是继承 Factor 类的(这里无疑用接口更好,但问题不大就懒得改),其中 NumFactorVarFactor 是叶子Factor,别的都是非叶 FactorExpr 很特殊,它既包含非叶 Factor 的方法,又包含根节点的方法。

Factor 需要实现的方法,最初只有一个:toPoly

algebra 的标准输出形式最初是 num*x^exponent

对于输入的表达式字符串,我首先通过 Parser 将它转化成 ast 的形式来存储。ast 是一棵树,它会自动把表达式整理成递归下降的结构,可以通过根节点 Expr 访问。在 Expr 实现 toPoly 方法,把整理好的语法树导出成多项式的形式,此时已经到达了 algebra 大陆的 Poly 类。在 Poly 实现合并同类项方法和 toString 方法,将结果导出成字符串并输出。

以上是我的基本思路,具体实现在三次迭代中逐渐填充。

在迭代中要求实现的功能可以 按实现路径 分为 2 类,函数定义函数调用。前者包括 HW2 中自定义函数的定义和 HW3 中递归函数的定义,实现方式均为通过与之前无异的方法存储一棵语法树作为模板。后者包括 HW2 中 自定义函数、指数函数、选择表达式(函数)的调用,和 HW3 中递归函数,求导(函数)的调用,实现路径为 识别输入字符串中的特定字符 -> 用解析出的实参(因子或者表达式)替换模板中的自变量 -> 把生成的子树挂在目标表达式树中的对应位置,从这个角度想,那个复杂的自定义函数语法树,与 ExpFactor 类没有任何实质上的区别,只是一棵大树一棵小树罢了。

第一次迭代

就是那个骨架。

第二次迭代

功能一:自定义函数的 定义调用
功能二:指数函数的 调用
功能三:选择表达式的 调用

需要注意深拷贝的问题,因为函数可以多次被调用,使用浅拷贝会得到几棵纠缠在一起的树。

第三次迭代

功能一:递归函数的 定义
功能二:递归函数地 调用
功能三:添加 y
功能四:求导函数的 调用

有关用来求导的数据结构:
我的第一反应就是在 ast 里递归下降求导,研讨课时发现多数人采用的在 Poly 里求导,这样对于 dx(x-x) 等情况会更快。

其实我有一个异想天开的想法,既然给这个项目加功能的路径就那么两条,那能不能实现一个 “加功能 功能” 呢?统一功能接口,然后在加功能的时候写回调函数。当然,这个纯属异想天开。


Method CogC ev(G) iv(G) v(G)
Main.main(String[]) 2 1 3 3
algebra.MonoKey.MonoKey(BigInteger, BigInteger, Poly) 0 1 1 1
algebra.MonoKey.equals(Object) 3 3 3 5
algebra.MonoKey.getExpArg() 0 1 1 1
algebra.MonoKey.getXexponent() 0 1 1 1
algebra.MonoKey.getYexponent() 0 1 1 1
algebra.MonoKey.hashCode() 0 1 1 1
algebra.MonoKey.mulMonoKey(MonoKey) 0 1 1 1
algebra.Poly.Poly() 0 1 1 1
algebra.Poly.add(Poly) 4 1 3 3
algebra.Poly.addToSelf(MonoKey, BigInteger) 3 2 2 3
algebra.Poly.appendMonoBody(StringBuilder, MonoKey, BigInteger) 17 2 12 14
algebra.Poly.appendSign(StringBuilder, BigInteger, boolean) 6 1 3 4
algebra.Poly.buildExpText(Poly) 2 2 1 2
algebra.Poly.constant(BigInteger) 1 1 2 2
algebra.Poly.equals(Object) 3 3 3 4
algebra.Poly.hashCode() 0 1 1 1
algebra.Poly.isFollower(BigInteger, boolean, boolean) 1 1 3 3
algebra.Poly.isPureExpFactorLike() 6 2 2 7
algebra.Poly.isZero() 0 1 1 1
algebra.Poly.liftMono(Unit) 1 1 2 2
algebra.Poly.multiply(Poly) 3 1 3 3
algebra.Poly.negate() 1 1 2 2
algebra.Poly.packExp(StringBuilder, Poly) 0 1 1 1
algebra.Poly.positiveToFront(ArrayList) 3 3 3 3
algebra.Poly.pow(BigInteger) 5 1 4 4
algebra.Poly.toString() 4 4 2 4
algebra.Poly.zero() 0 1 1 1
algebra.Unit.Unit(MonoKey, BigInteger) 0 1 1 1
parser.Lexer.getNumber() 2 1 3 3
parser.Lexer.getTokenType(char) 1 18 1 20
parser.Lexer.next() 6 2 4 6
parser.Lexer.peek() 0 1 1 1
parser.Parser.Parser(Lexer, Definer, DeepDefiner) 0 1 1 1
parser.Parser.parseExpr() 17 1 8 11
parser.Parser.parseFactor() 19 11 13 15
parser.Parser.parseTerm(int) 12 1 7 9

我的 ast 类都写得很干净,这里就不贴出来了,主要是看 PolyParser。方法圈复杂度还勉强能看,我把 toString 拆分成几个功能模块之后就没有太恶心的东西了,但 Parser 还是让我很难受,这当然可以通过添加预处理类、降低 ParseFactor 内部的耦合度来解决,但我一直懒于修改。

不过好在这几个复杂度惊人的方法没给我招来 bug。


优化

  • 这优化可以拿去出国赛了。
  • HW1 只有一个正号提前……吧。
  • HW2 和 HW3 的优化点相同,都是 exp 内的 gcd 提取和 exp 拆分。
    • 我在 HW2 引入了 gcd 提取,提取思路是从 gcd 向下枚举因子,记录每个位数下的最大因子,得到一个不超过 10 个元素的候选集,然后暴力。这个方法有点笨,效果也很一般,但也是我想了挺久才想出来的,我最终找到一个性能最好而且在互测中不可能被 hack 的临界点,超过这个临界点就不记录、仅尝试 gcdgcd/2……gcd/100 。我后来知道其实最优解一定出在 gcd ~ gcd/0 之间,可惜数学能力不够,完全没往这个方向想。
    • 我在 HW3 引入了 exp 拆分,这个拆东墙南墙补西墙北墙的最优化问题,我想到的唯一一个解法是动态规划,实现的……还算可以,能看到效果。但是由于处理不完整造成了 bug,这个后面再说。

几个显著的问题

  • 组合优于继承,但我一开始就用了抽象类而非接口,后来懒得改。
  • 没有预处理方法,事实证明 PreProcesser 的存在可以省下很大功夫,前两次还看不太出来,第三次就很闹心了。直接导致 Lexer 爆复杂度和 DeepDefiner 又丑又长。如果再有类似的东西我一定要写预处理。
  • exp 类没有指数字段,也没有什么灵活的修改空间,间接导致我 HW3 优化出锅(因为优化完的东西没法存)。

自动化评测方法

我纯古法的数据生成器。采用递归下降+模块式生成的方法。写生成器的过程中遇到了很多问题:递归以及递归下降层数限制、Expr 一类两用、是否允许 y、是否允许选择式……我最终给模块式生成整理出一套参数:(depth, isFactor, allowFunc=True, allowChoose=True, allowDeri=True, allowRec=True, allowY=True),以更好地复用函数。

这个评测机在 HW1 中找出了自己的一个 bug,在 HW2 中找出了两位同学的 bug。但它有诸多不足,他没能测出 HW2 强测我的 bug,也没能测出 HW2 中我室友的 bug,前者是由于那个 bug 就是不好用自动化方法测出来,后者是因为我的数据生成器生成的选择表达式 == 两边只有同类型数据。

bugs && 互测

在 HW2 强测中出现 1 个 bug。在 HW3 互测中优化出 2 个 bug 被刀了 4 次。HW2 有效 hack 9 次,HW3 有效 hack 3 次。

HW2

强测 WA 了一个点,WA 进 b 房了😿:实在有点愚蠢,我把 PowFactor 里的指数改成了 BigInteger,但 Poly 里还是 int,面对 (((((((((x^8)^8)^8)^8)^8)^8)^8)^8)^8) 理所当然的溢出了。

我需要反思一下,为什么我的评测机没有查到这个点呢?其实是我的数据生成器太随机了,我只按照题目的要求写了格式规范,至于它具体生成什么全权交给 random 决定了。当然,我也有通过改变权重、改变常数范围的方式加强我的评测机,但幂次足够高的数据往往伴随着爆代价导致的 TLE,因此我 Poly 里使用 int 的 bug 完全被 TLE 掩盖了。

其实也怪我懒于手动构造数据,但我清晰地记得我刚进 HW2 就把 PowFactor 里的数据类型给改了,所以根本没想过自己会在这里出问题。还有,这种 “不对称” 问题 AI 应该是很好查的,我下次要交之前让 AI 给我盯一盯。

互测:像 (((((((((x^8)^8)^8)^8)^8)^8)^8)^8)^8) 这种点在互测是非法的,所以我没有在互测期间发现自己的 bug,也没被刀。

有个一穿 3 的点是:

1
2
3
1
f(x)=(x+1)^8
[(1==1)?1:f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f(f((x+1)))))))))))))))))))))))))))))]

简单粗暴有效。制裁使用字符串替换(除了能写明白这个的大佬之外)/随意实例化的人。

另有一个一穿 4 的点是:

1
2
0
[(0==0)?1:((((((x+1)^8)^8)^8)^8)^8)^8]

这个 cost 计算很阴啊,我自己能过是幸运的,因为我到互测才知道这个问题。

还有一位代码处理不了连续符号的同学,他无法通过我的评测机的任何一个数据点,结果误伤了他好几次,私密马赛。

总之这个房间很欢乐。

HW3

强测没事儿。互测被 hack 惨了。

一个 bug 是很多人都中招了的,乘开优化少括号的问题。在 HW2 中,为了判断 exp 需不需要套一层括号,我写了一个 isPureFactor 方法,判断一个 Term 是否只是 Factor,判断方法为 constExit + xExit + yExit + ExpExit <= 1。显然,对于 exp -> exp*exp 的情况,这里会少一个括号。

另一个 bug 是类似 exp((2*exp((2*exp((2*exp(x))))))) 加函数嵌套。我在做优化的时候有考虑 TLE 的问题,所以我加了限制:项数增加到一定程度就不做优化了,可我没有考虑多层嵌套超时的问题。

不过没关系,优化开关一关,都是同质 bug😉。

心得体会

我习惯于先设计架构,再把所有的类和空方法都建出来,最后再填充具体实现,这样看着自己设计的骨架一点点长出血肉,当然每次都会经历断骨再生就是了。虽然后续的迭代过程很小心,但还是眼睁睁看着我的代码变得奇形怪状。

我认为本次设计最大的优点是功能清晰,没有出现 oopre 中出现的 “不知道有什么用的类”、“走错类的方法” 和 “互抢生意的兄弟类(甚至父子类)”。最大的缺点在于方法内部存在大量冗余逻辑,导致一些方法(特指 parseFactortoString)又丑又长,甚至 checkstyle 不过、被迫拆分。

这个月的 oo 命途多舛,听说下个月的电梯更阴间,还是在清明假期……要带着 oo 爬山吗。

img