迭代、Hacks与优化
hw1
需求分析
第一次作业要求实现一个仅包含整式的、括号最多嵌套一层的单变量('x'
)括号表达式化简。要求输出中不能包含括号。
架构与实现
第一次作业相对简单,只是一个预热,但也强度不小。处理表达式化简的问题,需要先理解何为递归下降法。这个方法的核心思想在于,利用表达式的特征结构,把整个表达式逐层向下展开分析,最后得到一个树形的结构,然后再自底向上计算答案。
然而仅是理解了方法,距离落实到代码上还很远。我们需要一个方便简洁的架构来实现我们的构想。参考第一次训练的代码,我设计了外层用Lexer
和Parser
分析输入的表达式字符串,然后Parser
按照表达式的逻辑转化成内部的由Expr
、Term
、Number
和Pow
构成的递归下降树形结构,最后再使用多项式类Poly
来计算答案这样的结构。
Lexer
是最外层的、仅对输入表达式字符串进行词元划分的分析类。根据多项式的格式,我把词元划分成了以下类型:EOF、无符号纯数字、'x^...'
、'('
、')^...'
、'+'
、'-'
、'*'
。如果在'x'
和')'
后没有幂次,手动补全幂次为1保持格式统一。Lexer
的核心方法是Lexer.next()
,其作用在于分析下一个词元并存放到curToken
中,然后向前移动字符串指针。可以调用Lexer.peek()
查看curToken
。
Parser
是根据Lexer
传递的词元分析生成树形结构的类。在构建此类之前,我先构建了Expr
、Term
、Number
和Pow
四个类,分别表示表达式、项、数字、幂函数。其中除Term
外的三类都实现了Factor
接口,表示他们是因子。Expr
包含一个ArrayList<Term>
,表示表达式由一些项构成;Parser
包含一个ArrayList<Factor>
,表示项由若干因子相乘而得。在Parser
中,main
调用parseExpr
,parseExpr
按照构造规则,多次调用parseTerm
。parseTerm
又按照构造规则调用parseExpr
、parseNumber
、parsePow
,形成一个互相调用,逐级向下的结构。
最后我使用Poly
类计算答案。考虑到第一次作业的答案形式比较固定唯一,一定是一个多项式,所以我构建了Poly
多项式类。Poly
类包含一个HashMap<Integer, BigInteger>
,键表示多项式内项的指数,值表示多项式内项的系数。利用Parser
构建表达式树后,直接调用构造函数Poly(Expr)
构造答案。其内部会向下递归调用Poly(Term)
、Poly(Number)
、Poly(Pow)
等其他构造函数,遍历整个表达式树,并实现加法乘法辅助计算。
优化
由于第一次作业答案形式唯一,并且在Poly
的计算中已经体现了同类项的合并,所以并没有太多的可优化内容。我实现的唯一优化是找到最终答案中是否有正项,如果有就可以提到最前面,这样可以省下一个符号的长度。
Hacks
第一次作业房间里没有任何成功的hack。
结果
第一次作业顺利获得了满分,互测中也没有被hack。
hw2
需求分析
在第一次作业的基础上,加入了两个新内容:其一为自定义函数,规定定义内部不会嵌套,也就是自定义函数定义内不会有任何自定义函数;其二为加入了指数函数exp()
。
架构与实现
针对自定义函数,我的解决方法是在最开始就把函数带入表达式中,做字符串替换,不涉及到后面表达式分析与处理。我新建了两个新类Func
和FuncKilla
,前者内部保存一个函数,包括一个String name
表示函数名,一个ArrayList<String>params
表示参数,一个String definition
表示函数的定义;后者包括一个HashMap<String, Func>
存储所有函数,其核心方法为FuncKilla.killFuncs(String input)
,它接受包含自定义函数的输入,返回消除所有自定义函数后的纯表达式。处理过程中需要注意一些细节,例如自定义函数代入参数时需要加入额外括号,自定义函数本身替换完成后也需要加括号,为了防止出现运算符优先级相关的问题。
针对exp
,首先对Lexer
和Parser
做相应的修改。然后加入Exp
类,内部包含一个Expr
表示指数上的内容,并实现Factor
接口,这与第一次迭代相关内容并无大异。改动最大的部分是Poly
部分。虽然此次迭代答案不再一定是多项式,但是我仍然希望沿用第一次迭代求答案的形式。然而,Poly
内部不能再用 HashMap<Integer, BigInteger>
存储,这就需要另动脑筋。
第一次迭代中,键的Integer
表示x^...
,考虑到这次有指数函数,我新建了Unit
类,包含一个Poly poly
和一个BigInteger index
,表示一个形如
e
poly
x
index
e^\text{poly}x^\text{index}
epolyxindex的单元。这样Poly
内部就可以使用HashMap<Unit,BigInteger>
这样的结构存储。然而这样需要实现Unit
的equals
和hashcode
等内容,比较繁杂。因此,我使用了一种简单的替代方案。考虑到后面一定会对Unit
实现一个toString
方法,因此我决定使用两个哈希表HashMap<String,Unit>
、HashMap<String,BigInteger>
代替上面一个哈希表的结构。这样的替代需要一定的牺牲:为了让同类项的合并顺利进行,我需要保证两个实际上相同的表达式toString
之后的结果也必须一模一样。因此,我把HashMap
换成了TreeMap
,这样toString
遍历项时遍历的顺序也一模一样,就可以实现上面的保证了。最后,由于我需要多次调用,我给Poly
和Unit
的toString
方法都加上了记忆化,保证不做重复计算。
优化
这次作业优化可以做的工作很多。首先我延续了第一次省去一个符号的优化。其次由于指数函数的特殊性质,一方面可以提提公因式exp((10000+20000*x))=exp((1+2*x))^10000
,另一方面可以拆弹专家exp((100000001+100000002*x+100000000*x^2))=exp((1+2*x))*exp((1+x+x^2))^100000000
,此处可以做的优化多种多样。提公因式类的优化很好实现,拆弹专家在小情况下可以暴力枚举搞一搞,在较大的情况下不好实现。我决定只实现提公因式,不做拆弹专家。
hacks
没有被hack,也没有成功hack别人。最终房间里有一份成功的hack,是利用exp
多层嵌套卡掉了一份优化不佳的代码的运行时间。
我手构了许多corner case,还借助了民间开发评测机的力量,但也没有发现其他bug。
结果
课程组只出了一个拆弹专家的点,因此性能分基本没什么损失。但是我Unit
没有开BigInteger
,而是保留了上次的Integer
,这次由于不限制括号嵌套,指数会超出Integer
范围挂掉了一个点。最终得到了约94.9分。互测顺利通过。
hw3
需求分析
加入了两个点,其一是函数可以嵌套定义,但不会递归;其二是加入dx()
算符,表示内部内容对x
求导。
架构与实现
函数嵌套很简单,只需要把之前的FuncKilla
用在函数的definition
上就行了。求导算符与hw2类似,在词法树上加入Dx
类并对Lexer
和Parser
做相应添加,然后在Poly
和Unit
实现getDx
方法即可。只是一些简单数学方法的使用,没有架构上的改动。需要注意,任何修改都应该new
新的对象,防止出现容器冲突。
优化
长教训了,挂一个点是再多性能也补不了的。所以与hw2相同,我没有添加任何新的优化,反复检查正确性去了。
hack
似乎这次大家都受了hw2的洗礼,代码变得无比坚挺。一直到周一中午我才发现房间里出现了成功的hack。最终结束后检查结果,我发现居然是故技重施,hacker使用了一个dx(exp(exp(exp(...exp()...))))
的结构卡掉了一份toString
使用过于频繁的代码的运行时间。难绷
结果
又出了一个拆弹专家的点,喜获88分。其他点都是满分。最终得到99.2分。互测也顺利通过。
最终架构与复杂度
类图
下图中我是在PPT中简陋地简单地用箭头框图描绘的类架构。具体已经在上一部分有详细说明,下面再做一些粗略解释。
Main
收到输入和自定义函数,经过FuncKilla
预处理去除了自定义函数,然后交给Lexer
进行词元分析,Parser
基于此递归构造表达式树。表达式树结构复杂,Expr
表达式类包含若干项Term
,而项又由因子Factor
构成。接口Factor
可能由幂函数、数字类实现,这里到达递归底层;也有可能直接由Expr
实现或者间接经过Exp
或Dx
到达更深层,形成一个网状互相递归调用的结构。Parser
处理完成后,把最外层Expr
交给Poly
执行构造函数,而Poly
又与Unit
互相递归调用,最终产生答案。
复杂度
复杂度表格如下所示。
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Func.getName() | 0.0 | 1.0 | 1.0 | 1.0 |
FuncKilla.FuncKilla() | 0.0 | 1.0 | 1.0 | 1.0 |
FuncKilla.FuncKilla(HashMap) | 0.0 | 1.0 | 1.0 | 1.0 |
FuncKilla.addFunc(Func) | 0.0 | 1.0 | 1.0 | 1.0 |
Lexer.Lexer(String) | 0.0 | 1.0 | 1.0 | 1.0 |
Lexer.peek() | 0.0 | 1.0 | 1.0 | 1.0 |
Main.fuckAwayWhitespaces(String) | 0.0 | 1.0 | 1.0 | 1.0 |
Parser.Parser(Lexer) | 0.0 | 1.0 | 1.0 | 1.0 |
Parser.parseFactorDx() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Dx.Dx(Expr) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Dx.getY() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Exp.Exp(Expr) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Exp.getIndex() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Expr.Expr() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Expr.addTerm(Term) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Expr.getIndex() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Expr.getTerms() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Expr.setIndex(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Number.Number(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Number.getNumber() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.Poly() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.Poly(Exp) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.Poly(Number) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.Poly(Poly) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.Poly(Power) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.Poly(Unit) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Poly.getCoefs() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Power.Power(BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Power.getIndex() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Term.Term() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Term.Term(ArrayList) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Term.addFactor(Factor) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Term.getFactors() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Unit.Unit() | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Unit.Unit(Exp) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Unit.Unit(Poly, BigInteger) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Unit.Unit(Power) | 0.0 | 1.0 | 1.0 | 1.0 |
expr.Unit.mul(Unit) | 0.0 | 1.0 | 1.0 | 1.0 |
Main.main(String[]) | 1.0 | 1.0 | 2.0 | 2.0 |
Parser.parseFactorExpr() | 2.0 | 2.0 | 2.0 | 2.0 |
Parser.parseFactorNumber() | 2.0 | 2.0 | 2.0 | 2.0 |
Parser.parseFactorPower() | 2.0 | 2.0 | 2.0 | 2.0 |
expr.Unit.calcDx() | 1.0 | 1.0 | 2.0 | 2.0 |
Lexer.getNumber() | 2.0 | 1.0 | 3.0 | 3.0 |
Parser.parseFactorExp() | 4.0 | 2.0 | 3.0 | 3.0 |
Parser.parseFactorNumberWithAddSub() | 5.0 | 2.0 | 3.0 | 3.0 |
expr.Poly.add(Poly) | 4.0 | 1.0 | 3.0 | 3.0 |
expr.Poly.calcDx() | 3.0 | 1.0 | 3.0 | 3.0 |
expr.Poly.Poly(Expr) | 4.0 | 1.0 | 4.0 | 4.0 |
expr.Poly.div(BigInteger) | 4.0 | 4.0 | 2.0 | 4.0 |
expr.Poly.mul(Poly) | 7.0 | 1.0 | 4.0 | 4.0 |
expr.Poly.zeroFucker() | 4.0 | 1.0 | 4.0 | 4.0 |
Parser.parseTerm() | 7.0 | 1.0 | 5.0 | 5.0 |
Lexer.getPower() | 10.0 | 3.0 | 5.0 | 6.0 |
Parser.parseExpr() | 8.0 | 1.0 | 6.0 | 6.0 |
Func.fuckParams(ArrayList) | 13.0 | 1.0 | 7.0 | 7.0 |
expr.Poly.Poly(Term) | 8.0 | 7.0 | 7.0 | 7.0 |
expr.Unit.i_want_shorter_exp() | 11.0 | 2.0 | 5.0 | 7.0 |
expr.Unit.toString() | 17.0 | 1.0 | 7.0 | 7.0 |
expr.Poly.toString() | 15.0 | 7.0 | 5.0 | 8.0 |
Main.fuckAwayExtraAddSub(String) | 18.0 | 1.0 | 8.0 | 9.0 |
Parser.parseFactor() | 8.0 | 7.0 | 9.0 | 9.0 |
expr.Unit.exprPrinterForExp(Poly) | 16.0 | 9.0 | 4.0 | 9.0 |
FuncKilla.killFuncs(String) | 29.0 | 6.0 | 7.0 | 10.0 |
expr.Poly.singlePrinter(BigInteger, Unit, int) | 27.0 | 2.0 | 10.0 | 10.0 |
Func.Func(String, FuncKilla) | 15.0 | 5.0 | 10.0 | 14.0 |
Lexer.next() | 14.0 | 9.0 | 6.0 | 14.0 |
Total | 261.0 | 122.0 | 178.0 | 207.0 |
Average | 3.896 | 1.821 | 2.657 | 3.090 |
可以看到,复杂度最高的几个方法都是与字符串处理有关的方法。这些方法可以强行拆分降低复杂度,但是这样丧失了可读性逻辑性得不偿失。
架构可扩展性
我的架构具有很强的可扩展性。以下是几个假想情景。
加入三角函数
只需要加入三角函数对应类,然后修改Unit
到适配的格式即可。
加入更多字母
只需要再Unit
中加入一个HashMap<String,BigInteger>
表示不同的字母的幂次即可。
加入求和函数
求和函数也可以视为一种自定义函数,可以像自定义函数一样加括号替换去除,不需要增添后面语法分析的麻烦。
心得与课程发展建议
学长都说OO的强度全在进度条前半,实际干下来一个单元发现确实如此。第一单元的内容不如上个学期流水线全靠借鉴没有前人智慧活不了一点逆天但也强度不小。
不过我和周围同学最后的感受都是,实际这样过一遍下来似乎也不是特别难受。我仔细思索了原因,应该是因为面向对象的一个特点:封装。每个类管好自己,每个方法做好自己该做的事,这种天然的特性把巨大的工程量化整为零。有的时候会出现辛辛苦苦码码码,写完某个方法突然不知道干啥了,再仔细一想发现好像已经写完了,这是面向对象分解问题的独有魅力。
整个完成作业,互相hack的过程看似残酷,其实非常有趣。要获得一个不错的分数并不难,只需要做好该做的事情,重点是过好强测,因此互测压力其实不大,更像是一种激励。我在作业的完成期间和互测期间与周围的小伙伴们都有很多的交流,互相借鉴互相吸收,对自己启发也很大。总体来讲第一个单元是非常愉快的一次学习体验。
对于目前的课程设计,我最希望修改的点是exp
拆弹专家。虽然拆弹专家们确实很厉害,但是我认为这一方面有违所谓化简的含义。把乘到一起的exp
再拆回去,这怎么能算是化简了呢?另一方面这部分化简内容有点偏题,不像是面向对象课的内容。可能规定一下最简形式下每个项至多一个exp
因子,且不允许有指数会比较好。不过这部分对分数影响并不大,不做修改,让愿意努力的人多拿一点分,也问题不大。其余部分都是比较满意且有趣的。