301
社区成员
发帖
与我相关
我的任务
分享读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的单变量表达式,输出恒等变形展开所有括号后的表达式。
需求如下:
受到第一次实验的代码启发,实验中使用递归下降解析表达式后输出为后缀表达式,后缀表达式其实是更加符合计算机的计算方式,相当于将表达式处理成表达式树而后只要顺着树依次计算即可。故将整个任务分成了两个部分,第一部分负责将表达式解析存储为各个对应的对象(Expr,Term等),调用自己重写的toSring方法化成后缀表达式(字符串);第二步负责将后缀表达式(字符串)中的元素依次取出转化成多项式对象(Poly)再调用Poly类中写好的运算方法计算出最终结果。
整个流程可用main类中的方法简单明了的概况。
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String input = scanner.nextLine();
Lexer lexer = new Lexer(input.replaceAll(" ", "").replaceAll("\t", ""));
//System.out.println(lexer.getInput());
Parser parser = new Parser(lexer);
Expr expr = parser.parseExpr();
//System.out.println(expr);
Poly anwser = parser.simplify(expr);
System.out.println(anwser.toString());
}
}
个人感觉最难的其实是第一次的从无到有的构造。第一次作业最重要的其实就是领悟“递归下降”的表达式解析方法,由lexer负责提取基本语法单元返回给parser,而parser要做的其实很简单,parserExpr()就只需要对内部调用parserTerm调用Expr的add方法加入第一个项,然后pos指针往后移动如果遇到符号则添加符号,继续移动读取Term添加Term,如此循环直到表达式结束。
代码如下
public Expr parseExpr() {
Expr expr = new Expr();
expr.addTerm(parseTerm());
while (lexer.pick().equals("-") | lexer.pick().equals("+")) {
expr.getOp().add(lexer.pick());
lexer.nextpos();
expr.addTerm(parseTerm());
}
return expr;
}
而parseTerm要做的事也基本一样,只不过不用添加符号(默认是乘号*),parserTerm换成parserFactor,factor解析如果是内部能包含表达式的因子则调用parserExpr,如此向下递归直到遇到基本因子。以下为parserFactor解析的几种情况
public Factor parseFactor() {
if (lexer.pick().equals("(")) {
lexer.nextpos();
Factor expr1 = parseExpr();
lexer.nextpos();
return expr1;
}
else {
if (lexer.pick().equals("x")) {
String element = lexer.pick();
lexer.nextpos();
return new Element(element);
}
else {
String number = lexer.pick();
lexer.nextpos();
return new NumberFactor(number);
}
}
}

第一次作业架构细节如上图。
在第一次作业基础上,本次迭代作业增加了以下几点:
整体思路
第二次作业主要是要添加自定义函数与指数函数,对于自定义函数采用字符串替换的方法,在进行lexer预处理之前就由personalFun(自定义函数类)自身的process方法将所有自定义函数替换成带入后的形式,从而在之后的处理上减少了修改,而exp则作为一个单目运算符处理,Poly内添加exp方法,作用是对this多项式套入exp()中。exp本身其实很简单,麻烦的是处理exp加入之后的运算,需要对Poly类进行重构,多项式的存储方式和运算方法都需要重新构造。
正如整体思路中所说,本次作业最大的难点就是对引入exp后造成的多项式类的储存与运算方式的重构。
首先就是存储方式的重构
public class Poly {
private BigInteger maxP;
private HashMap<BigInteger, BigInteger> expr;//前一项是指数,后一项是系数
public Poly(String origin) {
expr = new HashMap<>();
if (origin.equals("")) {
maxP = new BigInteger("0");
}
else if (origin.equals("x")) {
this.maxP = new BigInteger("1");
this.expr.put(new BigInteger("1"), new BigInteger("1"));
}
else {
this.expr.put(new BigInteger("0"), new BigInteger(origin));
this.maxP = new BigInteger("0");
}
}
......
}
以上是第一次作业的存储代码,由于第一次作业的数据限制,变量因子只有x,所以直接用hashmap存储系数和指数即可,key为x的指数,value为对应指数项的系数,maxP存储整个hashmap的最高次项方便运算。
而这样的储存结构在第二次作业中显然不行,引入exp之后显然需要增加一个属性用于存储变量因子本身的形式,所以这里选择采用了容器嵌套,实现如下
public class Poly {
private HashMap<HashMap<String, BigInteger>, BigInteger> expr;//前一项是指数元和指数,后一项是系数
public Poly(String origin) {
Pattern constPattern = Pattern.compile("^([+]|[-])?\\d*$");
Matcher constMatcher = constPattern.matcher(origin);
expr = new HashMap<>();
if (origin.equals("")) {
HashMap<String, BigInteger> mono = new HashMap<>();
}
else {
if (!constMatcher.find()) {
HashMap<String, BigInteger> mono = new HashMap<>();
mono.put(origin, new BigInteger("1"));
expr.put(mono, new BigInteger("1"));
}
else {
HashMap<String, BigInteger> mono = new HashMap<>();
mono.put("x", new BigInteger("0"));
expr.put(mono, new BigInteger(origin));
}
}
}
......
}
嵌套之后内部作为key的hashmap(mono)用于存储变量因子本身和对应指数,如下x^2*exp(x)就存储为<"x",2>&<"exp(x)",1>两个键值对,一个内层hashmap就相当于一个单项式,而其在外层hashmap中对应的value即为此单项式的系数,单个运算方法(add,mult,sub)设计的核心就是单项式的运算,运算时只需遍历外层hashmap取出单项式,然后遍历内层hashmap对单项式进行加减乘运算即可,判断add与sub相等直接比较字符串是否相同即可,而mult需要特殊处理,因为exp内部不相同,其相乘也可以直接内部相加,因此mult方法需要完全重构,同时运算时应对exp进行特殊识别,识别到之后就要调用exp的特殊乘法。
特殊乘法的关键如下,只需要对两个exp内部再次调用parser解析为多项式之后,调用poly的add方式即可
public String expMult(String element1, String element2) {
Lexer lexer1 = new Lexer(element1.substring(4, element1.length() - 1));
Lexer lexer2 = new Lexer(element2.substring(4, element2.length() - 1));
Parser parser1 = new Parser(lexer1);
Parser parser2 = new Parser(lexer2);
Expr expr1 = parser1.parseExpr();
Expr expr2 = parser2.parseExpr();
//System.out.println(expr);
Poly expIn1 = parser1.simplify(expr1);
Poly expIn2 = parser2.simplify(expr2);
String addResult = expIn1.add(expIn2).toString();
......
}

第二次作业架构细节如上
在第二次作业基础上,本次迭代作业增加了以下几点:
第三次新增两个主要功能自定义函数的嵌套定义与求导因子,其中自定义函数的嵌套调用在第二次作业就已经实现(第二次实现时就是按遇到函数符号就进行分割替换因此已经满足了递归调用),因此只需要对求导因子进行处理即可。
对于求导因子,这里是将其当作和exp一样的函数识别存储,只不过与exp不同的是其自带的特殊运算法则不需要修改已有的运算方法,而是直接在poly内新增deriv运算,在读取后缀表达式时识别到“dx”即对相应的运算数转化为poly后调用deriv方法即可返回求导后的结果。
而deriv的具体实现则采用了和解析表达式时一样的递归下降的实现方法,exprDeriv()--> termDeriv()-->factorDrive()-->......
求导的加法法则在exprDeriv层面实现,乘法法则在termDeriv层面实现,链式法则在factorDeriv层面实现,层次分明便于调试。
exprDeriv实现如下,下层只需类推即可
public Poly exprDeriv() {
Lexer lexer = new Lexer(this.toString());
Parser parser = new Parser(lexer);
Expr expr = parser.parseExpr();
Poly undoPoly = parser.simplify(expr);
Poly newPoly = new Poly("");
if (undoPoly.expr.keySet().isEmpty()) {
return newPoly.add(new Poly("0"));
}
for (HashMap<String, BigInteger> mono : undoPoly.expr.keySet()) {
Poly tmp = termDeriv(mono, undoPoly.getExpr().get(mono));
newPoly = newPoly.add(tmp);
}
return newPoly;
}
需注意幂函数与指数函数求导法则不同,应在factorDeriv方法下层继续分情况分别实现

第三次作业架构细节如上

类图如上

PersonalFun是自定义函数类,只有两个方法,一个是构造方法一个是替换方法,用字符串替换完成自定义函数的带入。输入进入程序的第一步就是经过这个方法处理消去自定义函数。
Factor为因子接口,没有具体内容,用于实现对多种因子的统一管理

NumberFactor为常数因子,内部储存一个表示自身的值的字符串

Element为幂函数的底数因子x(不带指数)

PowerFactor为幂函数因子,内部有一Expr容器,依次存有幂函数的底数与由内层到外层的指数


ExpFun与DerivFun均为迭代中添加,分别为指数函数因子与求导因子,内部均存有一个表达式,addInside方法用于添加内部表达式。


Expr,Term,Factor层级分明,Expr类属性有一个Term容器用来存储项,同时还有一个容器用于存储符号,Term类中则只有一个容器,用于存储因子(因为因子之间默认用乘号相连)。
上述所有类中均带有自己的toString方法,能将自己的内部元素输出为后缀表达式。

Parser是解析类,用递归下降的方法依次从表达式Expr往下解析直至遇到基本因子(NumberFactor与Element)时停止递归返回。parseExp与parseDx内部均调用parseExpr。

Lexer是预处理类,负责将冗余的符号简化、去除空格、同时返回当前位置的语法元素。preProcess用于将重复冗余的符号化作一个符号,factorProcess则将指数上的多余符号去掉,都属于预处理类,nextpos用于判断当前状况,调用token刷新当前语法单元为最新,并将pos指针向后移动一个语法单元。ifPreAdd与ifPreSub用于判断当前符是先导符号还是运算符号,pick方法用于直接获取当前pos。
Lexer与Parser相配合结合以上表达式类共同完成项目的第二步,将输入字符处理解析转化为后缀表达式。最后再调用parser中的simplify()方法,依次取出后缀表达式中的元素转化为Poly(多项式类)对象,

Poly中的方法均用其作用命名,简单直接,调用Poly中对应运算符号和函数的add,sub,mult,pow,exp,dx方法进行计算,用toString方法输出最终结果。
整个项目整体复杂度正常,类总数13个,方法总数47个,其中Poly类方法最多(14个),但是有部分类出现标红警告,如下图

可以看出复杂度警告部分主要集中于Poly类与lexer类,这两个类可以说是本单元作业中承担任务最多的类,出问题也可以说是意料之内(
于是对这两个类中的方法进行了度量评估,结果如下图所示


四项数据从左到右依次是CogC, ev(G), iv(G), v(G)。
lexer的iv(G)超标主要是由于预处理输入分了两步进行,作为第二步自然会耦合度过高,这部分在之后的其他开发中应通过选择更有效率的算法精简代码来解决,分支复杂度则是处理字符串输入对表达式解析的必然开销,目前暂未找到很好的解决方法。
lexer中的token方法与Poly中的mult方法耦合度过高单纯是因为单个方法行数超过了代码风格限制进行强制拆分导致的耦合度增大,这部分只能在之后课程的学习中提高对自己的要求,尽量优化精简。
这部分其实要说的并不多,值得一提的是由于在预处理时如果遇到第一个为"(" ,"-"等符号或者exp,dx时由于这三样的解析与处理都位于nextpos中,解析第一个字符时并未调用nextpos所以测试数据没有覆盖到是很难察觉的,从根本上处理应该是要重构对应方法,不过这里图省事直接识别这几个然后在预处理表达式之前加上1*或者0了(
if (input.charAt(0) == '-' || input.charAt(0) == '+') {
sb.append("0");
}
else if (input.charAt(0) == '(' || input.charAt(0) == 'e' || input.charAt(0) == 'd') {
sb.append("1*");
}
对于bug最好的预防方法就是做好关键部分的单元测试,其次就是迭代的时候分步实现,先实现简单的功能,完成一个新功能的时候尽量先把这个功能测试完毕(如第二次作业的自定义函数),最后就是学会自己构造数据,尽量全面覆盖,面向数据debug永远都是最后的最有效的方法。
没有卷优化。怕优化出问题(
oo正课相比于pre难度与强度上有了显著提升,刚开始做的时候总是在心里感叹作业怎么这么抽象但是又贴合实际,抽象是因为被文法语法递归下降这些新概念弄得汗流浃背,贴合实际是因为实现的功能确实在生活非常常见且简单。
实现过程中深刻体会到了可扩展性对于一个项目的重要性(第二次作业的重构真的是整的死去活来),以及单元测试对debug负担的减轻,虽然可能做不到每一个小的方法都用junit去测试,但是至少做到对整体任务分布实现,由易到难,每一个完整的功能完成后都对其进行充分测试保证没有问题在进行下一步,这样出问题也能很快明确出问题的地方。
三次作业下来确实学到了很多,别的姑且不说,递归下降的分层处理方法对于应对和表达式这样类似的层次分明的问题的时候真的用处很大,在实现求导功能的时候体会非常真切。
最后就是在debug的过程中对自己代码风格以及思想的不够成熟有了全面且深切的认识(比如几次碰到方法超过60行行数限制)。这次由于不想过度修改导致新的bug,很多问题都是修修补补打补丁就过去了,希望之后的作业中能够从根本上解决问题,提升自己的面向对象编程素养。