BUAA_OO 第一单元总结——表达式求导
第一单元终于落下了帷幕,期间为了四版架构熬了无数个夜,真是充实而又快乐心力交瘁。不过好在结果没有很差,程序也应该是符合了面向对象的思想,也体会到了面向对象思想在程序扩展上带来的便利。
第一次作业
架构
第一次作业时是有面向对象的意识的,也试图让自己的程序能够具有扩展性,但是实现得有些蹩脚。
想法是让所有小的元素(幂函数、常数,以后还想包括sin(x)等)继承自同一个Item类,有通用的toString、求导、化简方法。有一个独立出来的Poly类表示多项式,其内部存储着众多Monomial单项式类,而单项式类中存着那些继承自Item的因子。在最开始用一个工厂类完成输入表达式的所有解析任务,“造出”多项式、单项式、因子等所有对象。
起初用ArrayList存储了所有相加、相乘的元素(如上图),这导致在求导结束,化简的时候完全无从下手,因为对象里存的全是“小东西”,没有化简必须的系数的指数,于是想到通过加入coe、exp两个整数成员变量来弥补。这两个量给我一种与存储成员的ArrayList纠缠不清的感觉,在向ArrayList中加入元素时总需要想着改变coe和exp,甚至求导产生新的Monomial时也需要给coe和exp赋值,这种感觉很不自然,而且最后在使用coe和exp合并时内部存储的元素们还是以很散乱的形式完成的合并,于是并不满意,果断重构。
新架构的改动也算不上很大,只是将Monomial中的容器改为了HashMap,用一个枚举类中的元素做Key,表示存储的Value是哪一类型的成员,每次有一个新的因子加入到某个单项式时都要先“询问”该单项式中是否有与其相同类型的因子,如果有,直接将二者合并,若没有,加入新的键值对,这样在任意时刻存在的对象中存储的东西都是简洁干净的,最终合并时也只需要在Poly层面选取存有相同<X, Item>键值对的单项式进行合并。
当时的想法很美好,若以后加入sin(x)这类在两周前看起来还不可思议的元素,可以在ItemType中引入一个新的枚举Sin,这样就可以在HashMap中再存入一个<Sin, SinItem>键值对。然而这种做法确实可以挺过第二次作业,但到第三次作业三角函数嵌套表达式就做不动了。
UML类图
其实还不是很理解为什么放类图,感觉如果求导生成新的对象的话,各种依赖关系是不可避免的吧。目前还看不太懂类图,不过大家都放上了就跟风放上吧。
解析字符串
第一版的时候直接全程split完成,但这种做法只是小聪明,并不通用,于是重构时顺便也换用了正则表达式解析。
在互测时我注意到很多同学是直接使用一个巨大的正则匹配整个表达式,之后取用不同的group实现的解析,而我是先匹配出“项”这一部分的字符串,再用“项”字符串匹配出各个因子,相比之下我还是更喜欢自己的做法(因为正则过长我看不懂)。
思考
在第一次作业我只想到了所有因子是统一的有共同行为的东西,而没有认识到单项式、多项式本身也是可求导的。值得注意的是,我的因子求导方法的返回值是Monmial单项式,这在数学上确实是合理的,因为生成的东西确实就是定义为单项式,单项式相乘也确实返回单项式,但这种分类其实并没有实现最大的统一抽象,又好像过于具体,比如x求导只需要返回1,它应该是一个NumberItem,但在我这里却是以单项式的外观存在(NumberItem甚至并没有继承自单项式)就好像只找到了爸爸,叔叔,而没有意识到还存在着一个“爷爷”,那就是所有具备求导方法的东西的统称。
在本次作业中我还鬼迷心窍,孤注一掷地认为“合并”是直接修改某一成对象,比如x**5和x**6合并时是把x**5的指数改成了11,这导致合并过后原本的表达式被改变了,不复存在了!如果以后还想用原表达式做些什么,那直接是不可能了。
此外,第二版架构中,每一步操作都兼顾了化简,这看起来很周全,也不拖泥带水,但事实上并不符合程序设计“分而治之”的思想,还是应该创建、求导、化简分开实现。
复杂度分析
本次作业代码共329行,主要在检查HashMap的Key值,向单项式中并入因子部分代码较多。
不知道为什么,每个统计都是双份的...很明显工厂类、单项式类和多项式类的行数很多,我认为可能是merge(用于合并的函数)占用篇幅过大,如果直接new一个新的对象就能节省很多空间了。
方法复杂度截取飘红部分:
工厂类中建造单项式的方法和单项式类中的toString方法复杂度较高。MakeMonomial方法用于建造单项式,在向单项式中加入因子时需要判断原单项式中是否已有此类因子,因此判断和操作较多,复杂度较高,以后在创建项时将不考虑化简的问题,一个模块应该尽量只专注于实现某一个问题。toString为了优化判断了较多特殊情况,这可能是它飘红的原因。
第二次作业
架构
第二次在受到老师和指导书的启发后,认识到所有东西,大到整个表达式,小到一个数字,都源自一个类,在这里我称他为Item类,它们重写相同的方法:toString、求导、化简和获得自己的类型(当时不知道有instanceof这个好东西)
在Item之下有两个第一级的类,BasicItem基础类,其下有常数、幂函数、正余弦函数等基本因子类;CalculateItem计算类,其下有加法类、乘法类和嵌套类。每个计算类中都存储着两个成员变量(均为Item),表示参与运算的两个成员。其中嵌套类NestItem中第一个成员为外部函数,第二个成员为被嵌套的函数。
根据求导公式可以知道每个类求导时具体返回什么其他类,当然,函数返回值处还是写Item。比如乘法类,其求导返回一个加法类,加法类的两个成员变量又分别是一个乘法类。
这种架构可以让求导的思路非常顺畅,以至于实现完字符串解析的我高兴得以为一切突然结束了,因为求导的代码是在太少了,有一个Item统领,返回什么都是合法的!
1 //加法类 2 public Item derivate() { 3 return new AddItem(add1.derivate(), add2.derivate()); 4 } 5 //cos类 6 public Item derivate() { 7 return new MultiplyItem(new NumberItem(new BigInteger("-1")), new SinItem()); 8 } 9 //乘法类 10 public Item derivate() { 11 return new AddItem(new MultiplyItem(multiply1.derivate(), multiply2), 12 new MultiplyItem(multiply1, multiply2.derivate())); 13 } 14 //嵌套类 15 public Item derivate() { 16 return new MultiplyItem(new MultiplyItem(new NestItem(new PowerItem(((PowerItem) fatherItem) 17 .getTimes().subtract(new BigInteger("1"))), sonItem), 18 new NumberItem(((PowerItem) fatherItem).getTimes())), sonItem.derivate()); 19 } 20 //常数类 21 public Item derivate() { 22 return new NumberItem(new BigInteger("0")); 23 } 24 //幂函数类 25 public Item derivate() { 26 return new MultiplyItem(new NumberItem(times), 27 new PowerItem(times.subtract(new BigInteger("1")))); 28 }
她那时候还太年轻,不知道所有命运馈赠的礼物,都暗中标好了价格。 ——茨威格
化简的时候事情开始向奇怪的方向发展......
此外直接的问题在于,由于每个计算类都只存储两个对象,整个表达式最后变成了一个二叉树,它导致很多本可以合并的东西被分到了各个分支,互相碰不到。
但毕竟性能分也是分,硬着头皮往下做,使用递归的方法挣扎着完成了同类因子的合并和相加常数的合并。
以加法类为例,具体方法就是在类内部调用化简方法时,创建一个容器,对这个加法类两个成员变量分别化简,如果化简后是加法类,就把这个子加法类的成员变量(一直向下找,直到找到的不再是加法类)放到容器里,最后得到的容器内是整个表达式树里同一级所有能加到一起的东西,然而由于我的存储结构是二叉树,所以还要费力把这些好不容易压到同一级的东西再建成树...
问题还发生在了toString的时候,对于相乘的两个成员,需要用乘号连接两个成员的toString,而对于乘法类,出现1*什么的时候我优化掉了1这一项,于是bug出现了。(1*(5+7))*6,乘号左边直接优化成了5+7,于是变成了5+7*6,原地爆炸。
UML类图
解析字符串
这次作业中,我仍然是使用了一个工厂类专门解析字符串,考虑到括号导致的运算顺序,我使用了后缀表达式的方式完成字符串解析(这纯粹是一个面向过程的过程)。
对于本次作业我的“二叉树”一样的架构,我认为后缀表达式的计算过程也恰好与之非常相配,创建一个Item的栈,从解析出来的队列中取出最前面的值,如果是因子就压入栈中,如果是符号,弹出栈顶的两个Item根据运算符号造出新的Item再放回栈中。整个流程结束后栈中将只剩一个Item,这就是整个最初的表达式。
此处有一个值得注意的问题,栈中压入的顺序和弹出的顺序不同,尤其在做幂次运算时需注意指数后压栈而先弹栈。
思考
本次作业总体来说应该算是面向对象了,但由于曲解了老师讲解的思路,似乎有些跑偏,二叉树的架构导致那一周的化简体验极差,欲哭无泪。不过也并不是完全失败,递归化简的方式在我的第三次作业中也发挥了巨大的价值(性能分upup)。前面提到的toString问题也促使我思考输出和优化之间的矛盾,我认为出现矛盾是因为内部成员在toString时并不知道外部是什么东西,有没有可能改变运算顺序,当然,无脑加括号无疑是最保险的做法,但过于摸鱼,所以我觉得判断是否加括号这一行为可以由外层的对象来做,毕竟内层知道外层需要传参,而内层本就属于外层,外层可以清楚地知道内层的一切。
复杂度分析
本次作业代码686行,行数主要浪费在化简表达式上了。工厂类占据了228行,有点不平衡,可以考虑将本工厂的一部分功能划分成另一个类。
乘法类的行数一如既往的多,我认为这在我的这个架构里是很难解决的,因为乘法部分是化简的主体,比其他类多一个递归查找同级因子的过程,乘法类也是toString时需要特判最多的类,所以行数就飚上去了。
方法复杂度飘红部分:
工厂里造对象的类依旧红的彻底,这次解析字符串几乎完全是面向过程,可能判断过于复杂,而且对其他函数的依赖性较高。其他大面积飘红的都是用于化简的部分,可能因为运用了较多递归,还频繁调用其他方法用以获取同级加法项,所以复杂度和依赖程度爆炸。
第三次作业
架构
重看代码时发现我竟然让只需要实现第二级接口的类还实现了第一级接口?多此一举了,于是微调了一下架构。
这次作业了解到,方法可以用结构来统一,而不必使用抽象类,因此这次我使用了一个Derivable接口作为“爷爷”,继承它的是另外两个接口,它们的具体作用后面再讨论。
两个接口下是七个类葫芦娃狂喜,加法类和乘法类(我的程序中加法类叫做Poly乘法类叫Monomial,这里为了阅读将其称作AddItem和MultiplyItem)中用ArrayList存储成员,特别的,Sin类中的成员是Item(Derivable),表示sin()的括号里是什么。与第二次作业那个“嵌套类”不同,嵌套类只知道一个函数套另一个函数,事实上不同函数嵌套求导得到的结构可能并不以嵌套乘内部求导为最优。比如sin(x)**6,我们更愿意认为它的导数是6这个常数类,sin(x)**5这个幂函数,cos(x)这个余弦类相乘,而不是第二次作业得到的乘法嵌套与cos相乘。这样的架构更便于得到真实返回的东西(求导需要判断一些细节,但并不难想,即便没有判断也不会影响正确性)。
同样运用递归方式化简,以加法为例的示意图:
基本思想就是,化简时能扔就扔,像0,1,-1之类的简单特判一下就可以扔掉了,最后剩下的容器里如果个数多就返回加法类,如果剩一个就返回它,如果什么都不剩了那就返回一个0常数类吧,反正外圈也会把这个0优化掉的。
终于回到了那两个看似多余的二级接口,它们中空空如也,只是因为我从第二次作业得到了一个toString的灵感:在一个乘法类中,其某个成员如果继承了BracketRemovable接口,那么在toString时就不需要加括号来保护运算顺序;继承了Factorable接口的类表示可以作为因子,无需加括号。这样在toString时只需要用instanceof加以判断就可以完全解决括号的问题(少了长长的ifelse与或非还是很舒适的)。
字符串解析
当sin开始嵌套后,感觉后缀表达式变得有些绕弯子了,需要判断的字符情况也变多了,于是开始尝试传说中的“递归下降”(不如说是照着这个名字猜到底干了什么)。
众所周知,递归算法是不能画流程图的,但还是画了一个简单的示意图,虚线部分表示进入下一次递归了。
基本思路是先判断一部分WrongFormat,之后开始匹配括号,遇到左括号后开始截取这一层括号中的内容,其中的内容就可以作为一个新的表达式再次送到这个解析的函数中,而外部将这个括号块用一个字母表示,最终最外层表达式的第一层括号及其内容全部变成有代表意义的符号,而利用这些内容递归造出来的对象按顺序存储在一个队列中,之后对外层运用简单的正则匹配即可。在递归下降中可以完成其余WF的判断,如括号不匹配、表达式因子不加括号等。
思考
弱小和无知不是生存的障碍,傲慢才是。 ——《三体》
重构这么多次后这一版真的是我最满意的一个架构,自动测试也跑了五个小时没有出错,然而终于被强测的WF爆锤了一下。sin(+ 30)没有判断出来(+号后有空格,就不再是符号数了),自己没有测WF追悔莫及。
这次工厂造Item也遇到了问题,遍历字符串的代码过于长了,然而分函数需要传很多参数,尤其是循环里面的i,它应该在每个操作里视情况而改变,但是我不太会,因为整数它是值传递?于是非常无脑地把这些东西都变成了Factory的成员变量(相当于全局变量了?),又因为处理子字符串还要用到工厂,这时候Factory就不能当做静态类了,于是出现了频繁new一个Factory的迷惑行为...
复杂度分析
本次作业代码624行,居然比第二次还少,不过真实感觉到重复的轮子变少了,而且也几乎没有用于处理窘迫情况不得不写的代码了。
乘法类的行数少了一半!有了上次写递归化简的经验,本次的simplify过程更精简了,同时本次在化简的时候就扔掉了诸如*1之类的情况,还引入了可去括号的接口,因此乘法类的toString也更精简了!
方法复杂度飘红部分:
checkBlank,checkBracket居然飘红,前者只是检查空白WF,后者只是用来计算括号的总数...对于checkBlank可能是因为之后的所有操作都要在这一函数执行的基础上进行,相当于第一颗纽扣?checkBracket对于可能是因为其他函数调用这一函数过多,如果修改这一函数的bug可能导致其他调用它的函数受到影响。
除此之外,化简和造对象的函数一如既往地飘红。化简飘红的情况较第二次作业似乎有所好转。
自动化测试
硬着头皮写了测评机,看上去可以说非常小白了。主要思路:由一个模块生成数据到input.txt文件中,再将文件中数据分别输入到sympy和java程序x中,对比java程序的输出结果和sympy的计算结果,判断是否相等,若不相等存入另一个文件中(睡一觉批量收割🐶),数据生成不易,所以同一个数据再测试下一个人。顺便还可以把前一个人的输出送回到java程序,检查输出格式是否正确。
根据自己的理解造数据,不过似乎并不全面(以后也不会有表达式求导了):
总的来说,测评机虽然很简陋,但还是发挥了很大的作用,帮我de掉了好几个致命的bug。
互测发现的bug
第一次互测hack掉了三个人,印象最深的是用一个3500字符的随机数据hack到了一位大佬涉及jdk底层的bug,这个bug很神奇,删掉某一个字符就不再产生bug,于是为了提交测试用例我花费了两个多小时手动把数据缩到了1000(独享3分,值了)。尽管我对着他的代码调试了很久也没有弄懂这个bug到底是怎么产生的(产生原因大佬在他的博客里写到了!学到了!)。
第二次互测房间里异常安静,估计第一天过去后还没人提交bug大家也就不再测了,于是我在互测关闭前最后一次开奖爆了狼(过于不讲武德了,以后保证不会再这样了...)。这个bug的发现也很靠运气,毕竟之后我的测评机连跑一天也没能再次hack到这个bug。
第三次互测时hack到的要么是WrongFormat判断错误,要么是0次幂处理错误,但是这些都交不了...后来修改了一下测评机,自动忽略掉这些点,免得它让我吃不着干看着...互测时发现了一个很奇怪的现象,有一位同学似乎是使用生成的随机数作为HashMap的Key进行存储,这导致同样的数据在他那里每次的输出顺序都完全不一样!我也确实找到了他的bug,但根本不能定位!同一个数据这次运行会报Exception,下一次又能正常产生结果,这太魔幻了!
总结
比较幸运,三次强测只WA了一个点,也活过了互测的每个夜晚,只是过程有些忐忑。回过头来想,发现自己还是有些懒的,比如宁可花一个上午搭测评机,也不愿意花半小时通读一遍自己的代码来梳理逻辑,下一单元再这样子怕是要凉凉了。
互测时发现的bug也都是靠着自动测试黑箱,曾经试图寻找同学bug出现的原因,但每次看到一半自己就开始不知所云,一方面感觉对方的代码面向过程的部分有些多,另一方面也是因为自己做不到静下心来梳理逻辑。读懂一个规模较大的代码应该也是我们必须具备的能力,这也将是一个学习与发现自身问题的过程,希望自己能有所改进吧。