301
社区成员
发帖
与我相关
我的任务
分享
本次作业思路绝大部分来源训练代码。
训练代码主要干了三件事:
拆解表达式token
递归解析
化简
输出
前面三步基本上都能套训练代码,所以问题的关键便在于如何化简,或者说如何计算。
如果是面向过程编程,可能需要写很多方法,并且按情况讨论:常数×常数,常数×变量,变量×变量,常数×表达式……
然而,要想简化计算,在这里希望将待计算的对象统一,这样只需写少量方法,即可适用于所有计算。
本次作业规定的形式化表述限制较多,因此可以将变量因子和常数因子写成S M * x ^ E的形式,统一申明为Powerfun类,其中S为符号,对于变量因子M=1;对于常数,E=0。
对于表达式因子,去括号操作后依然可以转为变量因子和常数因子。
本次作业还有一个需要格外注意的问题是纷繁复杂的符号。
对于-1-2的式子,将其视为-1+ -2,可以发现实际上Expr和Term并不需要符号的概念,也不需要记录Expr中的加减运算符,因为所有的符号都落在S中,进而可以默认Expr的连接方式都是加法。
可以这样修改递归解析的函数。
parseExpr()伪代码
boolen tag = true;
//如果一开始识别到符号,直接读给Expr
if ("+" || "-") {
next();
if ("-") {
tag = false;
}
}
getFirstTerm();
//如果之前读到负号,则改变第一个Term的符号
if (!tag) {
FirstTerm().vertSign();
}
parseTerm()同上
由于Expr、Term、Powerfun都是Factor的实现,所以可以直接给Factor接口加上vertSign()方法。
Term的vertSign()指递归调用第一个Factor的vertSign()。
Powerfun的vertSign()是翻转sign属性。
Expr有些特殊,要调用他的vertSign()方法实际上由是Term递归下来的翻转表达式因子,因此要对其中每一个Term再递归调用翻转,而不是仅翻转第一个Term。
问题
在测试最初版代码时,发现针对部分表达式会出现速度极其缓慢甚至内存溢出的情况。互测中发现部分同学也存在同样的事情。虽然由于数据的限制应该不会在这个方面卡住,但还是值得优化的。窃以为问题大多数出在计算上,因为本人只是修改了计算的逻辑程序性能就得到肉眼可见的提升。
最初思路
最开始,我计算的方法是将所有的式子展开,如x^2会展成x*x,而(x+1)*(x+2)*2就会先展开成x*(x+2)*2 + 1*(x+2)*2,再展开成x*x*2 + x*2*2 + 1*x*2 + 1*2*2。虽然这样可以保证思路的简单粗暴,但是浪费了太多空间和时间。并且如果按照这样的思路展开,项展开后可能是Powerfun((x)->x),也可能是Term((x)^2 -> x*x),还有可能是Expr((x+1)*2 -> x*2+1*2),还是很复杂的
优化
经过zyt佬的指点后,发现更优的做法是新建一个Poly类,该类包含Powerfun的集合(本人的Powerfun应该和部分同学设计中的Mono相同)。这样只需要给Expr、Term、Powerfun都增加toPoly()的方法,将复杂的类型转换转为同一种Poly。经过层层调用,最后会得到唯一的一个Poly,再调用其toString()方法即可。
实现方法
运算的基础是Powerfun,所以其需要有加法和乘法两种运算方式。
在实现的过程中,我给Poly也加上了“加法"和乘法两种运算,这里的加法指合并两个Poly,而乘法指两个Poly中的Powerfun一一相乘再存到新的Powerfun中。
这样,Expr的toPoly()就是调用Term的函数再相加、Term则是调用Factor的函数再相加、Powerfun是直接创建新的Poly对象。
其中还有一个值得注意的问题是Poly的AddPf()方法需要检查该Poly中是否已有指数相同的pf,如果有,直接相加替换。
这个是拿到的时候最纠结的部分。关于如何实现自定义函数,设想了很多方案,敲定一种后也反复前后摇摆。最终选择是建一个类Define用于存储函数的定义
private static HashMap<String, ArrayList<String>> name2Paras = new HashMap<>();
private static HashMap<String, String> name2String = new HashMap<>();
利用正则表达式解析函数定义
public static void addDefine(String input) {
Pattern pattern = Pattern.compile("([fgh])(\\([xyz])?(,[xyz])?(,[xyz])?(\\)=.+)");
Matcher matcher = pattern.matcher(input);
if (matcher.find()) {
...
}
}
这个类核心功能就只有这两项,起到存储记录的作用。在解析目标表达式时,按照解析因子的方式解析实参,存成Deffun类。该类存有形参和实参的对应,并且有替换函数进行字符串层面的替换。
然而,替换的过程并没有在解析字符串层次上进行。再次明确整体架构:
parse
toPoly
toString
本次作业中选择将替换放在第二步,也就是toPoly的层次。当递归下降到Deffun的toPoly方法时,先进行替换,再当成新的表达式进行解析,最后才调用Expr的toPoly。这样做的好处是,实际上并没有新增很多方法,仅仅实现了信息的管理和简单的替换,而解析和打印功能都可以直接调用第一次作业的方法。
在具体实现上,为了避免一些隐性的问题,传参的时候手动添加括号。
问题
刚开始以为指数函数相对容易实现,看起来直接更改基本项的结构即可,然而实际实施发现指数的因子会造成很多问题。指数的因子最“大”可以是表达式因子,所以基本项中增加类型为Poly类型。然而,第一次作业中为了优化方便,在给poly中添加新的meta时,会先遍历搜索全部meta,检查是否有可以合并的。在这次作业中,为了添加meta,需要比较exp的poly是否相同,而比较poly是否相同,最直观的方式就是比较每个meta是否相同……似乎到这里遇到了死循环,并且poly中挨个比较也并不是很容易实现的事情。
优化
核心问题还是如何判断meta中的poly是否相等,其实等价为两个poly相减是否为0,而相减其实就是改一下相加的方法,进一步就是更改可加的判断。判断两个是否可加,可以先从最基本的情况开始,如是否为单纯数字,或单纯幂函数等,最后才相减判断是否相等。这样就可以保证最终一定会到达调用底部,并且完美地解决了判断相等的问题。
细节
有几点在实现的过程中需要注意,首先是自定义函数的传参。由于我初始自定义函数传参逻辑是:挨个查找每个字符,如果这个字符是形参(x,y,z),就替换成对应的实参。然而对于指数函数的exp,如果不加处理会出现e(x^2)p这种令人小脑萎缩的情况。
以及在化简的时候,对exp里的内容需要进行删括号的操作,最初我忘记了符号问题,仅仅记得判断exp((x))要改成exp(x),而忘记了exp((-x))的括号不能去。
本次作业整体相对简单,然而由于第二次作业里存在一些历史遗留问题导致花费很多时间修改之前的代码,具体问题留在测试部分细说。
本次作业中对于自定义函数的限定放宽,现允许在定义中出现之前已经出现的定义函数。
再次感谢课程组提供的实验代码,求导功能的实现依旧来源于实验代码给的启发。在解析中如果遇到求导因子,则将需要求导的表达式存起来,在toPoly的时候再迭代下去求导。由于几乎所有基本结构都是Factor的实现,所以也就是给Factor新增derive()方法,加起来也很方便。
方法
| method | CogC | ev(G) | iv(G) | v(G) |
|---|---|---|---|---|
| Total | 198.0 | 127.0 | 202.0 | 218.0 |
| Average | 2.475 | 1.5875 | 2.525 | 2.725 |
类
| class | OCavg | OCmax | |
|---|---|---|---|
| Deffun | 1.5 | 5.0 | 12.0 |
| Define | 1.75 | 4.0 | 7.0 |
| Derive | 1.0 | 1.0 | 5.0 |
| Expr | 1.4444444444444444 | 2.0 | 13.0 |
| Lexer | 2.6 | 5.0 | 13.0 |
| MainClass | 2.0 | 2.0 | 2.0 |
| Meta | 1.5294117647058822 | 5.0 | 26.0 |
| Parser | 4.7 | 9.0 | 47.0 |
| Poly | 3.1333333333333333 | 10.0 | 47.0 |
| Term | 2.1666666666666665 | 5.0 | 13.0 |
| Total | 185.0 | ||
| Average | 2.3125 | 4.8 | 18.5 |
类结构

方法

这几次作业实现中除了一些常见的若智bug,还有一些困扰我很久也很有价值也很难de的bug
在第一次作业实现中,我发现一个奇怪的事情。在符号处理的时候,对于Term类型,我想要翻转第一项Factor的符号,然而宏观上发现并没有更改。逐步debug发现并不是没有更改,而是所有项的符号都更改了,进一步查看,所有项的地址竟然相同。
是深浅克隆问题!在写的时候我其实注意到深浅克隆的问题,甚至为了避免浅克隆自己写了一个myClone方法,没想到实际上所写的克隆方法也是浅克隆……
后来在研讨课上发现也有同学在整个实现过程中没有深浅克隆的问题。归根结底还是自己对象太乱了,比如如果两个meta可以相加,应该直接返回一个新的meta,而不是“把一个加到另一个上面”。并且有的计算是更改对象本身,有的计算又是返回新的对象,容易乱套。
第二次作业中出现了更加离谱的事情,即每次调试时结果不一样。后来发现是因为IDEA显示结果实际上是调用toString方法,而如果在toString中会更改对象内容就会造成调试结果不一致的情况。这也警戒在编写的时候要慎重考虑修改对象,尤其是toString这种帮助调试的方法不应该改变属性内容。
第三次作业倒没有出现de不出的bug,但是因为一点设计导致改了很多。为了简化(也可能根本没简化到),在写的时候我给poly写了一个isEmpty()的判断方法,条件是其因子为空或为0。这样两个多项式相加且发现其中一个为空,就直接返回另一个多项式(这里其实保险来讲还是应该新建)。然而在乘法中我也这样做了,在第二次作业中神奇的通过了测试,这个0*x=x的bug居然在第三次作业才暴露。然而暴露的时候我已经基本上写完了,并且发现自己一直将poly为空和poly等于0混用,导致会在各种奇怪的地方发生奇怪的事。
面向对象应该讲究的是低耦合,显然这个方法没有达到这个要求。在写的时候还是应该分清楚对应的情况。
本次测试由于技术原因并没有搭建测评机,倒是写了一些辅助打包jar和统一喂数据等程序方便互测。测试主要还是参考了各位大佬搭建的评测机,然而发现这些评测机在数据构建上或多或少都存在一定限制,并不能完全排除问题。在互测中被各位佬奇妙的数据hack后,发现测试重点还是应该放在题目约数分析和特殊情况上。
在互测中,我会将自己出过错的测试数据喂给其他人观察结果,偶尔会发现同质问题。我还会阅读他人代码,将逻辑稍显混乱的和if...else较多的部分进行额外的检查和构造数据。
心得
还是要尽早开始写,写了一份出来要debug,如果架构不好还得重构……
在完成某项功能的时候需要主动转换思维方式,思考如何从面向对象的角度更优雅地解决问题。如本次作业中每一次想要自己手动分类特判的时候都发现交给递归下降是更好的选择。
评测机还是方便的,自己需要学习搭一下。但是也不能完全信任评测机,评测机的测试强度和编写者有很大关系,如果作者不认为某个点是需要额外注意的部分,而你正好在这里错了,就会发生惨案。
建议
对部分数据放宽性能限制,避免不必要的性能内卷ono