「BUAA OO Unit 1 HW4」第一单元总结 目录
Part0 前言
0.1 文章简介
为了让博客更容易被读者接受,我将尽可能地做到条理清晰地行文(文章总体框架可参考多级目录),同时尽可能以循序渐进的方式引出我的思路以尽可能降低读者的理解难度。
在我看来,写博客是一种非常好的知识输出方式,而知识输出不仅能够巩固自己的知识,同时很重要的一点是知识输出后可以创造更多的价值,即,可以供后来者在去除糟粕后参考学习。因此这篇博客我将以面向求知者的口吻行文。
本文主要介绍了以递归下降对字符串数学表达式进行多层括号展开与求导的思路,并介绍了一个简易的用python语言编写的自动评测机(数据生成器+结果评测器)的搭建思路。
0.2 阅读本文后将有的收获
- 对递归下降算法有一个较为清晰地认识
- 学会使用递归下降算法进行字符串表达式去括号、求导等操作
- 了解数据生成器(本文以生成字符串表达式数据为例)的构建思路,了解一个极简的自动评测机的搭建思路
0.3 阅读本文需要的前置知识
- java语法基础:如,正则表达式,类,接口等概念的理解
- 递归的算法思想(不需要知道递归下降是啥玩意)
- python语法基础:一丁点就够
(因为笔者也只有一丁点) - 要阅读过作业的指导书
Part1 第一次作业
1.1 程序框架
1.1.1 构建思路
在阐述程序思路之前,首先值得一提的是对空白字符,前导0,冗余正负号
的处理,这个的话我们可以定义一个方法String initExpr(String input)
,这个方法可以去掉空白字符+去掉前导0+去掉冗余的正负号(连续正负号/首项前的'+'
/ 指数中的'+'
/ 乘号后的'+'
/ 左括号后的'+'
)。
由于之前没有递归下降思想的积淀,所以也是在各种资料的帮助下思考了几乎一整天后才有了大致的思路,那么这里我就从第一视角出发,尽可能以循序渐进的方式来复现一下我的框架的成形过程。如下:(在阅读的过程中,若想了解提到的具体的类的定义,可以看后面的UML类图分析部分)
- 直接定义方法
String parserExpr(String expr)
,这个方法直接接收题干输入的一行表达式,然后返回处理好的字符串结果,这样的话我直接在主函数里调用一下这个方法处理input
,然后输出方法的返回结果即可(主函数非常简洁)。接下来我的任务就是去实现这个方法。 - 我以这样的视角来看待输入的表达式:
(表达式)**指数 或者 表达式
。也就是,将输入的字符串分类成这两个形式:带 或者 不带指数 - 若带指数((表达式)** 的形式),那么我们便提取出来指数,并令
BigInteger exp = 指数
(先存起来指数,后面用);例:(x**2+y**3)**2
若不带指数,那么我们便令BigInteger exp = 1
(没有显式地带指数,那其实也就是指数是1而省略了);例:x**2+y
- 提取出指数后,我们的思路就比较简单易懂了:对指数所“管辖”的表达式进行彻底地去括号操作,然后去完括号之后,不要忘记我们之前还存了一个指数,那么就要写一个字符串表达式乘方(这个很简单,就是多项式乘法)的方法来处理这个指数
到这里,我们的最顶层流程就已经处理完了,上述思路可以用如下伪代码表示:
initExpr(input);
/* input 是输入的String型表达式,这么看 (项1+项2+项3+项4+...)**指数
* 项1+项2+...即为指数的辖域,当然,如果没有"显式"的指数,那指数就是1,"辖域"也很容易确定
*/
parserExpr(String input) {
if (input 有括号) {
BigInteger exp = input中提取的指数; // 上面说过这一点
// 拿到指数后,接下来去掉指数“辖域”内的括号
ArrayList<Term> terms = getTerms(input); // terms盛放"辖域"表达式的所有项
// getTerms方法可以将input拆成各个项,可以自行实现
// 遍历所有的项,对每一项分别展开,那么就相当于整个表达式被展开了
// parserTerm这个方法我们后续会实现,也就是针对一个项,将其中的括号都展开的方法
String exprRes = parserTerm(terms.get(1)) + parserTerm(terms.get(2)) + ...;
// 将每一项展开的结果字符串拼接在一起,可以用循环遍历实现(这里只是伪代码)
// 展开后就到了考虑乘方的时候了
String exprNoPar = myPolyPow(exprRes,exp); // 计算表达式乘方的方法,很简单
}
// 没有括号后当然就该化简了,这个方法是尽可能地进行合并同类项等优化操作,在下文的优化部分会详细介绍
sortExpr(exprNoPar);
return result;
}
接下来,我们继续揭开递归下降的全貌。我们把眼光放到上面伪代码中的parserTerm
上。(myPolyPow
sortExpr
这样的方法,都不属于递归下降的核心,因此在这里我们并不聚焦于他们身上,如果不理解,就别管这里,先往下看)
5. 在有了上面的parserExpr
的实现基础之后,parserTerm
方法的引出就很自然而然了。几乎与前者的实现完全一致,只是表达式拆分项的时候是以+
为界限拆分,而项拆分其中的因子时是以*
为界限来拆分。给出伪代码如下:(相信有了前面的基础+伪代码的注释很容易理解)
/* 将一个项中的所有因子都去了括号,然后再把他们乘起来,就实现了去掉项中括号的目的 */
parserTerm(String input) {
if (input 有括号) {
/* 此处其实不用再管幂次,因为如果某个项是(...)**exp这样的形式的话,
* 那么它其实算一个"表达式因子",后续对因子的解析中会重新递归到对表达式的解析,
* 那时这个指数便可得到处理,暂时不理解的话先继续阅读即可
*/
handleNegOne(input);
/* 这句伪代码的意思是:
* 项的前面是可以添加一个负号的,如果这个项的前面刚好这么做了,
* 那么就将负号提出来,变成-1,不然会出bug(例: -x*y ---> -1*x*y)
* 可以理解为,这里我们把项看做一个个因子的乘积,但是负号本身并不是一个因子,-1才是因子
*/
ArrayList<Factor> factors = getFactors(input); // 可以自行实现一下拆项为因子的方法getFactors
String term = parserFactor(factors.get(1)) * parserFactor(factors.get(2)) * ...; // 这里变成了 '*' 号,实际实现时要用循环遍历的方式来实现
// 同时,这里的"乘号",是多项式乘法(因子被展开后可能成为一个多项式),要写函数来实现这个方法
}
/* 一个项进行去括号之后也可能变成一个多项的表达式,
* 因此这里其实也可以加一行代码调用一下表达式优化方法sortExpr,但是不调用也没事,
* 最顶层的框架里有这个方法就能保证最终的结果是经过了化简处理了的
*/
}
好,那么接下来就到了最后一步。为了让上面的方法能够顺利运作,还要处理一下parserFactor
方法,这也是递归的核心方法(别的不是递归的核心,所以不说,也容易实现)。
6. 对因子的去括号,也就是parserFactor
方法,比较简单理解,其实就是一个分类讨论的过程:因子分为 常数因子/幂函数因子/表达式因子 三类,要针对这三类分别做不同的处理,值得一提的是,只有表达式因子需要继续递归,而前二者都属于是递归的终点了,因为从形式化定义很容易得知,他们已经不再带括号了。
parserFactor(String input) { // 去掉一个因子字符串的所有括号并返回
if (input is belong to Number) { // Number 是常数因子类,也就是说input 是一个符合"整数因子"形式化定义的字符串
return itself; // 也就是返回它本身,因为整数因子本就不带括号
} else if (input is belong to Power) { // Power 是幂函数因子类(或者说"变量因子",一样的)
return itself; // 返回本身,原因同上
} else { // 如果程序走到了这一步,那么不是前两种因子就只能是表达式因子了
return parserExpr(input);
/* 注意: "表达式因子"和"表达式"是不同的,
* 只不过一个"表达式因子"可以认为是一个"项",从而认为是一个"表达式",
* 因此直接把input丢进用来解析"表达式"的方法中是没问题的
*/
}
}
至此,我们已经完成了如下图所示的递归闭环,这也就是第一次作业的递归下降的全貌了:
我们也可以由这张图体会一下递归下降中"下降"二字的含义:我们递归开始于最上层的
parserExpr
方法的调用,然后在程序执行的过程中,我们的目光是一直在下移的,即便是到了最下层的黄色框,而需要重新递归,但是最终程序会终止在两个红色框中,也就是递归的尽头。结合上面的图片,想象一下程序执行的流程,便可以对“下降”二字有着更深一层的体会。
1.1.2 框架评估
- 优点:其实第一次作业的输入括号是不允许嵌套的,但是上面的框架可以“免疫”括号嵌套的情况,这使得我们的后两次作业的工作量也有所减少。
- 缺点:缺点的话可能就是上述框架基本都是基于
String
实现的,字符串层级的操作可能容易有疏忽而导致较多的bug,有一种建立表达式树的做法,可能就不会这样。
1.2 性能优化策略
- 首先,我们要清楚这个优化是在我们进行完了所有的去括号操作之后再进行的操作,也就是说,我们的优化函数传进来的表达式是不含括号的。
- 去括号后,每一项都可以化成这样的标准形式:
标准形式 ---> 整数*x**a*y**b*z**c
(其中,a
b
c
表示一个不大于8的整数因子) - 接下来,我们通过以下几个步骤进行化简与合并同类项的操作:(这就是上面伪代码中我们所调用的
sortExpr
方法的具体实现)
step1:遍历每个项,将每个项化为上面所说的标准形式
。
step2:从前往后遍历项,设计算法(一个二重for循环即可,不难实现),将所有同类项(标准是:x,y,z的幂次相同
)的系数相加,得到合并同类项的目的
step3:最终将每一项化到最简。我们之前化为标准形式只是为了方便我们进行合并同类项的操作,但是标准形式却未必是最间洁的形式。因此,我们便要将他们化到最简,这有如下几个方面:-
系数
if (系数 == 0) 整项为0,直接不写入表达式中 // 0+任意东西 = 任意东西 else if (系数 == 1) 省略系数 // 1*x ---> x else if (系数 == -1) 只保留负号 // -1*x ---> -x
-
变量
x**exp
(以x为例)if (exp == 0) x**0 ---> 1; // 1直接省略不写,x**0*y ---> y else if (exp == 1) x**1 ---> x // 省略幂次 else if (exp == 2) x**2 ---> x*x // 写开,少一个长度 else 不作操作 // x**3 ---> x**3
至此,化简的核心思路便处理完毕,需要注意一点如果出现了类似这样的形式
1*x**0*y**0*z**0
每一个因子都是1,采取直接省略不写的策略的话,最后可能会得到一个空串,这显示是不符合要求的,这时候加点特殊判断就ok了。
-
1.3 UML类图分析
对这个类图进行一下必要的解释:就是
Expr
,Power
,Number
这三个因子类实现Factor
这个因子接口,然后Expr
中有Term
型数组,因此和Term
类相关,同时,Term
类中有Factor
型数组,因此和Factor
相关。
同时,主方法调用了解析器类中的方法,与之相关。而解析器中又主要调用了大量Methods
类中的方法,与之相关。
1.4 bug分析
1.4.1 Ta的bug & hack策略
这次的hack策略是,我发现了一个我自己的程序的错误数据点,然后就把这个数据点用来测房友了,就成功hack掉一个人了。
第一次作业狼王的心尚未成熟,只刀了别人一次,是这样的数据点:-(-27*x-3*x**5)**2
,它的程序的错误输出为162*x**6+729*x*x+9*x**10
,从他的输出来看,可能是对项前面所可能带有的-
的处理出现了问题。
1.4.2 我的bug
本次作业强测+互测中,出现了两个bug:
- TLE:高次方(数据点是5次方)运算,导致多项式的乘方运算超时。
- WA:忽略了次方数大于8的情况。尽管输入中,次方数不会超过8,但是在运算过程中是可以超过8的,但是我在程序中利用了不超过8这个特性去做相关的判断,因此出现了bug。
1.4.3 我的bug修复策略
- 对于TLE问题,我在进行乘方运算操作前调用了一下合并同类项函数,这样可以让作为"底"的表达式先被化简然后再乘法,成功解决超时的问题。
- 对于WA问题,我在对次方数判断的代码段处,将代码修改为对一串连续整数(之前认为指数只能是1位,且用了这个特性特判) 的识别判断,即考虑上了指数不在8以内的情况,从而顺利解决。
1.5 复杂度分析
在第一次作业中复杂度最高的两个方法是一对“双胞胎”方法,
stringToTerm
的作用是读取一个字符串型的项然后将其分拆转换为一个Term
型的变量返回。stringToExpr
的作用是读取一个字符串型的表达式,然后将其分拆转换为一个Expr
型的变量然后返回。那么为什么前者的复杂度更胜一筹呢?我感觉应该是因为在处理项的时候,需要进行因子类型的讨论,然后分别做不同的处理,由于因子的类型比较多, 所以导致这个方法的复杂度更高。
同时我们也可以发现,排前几位的方法的iv(G)
也是最高的,也就是说耦合度也是最高的。这也正是比较合理的。
Part2 第二次作业
2.1 程序框架
2.1.1 构建思路
- 本次作业的递归下降图
总体的框架呢,其实和第一次作业基本相同,但是由于本次作业新增了sin/cos
这两位爷,所以可能有些细节要进行一点修改,在列举这些需要略作修改的要点之前呢,我们首先先给出针对本次作业的全局递归下降图,并进行必要的解释:
这幅图中,绿色的部分是本次作业新增的部分。也就是在解析因子时要多一个判断分支,来处理三角函数因子的解析。那么对一个三角函数的去括号操作呢,其实就是对三角函数内部的“因子”(三角函数因子的形式化定义是:
sin(因子)
),进行一个去括号,或者说解析的操作。所以要重新调用parserFactor
方法来递归。 - homework1的基础上需要进行修改或添加的一些点
parserExpr parserTerm
伪代码中的if (input 有括号)
应该修正为if (input 有不必要的括号)
,因为由于三角函数的出现,表达式中可能需要含有必要的括号,只有当式子中存在不必要的括号时,我们才需要进行去括号的操作。那么如何实现这一判断呢?其实很简单,这里直接给出伪代码,大家可以自行理解一下:boolean hasNecePar(String expr) { // 方法的功能是,判断一个表达式是否仅含有必要的括号,或者说,是不是不含有不必要的括号 if (expr 没有括号) return true; // 都没有括号了,那当然属于“仅含必要括号”(没有括号当然也属于是不含有不必要的括号)的条件 else if (expr 有 sin/cos 之外的括号) return false; // 必要的括号只能出现在sin/cos的管辖范围内 else if (expr 仅有sin/cos之内的括号) // 不加if也行,不符合前两种情况的话就一定是这种情况 ArrayList<String> factors = getSinFactors(); // 把所有最顶层(也就是不管sin内部嵌套的sin,递归过程中自会处理)的sin中的因子拿出来 return hasNecePar(factors.get(1)) & hasNecePar(factors.get(2)) & ...; // 所有的顶层sin内部都仅有必要括号的话,才能说这整个式子只含有必要括号 }
initExpr
的初始化表达式方法中,要注意,,后面的+
也是冗余的,要去除(由于本次作业新增了自定义表达式,因此出现了,
)- 自定义函数的处理,由于这一点内容相对较多,我们下面详细讲一下。
- 自定义函数问题处理
这个要素引入后,其实处理思路比较清晰,就是把输入的表达式中的自定义函数全部都替换成具体的表达式,然后利用我们上面的递归下降图的思路进行去括号即可。这里,我们采用递归的方法来处理自定义函数:
同时,还要注意的就是对输入的自定义函数的存储方式,这一点并不难实现,简单提一下我的实现方式;deleteFunc(String expr) { // 这里为了方便,使用f来指代fgh if(expr 不含有f) { return expr; // 这个方法的目的就是把表达式中的函数都具体化,压根不含有的话当然直接返回 } // 遍历顶层f while (有未被处理的 expr 的顶层f) { // 所谓的顶层,是指不考虑嵌套在f里面的f,这里面的函数后续递归会解决 对顶层f中的每个参数,递归使用deleteFunc方法 // 去掉f内部的自定义函数 // 如:f(x,g(y)) ---> f(deleteFunc(x),deleteFunc(g(y))) // 经过上一步操作,我们已经使得f内部的每个参数都不含嵌套函数了,这时候我们就要把三个变量都代入 将表达式中的 "f(x,g(y))字符串" 替换成 "(" + 参数代入f函数所得字符串 + ")" // 加括号是为了保持替换前的因子性,也就是不能让替换后就不是因子了,这样可能出现bug // 这一个顶层自定义函数处理完毕,继续循环处理下一个 } }
这个东西的思维难度并不高,大家可以自行实现。class Function{ // 以 g(y,x) = x**2+y**2, 为例,下面注释中给出这个函数的各个属性的具体值,帮助理解各个属性的含义 private int varNum; // 存储函数自变量的数量 2 private String vars; // 存储函数自变量的名字,按照定义的先后顺序存储 "yx" private String funcExpr; // 输入的函数等号右边的表达式 x**2+y**2 private String funcName; // 函数名 g }
2.1.2 框架评估
代码框架将"去掉表达式中的自定义函数"和"去括号"的操作,泾渭分明地划分开,耦合度较低,这样可以方便debug,也降低了代码的复杂度,并且此种思路对于第三次作业的要求有着非常好的可扩展性。
2.2 性能优化策略
这部分由于引入了三角函数从而变得比较复杂。这里列举出来比较关键的一些三角函数优化点,这些点都做好之后,性能分就基本上可以拿到一个非常高的水平了。
-
sin(0)/cos(0)的处理:这个就是利用sin(0)=0,cos(0)=1,来进行优化,伪代码如下
while(expr 含有sin(0)/cos(0)) { // 为什么要用while循环,考虑一下sin(sin(sin(0)))这样的情况 将其对应换为0/1 } // 如果出现了sin((cos(0) - 1))的形式,可以在while的同时一边调用合并同类项的方法来对sin内部进行化简,以保证这种形式能优化到 0 这样的一个最简结果
-
合并同类项:
mergeExpr(Stirng expr,int isStop)
递归合并,先对一个表达式中的所有顶层sin/cos的内部进行合并(这个操作是调用了mergeExpr
方法自身的,也就是为什么会递归),将所有顶层的sin内部因子合并处理完后,就可以对顶层的表达式合并了。这里可以采用这样的数据结构,将一个项内的所有因子存进一个HashMap<String,BigInteger>
,HashMap的key
的含义是“类”(也就是一个项字符串中除去系数后剩下的部分),HashMap的BigInteger
的含义是“系数”,这个很容易理解。同类项的判断:将一个项中除了常数外的所有因子都存进
ArrayList<String>
中,然后利用其Collections.sort()
方法,它会将列表中的元素按照字典序进行排序,然后直接比较两个排序后的数组是否相等即可判断是否是同类项。(当然,字符串层级的比对可能会将两个本该是同类项的项误判从而削弱优化效果,但是却也能取得一个不错的效果,如果想更严谨的可以尝试实现一种算法让所有恒等的式子都以一种固定的样子呈现,这样的话字符串比对就没问题了,我没有实现,但是身边的同学应该有人实现)
3. 三角函数符号优化:首先给出一般形式sin((-x))**奇次 = -sin(x)**奇次 ; sin((-x))**偶次 = sin(x)**偶次 cos((-x))**任意次 = cos(x)*任意次
根据上述列举情况来查找表达式中同样的形式进行处理即可。
注意,sin/cos的内部有可能是一个表达式因子,这时候要具体地来讨论一下要不要用这个化简的操作,因为有时用这个操作就会起不到化简的作用,比如:
sin((-x+y))**2 = sin((x-y))**2
,这是可以起到化简的作用的,但是:
sin((-x+y))**3 = -sin((x-y))**3
,这是起不到化简的目的的,奇次方不能消去sin的括号。
4. 三角函数公式优化:项间:sin(x)**2 + cos(x)**2 = 1; 2*cos(x)**2 - 1 = 1 - 2*sin(x)**2 = cos(x)**2 - sin(x)**2 = **cos((2*x))** 项内:2*sin(x)*cos(x) = sin((2*x));
对于上述公式的化简呢,其实都要涉及到一种统一的操作:
识别出某一项中是否含有某个因子(不一定是最小不可再分因子,比如sin^3中也算含有sin^2这个因子,因为可以拆出来)
那么这个操作如何实现呢?我们可以采取这样的数据结构:
所谓的特殊因子,是针对于某一个具体的优化的式子而言的,特殊因子就是这个式子的核心部分。比如我们想要化简平方和公式的话,那么sin(x)**2 和cos(x)**2就是特殊因子,如果化简二倍角公式的话,sin(x)和cos(x)就是特殊因子。
相信大家看上面图中的例子也能看懂和这个数据结构了,在此的基础上,我们如何实现具体的优化呢?还是举一个具体的例子:
以优化sin**2+cos**2 = 1为例,来优化下式:
我们说过,特殊因子是针对于具体的优化等式而言的,那么对于这个等式,我们的特殊因子就是:sin²和cos²。y*sin(x)**3*cos(x)**2 + y*sin(x)**5 step1:从前到后find一个含有特殊因子的项,为y*sin(x)**3*cos(x)**2;锁定其中的一个特殊因子,是sin(x)**2 (可以从三次方中拆出来) step2:针对上面的特殊因子,find别的项中含有和它匹配的特殊因子(cos(x)**2)的项,发现是找不到的 step3:再锁定第一个项的第二个特殊因子cos(x)**2,并寻找带有与之匹配的特殊因子的项,可以找到y*sin(x)**5中是含有sin(x)**2的 step4:比较两项,除去特殊因子后剩余的部分是否相同。是相同的,则现在已经找到了精确的化简目标,进行后续化简操作即可。
-
x**2 = x*x优化
这个其实在第一次作业就已经有了,那么为什么要再次拿出来呢?这是因为我们要注意在加上三角函数这个因素后,我们应该要避免出现这样的情况:sin(x**2) = sin(x*x) ,因为这是不符合形式化定义的,三角函数内部必须得是因子。 -
最少必要括号优化
指导书中所谓的“必要的括号”其实有的也是不必要的,比如:sin((x))和sin(x)是一样的,而且都是合法的,这时候我们就可以把前者变成后者以尽可能地减小长度。
这个的实现比较简单,写一个方法(可以利用正则表达式的工具)进行这种形式(一定是sin/cos右边连续套俩括号的情况才会不必要)的搜索即可。
-
以上基本就是几点比较常见的优化点,要注意这个的优化可能比较杂,如果不确定各个优化方法的调用顺序的话,可以在优化的时候反复调用某些方法(如:在对sin(0)/cos(0)的优化中,我们可以一边优化一边调用合并同类项函数对sin/cos内部的因子进行化简处理),以使得各个方法相互配合来起到尽可能大的优化效果。
2.3 UML类图分析
在第一次作业的类图的基础上来补充说明一下hw2的UML类图。首先,左半区域新增
Triangle
类,是三角函数因子,实现Factor
因子的接口。由于自定义函数的引入,我又加上了一个类Function
,也就是自定义函数类,来专门处理自定义函数,然后该类的方法主要由Scan
调用以处理输入的自定义函数。而Scan
类是因为荣老师在课上说了把“输入、数据处理、输出”这三个操作分开处理的好处,也建议我们这么做,所以本次作业专门新增了这个输入类,其主要在主方法中起作用,因此存在由主类到该类的箭头,表示直接相关。
2.4 bug分析
2.4.1 Ta的bug & hack策略
本次采取的hack策略还是自己想一些可能错的情形(具体是什么样的情形下面会说),然后手捏数据点。
这里只列举部分有代表性的数据点。
hack1:
0
2*sin((-x))**2 // 这个hack数据的灵感来源于本次作业新增了一个必要括号的要求,这个刚好有一个人把必要的那一层括号去了,变成了sin(-x),因此因不合法而错误
hack2:
0
(sin(1)+sin(2))**2 // 这个数据测出了房友优化优化g了的bug,把展开后的4个项合并为了一个项,肯定错
hack3:
0
sin(0)**0 // 这个的灵感来源于0**0=1的规定,这次引入了三角函数,就可以给0换个"衣服"(sin(0)),来看看还能不能正确处理,结果有一个人的程序便得出了0这一结果,明显错误了。
2.4.2 我的bug
本次作业只有一个bug,就是我一个用来解析字符串表达式形式的方法的一个条件分支出现了bug。
将(x+y)**2+cos(x)**2
这种情况其实并不属于整个表达式被一个2次方所管控,但是我在判断的时候只进行了这样的形式的判断:(任意内容)**指数
,我忽略了两边的括号有可能不是相互匹配的一对括号这一点。
2.4.3 我的bug修复策略
修复的话,为了让代码更干净,我把这个if分支直接拿一个方法去判断了,在判断中,我除了识别上面所展示出的形式之外,我还加上了两边的两个括号是相互匹配的
这一个判断准则,然后成功解决bug。
2.5 复杂度分析
复杂度排前两位的依旧是第一问的两个方法,便不再赘述。本次作业新增的比较复杂的方法主要是
deleteFuncExpr
,这是由于第二次作业新增了自定义函数这个需求,而这个方法就是来处理自定义函数问题的。这需要进行递归去除自定义函数的方法,因此复杂度比较高。同时,我们发现第一次也存在的shortestTerm
方法在本次作业中复杂度变高了,这是因为本次作业加上了考虑三角函数的优化,因此这个用于优化的方法复杂度变大。
Part3 第三次作业
3.1 程序框架
3.1.1 构建思路
3.1.1.1 求导处理
本次作业其实相对来说是比较简单的(有前面的基础)。
在我看来,它的本质可以理解为四个字:处理输入。
这四个字可以从以下两个方面理解:(这两个方面几乎是本次作业新增内容的充分必要条件)
- 输入函数定义式
这一输入新增不同:函数定义式中可以调用其它自定义函数
,函数定义式中等号右边可以出现求导算子
- 输入待去括号表达式
这一输入新增不同:表达式中可以出现求导算子
那么我们显然可以知道,我们只需要将输入预处理(定义函数式展开重复调用、展开求导;输入表达式展开求导)一下,这样处理过后的输入便将和我们第二次作业的输入完全相同,这样的话直接把处理后的输入怼到第二问主体代码中相应的输入端中去,然后剩下的部分不需要做任何改动(优化也与本题的新增条件无关),本次作业便完成了!
如果文字不够直观的话,这一想法还可以用下面的图表示:(核心在粉色五角星所在的粉色边框内)
我们需要认识到下面几点:
-
符合第三次作业的要求的输入,经过预处理,可以完全转换为符合第二次作业的输入。且具体的化简步骤与第二次作业相同。
也就是我们只需要实现粉色框内的橙色长方形,则本次作业即可解决!
-
预处理中,要按照 先
去除fgh这三个函数调用符号(也就是“代入”)
,再去除求导算子
的顺序进行。很容易明白。 -
这种思路不需要考虑指导书中说的""输入有h(x) = dx(x)时,h(sin(x))是先去掉h再算导数还是先算导数再去掉h"这种情况,因为我们预处理后的函数定义式中不再存在求导算子。
下面,我们解决求导、定义函数可调用其他已定义函数这两个问题,这两个问题解决后,我们的输入便可顺利得到预处理。
循序渐进地理解求导:
求导是处理自定义函数和输入表达式的共同基础,我们先解决求导问题。
我们一步步地构建出直观递归图
很自然地,我们会想到定义一个方法(不妨起名为derAll
),这个方法可以接收一个表达式
字符串(我们这里认为是不含函数调用的表达式,当然也可以含,这无关紧要,相差一个函数调用的代入而已)作为输入,输出这个表达式去除其中的求导因子后的字符串。
这个方法的实现思路比较容易想到:
- 找到输入表达式中的
dx
(当然,dydz也是,这里都用dx表示) - 将
dx(表达式)
这个部分的字符串替换成去除求导因子后的结果 (在这里,我们调用一下我们hw2已经写好的去括号方法对dx内的部分去括号然后再处理,这一步的目的是把一个玩具(表达式)拆卸成最小零部件(最小因子)的集合,这会为我们顺利递归服务)
关键在第二步中的去除求导因子,那么我们便可以再定义一个方法derExpr
,这个方法接收两个参数待求导表达式因子,求导目标变量
,返回求导结果字符串
,那么我们的递归图可进一步延展:
我们知道,表达式是这样的结构:expr = term1 + term2 + ...
,那么我们为了对表达式求导,我们只需要识别出表达式的各个项,逐项求导再求和。为了逐项求导,我们定义derTerm
方法,能够接收项字符串,求导对象
,输出求导结果字符串
(这里不需要(当然,多加一层括号也无妨)保证返回的结果为因子,因为这个方法只会在derExpr
中被调用,而derExpr
最后返回时我们保证因子性)。
同时,项的结构是term = f1 * f2 * ...
(f
是factor
的简写,而非自定义函数名),那么我们对项求导,也就是这样的一个过程(以三个因子为例):dx(term) = 常数(只要不含求导对象就认为是常数) * (dx(f1)*f2*f3 + f1*dx(f2)*f3 +f1*f2*dx(f3))
(对某一因子求导,其他因子都不导,全部加起来)
那么我们又需要能够对因子求导,因此便需要有一个derFactor
(对因子求导)的过程,因子有多种,我们需要分类讨论。
这里,可能出现的因子分为:三角函数因子,幂函数因子,表达式因子
(常数部分在前面已经提取出,因此不会出现整数因子什么的)。
基于此段,我们将递归图进一步扩展:
我们将幂函数因子
用紫色框框住,因为我们不难想到,对幂函数因子
求导是递归的尽头!
除了幂函数外,还有:
1、三角函数因子:其内部因子可能出现的类型为——三角函数因子,幂函数因子,表达式因子
(不含整数因子,因为在derTerm
中我们已经将所有不含自变量的因子剔除出去了)这三种再下层因子,我们都已经讨论过,他们三个的行为分别是:递归调用三角函数求导方法/递归的尽头/递归调用表达式因子方法
(递归调用我们用红色的线条表示)
2、表达式因子:对表达式因子的求导我们已经在最开始的时候讨论过了,这里再次出现,便是递归的体现,它需要重新去调用这个图构建的起点部分。(递归调用我们用红色的线条表示)
因此,我们可以对递归图进行扩展:(derTri
是解析三角函数因子的方法,derPower
是解析幂函数因子的方法)
最后我们可以随心加一些递归尽头(图上紫色部分),即在处理相应的对象时,如果此对象不含求导对象,那么直接返回"0"
即可,不需要做无谓的递归了。
再次强调一下三条红线代表的是递归调用。
在构建到这一步时,我们的这张图已经完成了闭环
,成功闭环也意味着我们的工作已经完成了。
根据这个图,我们也可以对所谓的递归下降法
有一个更深入一些的理解,从这个图的最顶端给一个输入后,程序在运行的过程中,我们关注的焦点是在不断向下走的,也就是下降
,即使因为递归调用函数又会回到上面,但是最终的归宿一定是这个图的最底端(递归终止处)(类似于小球经历磕磕绊绊地路途后最终还是会因为重力落到最底端)。
相信明白了这张图后,程序的具体实现就不是什么困难的事情了。
注意,我们应认识到:
-
维护方法调用前后字符串的“因子性”,也就是说当方法接收一个因子时,我们尽量让它返回的字符串也是一个因子类型(可通过返回字符串两边加括号等方式实现),否则便可能出现把返回结果替换后因为不再是因子了,运算优先级导致出现bug的情况。
-
别忘了三角函数因子也是可能有指数部分的
-
derFactor
其实只是起一个分类作用,将因子分类,然后让他们去进行对应处理,可以不用实现而在derTerm
中完成因子的分类工作(当然,实现了derFactor
也没事)
3.1.1.2 自定义函数处理
注意,我们的指导书说只能调用已定义函数,因此对于输入的每一个函数式子的处理,不需要考虑在其后输入的函数。
这个问题很简单,如果我们之前实现过方法deleteFunc
(将一个表达式中的函数调用全去掉)的话,只需要对函数表达式先用一下这个方法,再用一下我们上面已经实现了的表达式去除求导因子的方法derAll
(这个表达式中不一定有求导因子,没有的话我们的这个方法原形返回就可以了),便完成了处理,变成第二次作业的输入的形式了!
核心代码几乎只有这一句。
但是要注意会有一个相对比较隐蔽的bug:f(dx(…)),在将dx代入到函数f的表达式中时,有可能会出现求导因子不止一次出现的情况,对于这种情况,我采取的解决方案是在derAll
用while循环来支持处理多个求导因子出现的情况。
3.1.2 框架评估
本次作业的框架思路还是很清晰的,而且将本次作业的新增内容与第二次作业的程序几乎完全分开,这避免了迭代时修改前面已经写好的程序而改出bug的情况。
3.2 性能优化策略
本次作业并没有增加新的元素,每一项还是由常数*x幂次*y幂次*z幂次*若干个三角函数因子
的形式构成。因此优化策略和第二次作业是一模一样的。不需要修改。
3.3 UML类图分析
我们在第二次作业的基础上对第三次作业的UML类图进行说明。本次作业新增求导的操作,因此我们新增一个
Derivation
,专门来处理求导。而求导在Scan类处理自定义函数时要使用,因此输入与求导"直接相关"(存在箭头)。同时,在主方法中,我们要调用一下求导类中的求导方法来处理输入的表达式,所以也存在主类和求导类之间的直接相关。
同时,我们这次作业将优化方法集成到一个类SimplyExpr
中,然后在对表达式的括号解析完毕后要进行优化,因此存在解析类和优化类之间的直接相关。
3.4 bug分析
3.4.1 Ta的bug & hack策略
这次用了科技手段来发现别人的bug。就是我写了一个自动评测机(后面会写),用这个评测机拉取同房间的7个程序一起对拍,然后用电脑挂着一直跑,就这么来找bug,要做的事只有从error.txt
文件中挑bug数据就可以了,注意不要刀多了,避免同质bug被处罚。
除了用自动评测之外,我还用第二次作业的强测数据区测房友的程序,也测出了一些bug。
具体发现的一些代表性bug列举如下:
hack0: // 这个测出了一个人的程序空指针异常
0
((y**2*y**2-4)**2)**2
hack1: // 这个的话一个人的程序少输出了一个*cos(z**2),我猜测应该是他的程序复合函数求导的部分写错了
0
dz((271+cos(z**2)**2*x**3)**1)
hack2: // 这是第二次作业的强测数据,测出了一个人的NumberFormatException异常
3
f(x,y,z)=z**2-2*x*y
g(x)=+2-cos((2*x))*+2
h(x,y) =12
f(g(x),sin(x),1)-(g(x**2))**2
hack3: // 一个人的程序输出了1,hack时发现,他的程序应该是对“自定义函数有平方,然后平方的底传进了一个常熟”这样的情况的处理出现了bug
1
g(x)=-x**2
g(-1)
使用上述测试的策略,发现了全房6个人或多或少的bug,少的可能评测机跑几千条数据才会撞到bug点,多的评测机跑几条就能遇到bug点,效果还是很感人的。
3.4.2 我的bug
这次作业只有一个bug。就是我上面提到的,我忽略了h(dx(x))
这样的形式,即将dx作为自定义函数的自变量带进去之后可能有求导因子多次出现的情况,我处理时认为只会出现一次,从而出现了bug。
3.4.3 我的bug修复策略
修复策略就是改动derAll
函数,原本是利用正则搜索求导因子,
if (matcher.find()) // 用if,是因为我当成求导因子只出现一次处理了
求导;
然后改成了:
while (matcher.find())
求导;
用while后,就可以处理求导因子多次出现的情况了,从而成功解决bug。
3.5 复杂度分析
本次作业新增的复杂度最高的方法基本就是为求导服务的方法了,而在这其中
derTri
的复杂度又最高,这是因为对三角函数的求导相对幂函数这种还是相对比较复杂的,需要进行递归的操作。除了求导之外,新增的比较复杂的操作,比如SimplyExpr.mergeHasTri(String)
,这是由于本次作业加强了优化力度,从而出现了因为优化而产生的复杂度较高的方法。
Part4 python自动评测机
4.1 数据生成器
这个数据生成器使用java写的,因为用java比python更熟练一些。下面给出数据生成器生成表达式的框架图;
该图应该按照从上到下,从左到右的方向阅读:
我们的递归终止条件要么就是执行到了上图中红色字体的框框处,要么就是depth == 0(depth可以理解为递归的深度),在depth参数不断地递归传递的时候,让其递减减1,使得其最后变为0而终止递归。
在有了捏出来一个"表达式"的能力后,我对于求导因子的处理是:直接在整个表达式的最前面加上求导号表达式 ---> dx(表达式)
,因为我考虑到这样的话可以把求导号直接分配进各个项中,同样可以做复杂的求导操作来验证求导操作的正确性。但是这样的话其实忽略了dx出现在括号之内
的情况,不过这个对我的测试却没有起到太大的影响。
更重要的是,由于时间原因,我没有生成自定义函数,这是因为我“自认为”自定义函数已经经历过第二次作业强测互测检验的毒打了,应该不会错了。但是我的bug就是由自定义函数+求导因子
的组合导致的,所以这导致我的评测机没有发现我的这个bug,这也提醒我们在捏数据的时候一定不要抱有侥幸心理。
4.2 自动评测机
这个用python编写,其实也可以说是对拍机。
这个的话我们从两方面来评测:
-
括号嵌套合法性:(递归判断,就是看看"有没有出现在sin/cos之外的括号")
这个应该很好理解,就是判断是不是有不必要的括号,必要的括号的话一定只能出现在sin/cos之内。
-
式子逻辑正确性:(就是对拍,可以拉取互测房中的其他所有人的代码.jar代码来对拍)
只说明一点,就是之所以要对各个程序的输出结果进行
simply
函数的化简,是因为这个函数会把一个字符串表达式按照一定的规律(如:升幂,当然这个只是为了帮助理解一下这个“规律”的含义)排列,这样的话就为我们直接用字符串相等来进行判断打下了基础。
但是房间中如果有一个大佬化简地特别彻底,那么就可能会出现他的程序的化简结果总是和别的人的不一样的情况,这时候可以用为自变量取一些具体值代入各个程序的结果算出函数值(用python中的eval
函数),通过判断函数值是否在误差允许范围内相等的方法来判断两个表达式是否恒等,这就避免了字符串比对误判的问题,这也就是上图中所说的"抛针"。
Part5 心得体会 & 反思
5.1 心得体会
首先体会的话就是oo这门课实在有愧于3学分这个数字,不论是任务量的要求还是对心态的要求都是比较高的。尤其是强测这块,想要在不提供强测的情况下全面测试出自己程序的bug是一个非常有挑战性的事,这也更让我体会到了“有时候发现问题比解决问题困难地多,当问题被发现后它就已经不是问题了”。
其次,我也非常欣慰于我能够自己编写出数据生成器+自动评测机了,以前我对这一块一直是“望而生畏”的而没有真正认真地去动手实践过,所以这次的评测机编写对我的测试能力有不小的提高。
5.2 反思
- 我的数据测试做的不够完美,在测试时心存侥幸:自以为我的某个模块不会错而没有去测试(一方面也是时间不够),但是我的bug偏偏就是在那个功能上出现的。
- 我自认为自己的代码比较乱,并没有用具体的设计模式去组织。这使得代码的可读性并没有那么好。
- 我写作业的时候存在“拖延”的情况,心想:我先把别的事做一下,最后ddl要来的时候必然会倒逼自己提高效率。但是实则是到ddl末尾的时候,很可能只是堪堪够de一下显式bug,而没有时间做充分地测试。
Part6 未来展望
对未来的展望是基于我对过去的反思生发而来的。
- 不拖延oo作业。这样的话能给我尽可能充分的时间去做测试,尽最大可能地去避免强测起飞的情况。
- 注重代码质量。学习单例模式、工厂模式等常见设计模式的设计思想,用设计模式来规范自己的代码。
- 在编写测试数据时,杜绝侥幸心理。不要认为自己的某个模块一定没有问题了而不需要测试,因为即便是这个模块本身没出现问题,但是它与别的模块结合起来却出了bug。(如:求导与自定义函数调用结合)