OO总结之多项式求导

OO作业总结之第一单元——多项式求导


  • 思路简析

  • 代码分析

  • 历次bug分析

  • 面向对象方法实现——接口抽象与工厂模式

  • 代码对比与思考


一. 思路分析

第一次作业集中于多项式求导,其规则比较简单,也并不包含嵌套,输入也保证了格式的正确性。故此思路也比较简单: 首先通过简单的正则解析输入并生成相对应的项,然后传入一个容器以便合并,之后逐项求导再合并,最后输出。

第二次作业在第一次作业的基础上增加了三角项与复杂项,其本质上是提供了项之间的运算法则,同时开始测试非法输入。我的解决方法使用大正则的方式直接校验表达式合法性,在第一次作业代码的基础上模仿幂函数类PowerTerm建立三角函数类TrigonometricTerm和复杂函数类ExtendedTerm,三者同时继承自抽象类Term。之后在三个子类中建立项之间的运算关系,包括项加法与乘法,以便实现自动化和程式化的求导。

特别要提到其中ExtendedTerm类的实现,在一开始我采用的是递归生成法,即对于类似x * x * x的项,我的构造方法会生成x * ExtendedTerm(x*x)的项,然后下一层递归实现。这样做的好处是在生成,求导,输出时全部递归实现,代码非常简单。然而在后续测试中遇到了一些问题,首先是由于递归层数过多导致的超时问题,其次该方法化简不便,导致对于长的测试用例输出极其复杂,难以判断正确性或者确定错误位置。(其实观察我的初始代码,其中的Expand方法就是对化简的一次不太成功的尝试

在最终强测阶段,我对ExtendedTerm部分代码进行了重构,将其赋予了一个标准化形式coef *x**expo1 *sin(x)**expo2 *cos(x)**expo3,其中coef代表系数,expo1至3表示指数。这种结构在实例化时首先将coef置1,expo全置0,整个项相当于常数1,然后用这一项依次去乘所有的因子项,其中根据定义好的乘法规则,幂函数分项,正弦函数项,余弦函数项分别会自动合并,以保证在乘法执行完毕后该项依旧保持coef *x**expo1 *sin(x)**expo2 *cos(x)**expo3的形式。

最后使用一个自定义的容器HoneyJar来进行基础同类项合并。合并进行两次,分别是求导前和求导后。

第三次作业增加了复合项,并在正确性判断上提供递归正则,难度陡增。为了苟命保证正确性前提,我对代码进行了重构,采用了树形结构来进行求导,具体实现放方法与其他同学大同小异,在此不必赘述。但是在判断正确性时,由于还有时间我并没有使用边解析边判断的方式。由于上次作业的正则是我好不容易精心调教的,所以不忍放弃,所以想了一个巧妙的方法重复利用了一下,并且避开了递归问题,这一点我讲后面单独叙述。

二. 代码分析


首先我们来看一下第一次作业和第二次作业的类图(Java Idea 生成)

其中HoneyJar是我自定义的容器类,类Reg是类似工厂类的存在,然而因为我当时还不知道工厂模式这个东西,所以没有采用静态方法,但是功能是一样的。
可以看到这个设计中我的顶层类MainClass只需要和HoneyJarReg工厂打交道,并且只需要知晓Term类即可,较好的掩盖了实现细节。

下面我们看看各个方法的量化表格(此表格由 DesigniteJava 工具生成)


类量化结果如下:

其中各个参数的含义如下:

LOC (Lines Of Code – at method and class granularity)
CC (Cyclomatic Complexity – Method)
PC (Parameter Count – Method)
NOF (Number of Fields – Class)
NOPF (Number of Public Fields – Class)
NOM (Number of Methods – Class)
NOPM (Number of Public Methods – Class)
WMC (Weighted Methods per Class – Class)
NC (Number of Children – Class)
DIT (Depth of Inheritance Tree – Class)
LCOM (Lack of Cohesion in Methods – Class)
FANIN (Fan-in – Class)
FANOUT (Fan-out – Class)

DesigniteJava工具还提供了探测程序BadSmell的功能,所以我也顺便对我的程序进行了一次检测,其中部分结果如下:
The length of the statement "else if (lista.get(i).getClass().equals(TrigonometricTerm.class) && ((TrigonometricTerm)(lista.get(i))).getSign() == 1) {" is 121.
The method contains a magic number: 10000
Cyclomatic complexity of the method is 8
从中可以看出一些问题,比如过于复杂的条件判断,过于复杂的方法逻辑。
但是我发现了一个我之前没有想到的问题,即magic number的问题,对于我这种之前并没有怎么接触过标准化编程的人来说,这个概念我基本上没接触过。所以经过网上搜索之后,我发现所谓magic number应该翻译成莫名其妙的数字,指程序中出现的复杂数字,这些数字一般都有一些特殊含义,然而这些含义并没有以文字形式写在程序里,所以对于程序阅读者来说这就是一个意义不明的数字(甚至对于之后查看程序的编程者本人也是如此),不方便代码的维护。所以一般这种数字应该使用MAX_EXPO之类的宏常量代替,在Java里可能就是final类型变量。这个问题在合作作业时就尤为重要了,所以大家应该谨记。

第三次作业重构后的类图如下:

这个代码的结构就很明朗了,其中所有节点类型均继承自Node类,然后通过Fac工厂方法调用构造。由于没有执行化简,所以它看上去有多简单,实际上也就这么简单。

三. 历次bug分析


第一次作业由于问题并不复杂,所以我并没有被发现bug,但是我发现有些同学在某些极端情况的输出是有问题,例如当求导结果为0时没有输出等。

第二次作业依然有同学犯下第一次作业的错误。而我的bug主要来自两个问题:
一是由于递归的复杂项结构导致的超时,这个是互测阶段出现的较多,这一点在第一部分提到过,使用了重构结构与添加了化简容器的方式解决了。
二则是正则部分,由于我的正则写的稍有问题,导致会将一些正确的格式识别出现问题。这一点还是很好解决的。但是,在完成这个修复后我造成了新的bug
最令人头疼的部分来了,就是正则回溯陷阱,我一直在听同学们分享,包括讨论区大佬们提到正则回溯陷阱,但是我从来没完全搞懂过什么是回溯陷阱以及如何避免回溯陷阱。直到切身遇到这个问题。我所遇到的回溯陷阱,是因为在构造正则表达式时基本完全按照形式化表述构造,导致程序在合法性判断区卡死,无法进行后续操作。 在上网进行了多方面学习后我终于找到了问题: 不能赋予你的正则过多权力 ,打一个可能不恰当的比方,其实Java的正则自动机就像一个选择困难症,如果你的正则里有很多不定数量匹配,或者重复量词,那么它基本会把所有的情况的检测一遍,然后才会进行下一步,这种回溯动辄上百,一个长长的正则里面这样的纠结结构如果有几个,那么根据乘法原理回溯次数就会轻松上万,这就是灾难性回溯。
那么如何处理这种情况呢?我在短暂的编程生涯中学会了一件事,就是人做的事(预处理)越多,机器做的事情越少, 我们的正则中要尽可能减少不定量词的出现,并且一定慎用|,这种选择符就会让正则自动机犯选择困难症,尤其是选择两边都是复杂表达式的时候!还有一点就是在某些必要时的时候要禁止回溯,比如第二次作业的三角函数项,常数项等。 要禁止回溯,可以使用独占模式这种情况下若第一次扫描不符合条件,那么下一次扫描不会回溯,具体可参考网上资料。但是有一点务必注意:独占模式匹配可能会引起正则的“异常”行为,这种看似异常的行为其实是因为独占模式禁止回溯导致的,比如b*+ba这个独占正则与字符串bbba就无法匹配,因为所有的b都被独占模式捕获完了。

第三次作业的bug主要集中在树形求导输出时的括号问题,因为括号缺失导致的表达式语义改变问题。
当然由于我本身的一个疏忽导致了一个运算bug,当我的输入读取-7*x这种字符串时,由于我采用了类似计算表达式时的栈方法,所以我需要告诉我的程序这是一个负号,而不是一个减号,否则会造成异常弹栈,我采用的策略是补项,即将-7*x补充成(0-7*x),但是我对补项的触发条件设置出现了问题,我的条件是因子栈为空,导致的结果就是-7*x结果正确,但是3*x+sin((-7*x))执行就会出错,因为要补项时我的因子栈里面是有3*x这一项的。所以应该在输入时就将项补好。

四. 面向对象方法实现——工厂模式


在这三次作业中,从第二次开始,我就使用了工厂模式来进行了对象创建,并使用统一的父类来统一调用(尽管一开始并没有将父类定义为抽象类)。

第二次作业工厂模式(位于Reg类):

public Term process() {
    //字符串预处理
    target = target.replace("**", "^");
    target = target.replace("*", "**");
    target = target.replace("^+", "^e");
    target = target.replace("^-", "^k");
    target = target.replace("+", "+1**");
    target = target.replace("-", "-1**");
    Pattern adjust = Pattern.compile(
            "(\\*[+-]?\\d+\\*)");
    Matcher adjustment = adjust.matcher(target);
    BigInteger i = BigInteger.ONE;
    //根据变量个数判断生成类型
    while (adjustment.find()) {
        temp = adjustment.group(0);
        i = i.multiply(new BigInteger(temp.substring(1,
                temp.length() - 1)));
    }
    target = target.replaceAll(adjust.toString(), "*");
    target = target.replaceAll("\\*+", "*");
    target = String.valueOf(i) + target.substring(0,
            target.length() - 1);
    target = target.replace("^", "**");
    target = target.replace("e", "+");
    target = target.replace("k", "-");
    if (target.charAt(0) != '-') {
        target = "+" + target;
    }
    int j = 0;
    Pattern ps = Pattern.compile("[^x]x[^x]");
    Matcher mt = ps.matcher(target);
    //根据结果返回目标项
    while (mt.find()) {
        j = j + 1;
    }
    if (j > 1) {
        return new ExtendedTerm("*" + target);
    }
    if (target.contains("sin") || target.contains("cos")) {
        return new TrigonometricTerm(target);
    } else {
        return new PowerTerm(target);
    }
}

第三次作业中工厂模式:

public class Fac {
public static Node com(String s) {
    //根据传入字符串内容决定生成节点类型并返回
    //System.out.println("generated node : "+s);
    if (s.contains("x")) {
        if (s.equals("x")) {
            return new Power("x^1");
        }
        return new Power(s);
    } else if (s.contains("+")) {
        return new Plus("+");
    } else if (s.contains("-")) {
        return new Minus("-");
    } else if (s.contains("*")) {
        return new Multiply("*");
    } else {
        return new Constant(s);
    }
}
}  

五. 代码比较与思考


在拜读了一些大佬的代码后,再加上对自己代码的分析,我得到了如下结论:

  • 好的代码要有延展性。 我们的作业使用了迭代结构,就是为了训练我们写代码的时候的设计思路,将我们的思路从解决问题扩展至可持续的解决问题。如果想要持续解决问题,就要使用标准化方法,比如设计求导这一功能时的返回值,如果只为解决第一次作业的问题,完全可以直接返回字符串,但是后序作业计算以及未来化简的需要,我们就有必要将返回值设计成项(例如在我的程序中返回值是Term);而且还要使用灵活结构,一个简单的例子就比如使用使用动态容器代替静态数组;并且要预留扩展空间,我的第三次作业被迫重构很大程度上是因为如果要沿用上次结构就需要重写基本每一个类文件的构造,过于麻烦的同时可能造成复杂bug,风险较大(其实也不是完全不可行,如果时间充裕)。

  • 设计时一定细化问题到底,不要急于构造。 这一点其实很影响代码延展性,但是本质上还是设计时的考虑问题,比如我们在第一次作业的时候幂函数类我们可以有系数,指数。这一点我觉得多数第一次接触这个问题的同学都可能这么想,但是我们如果转换到第二次作业的视角,甚至第三次作业的视角,我们是否可以统一乘号的作用,将系数分离出来,当做单独的一项来考虑(具体是当做常数项还是看做幂函数的一种取决于个人,我感觉均可)。 这样在处理输入以及化简的时候就容易一些,如果有同学没有这样做,相信你或多或少都可能在处理系数上遭遇过一些困难。这样把一些看似一目了然的问题再加以仔细思考,比盲目快写然后含泪重构好得多。

  • 善用封装思想。 面向对象程序的封装功能使得其程序简单明了,结构清晰,容易实现高内聚,低耦合的结构。我记得在刚开学做pre的时候有同学抱怨60行的方法限制,表示连main函数都写不完
    然后曾经有同学给出的解答是:随机抽一段代码扔到下一个函数里去。虽然这个问题带有打趣的成分,但是的确引起我们的思考。通过一段时间的学习,我认为一个理想面向对象的程序的顶层程序负责分配任务,执行调用,然后被调用的方法和类协作完成功能,而且这个结构应该是递归的,一直到最底层的执行逻辑,一般这种底层逻辑都是很简单的。为了实现这个结构,我们就要要用类来封装对象,然后用静态方法(如工厂模式)来封装方法。 尽可能做到顶层尽可能少的知晓底层的实现细节。比如我的第二次作业优化后就可以做到:main函数只需要调用Reg生成一个对象(甚至不需要知道生成的是什么类型对象,用Object调用即可),然后传入HoneyJar容器,最后调用一个被重载的toString方法即可完成输出。整个过程及其简单。还有一点,即函数也是可以封装的,这种集中处理的模式(如工厂模式)也可以进一步简化我们的代码。

  • 善用算法思想和编程思想。 这个可以说是基本功的考察,但是需要平时的积累,比如我们的第三次作业,如果善用堆栈的方法解析表达式,其实是可以一定程度上避免递归的。例如在第三次作业中我采用的处理输入合法性的方法(具体实现见注释):

    //check()方法是第二次作业中的正则检查方法,第一个参数是目标串,第二个参数是检测模式,0代表检测表达式,1表示检测因子项
    private static boolean advCheck(String s) {
        String ks = s;
        Stack op = new Stack();
        Stack num = new Stack();
        Matcher illegalCheck = illegalPat.matcher(ks);
        if (illegalCheck.find()) {
            //非法字符检测
            return false;
        }
        Matcher illegalSqc = illegalS.matcher(ks);
        if (illegalSqc.find()) {
            //检测非法乘方符**
            return false;
        }
        ks = ks.replaceAll("\\d+", "1");
        ks = ks.replaceAll("sin\\s*\\(", "[");
        ks = ks.replaceAll("cos\\s*\\(", "{");
        ks = ks.replaceAll("[sinco]", "@");
        //字符替换,将sin,cos换成特殊括号
        if (ks.contains("@")) {
            return false;
        }
        //检测是否有非法的sin,cos
        ks = "(" + ks + ")";
        int i;
        //当检测到右括号时开始向左检测
        //若左括号为“)” 则在当前左右括号中间的表达式不存在嵌套,且应该符合第二次作业正则中表达式的合法标准
        //若左括号为“】”或者“}” 则在当前左右括号中间不存在嵌套,且应该符合第二次作业正则中因子的合法标准(两边允许有空白字符)
        //根据上述规则套用第二次作业正则来检验合法性,若合法,则将本段表达式替换为“#”(若是三角函数替换成(#)),表示合法字符,然后继续寻找右括号
        //在后续检查中,“#”相当于x,且表达式因子不允许幂运算,所以还需检测“#**”字符串,若包含,则非法
        //最终表达式如果被替换为“#”,则为合法表达式
        for (i = 0; i < ks.length(); i++) {
            if (ks.charAt(i) == '(' || ks.charAt(i) == '['
                    || ks.charAt(i) == '{') {
                op.push(ks.charAt(i));
                num.push(i);
            } else if (ks.charAt(i) == ')') {
                int pren = (int) num.pop();
                String toBe = ks.substring(pren + 1, i);
                int len = toBe.length();
                Matcher netCheck = neter.matcher(toBe);
                if (netCheck.find()) {
                    return false;
                }
                toBe = toBe.replace("#", "x");
                toBe = toBe.replace("[", "sin(");
                toBe = toBe.replace("{", "cos(");
                char pre = (char) op.pop();
                if (pre == '(') {
                    if (!check(toBe, 0)) {
                        return false;
                    } else {
                        ks = ks.substring(0, pren) + "#" + ks.substring(i + 1);
                        i = i - len - 1;
                    }
                } else {
                    if (!check(toBe, 1)) {
                        return false;
                    } else {
                        ks = ks.substring(0, pren + 1) + "#" + ks.substring(i);
                        i = i - len + 1;
                    }
                }
            }
        }
        if (ks.equals("#")) {
            return true;
        }
        return false;
    }  

当然我个人认为这种方法也许可以处理输入,生成表达式,但是我的程序并没用采用。


总结

本单元的作业到此结束,但是还有些许想法没来得及实现,几次或大或小的重构也体现了我设计思想的不足。

但是对于抽象类继承,工厂模式以及其他面向对象编程思想的学习也让我受益匪浅,希望下一次作业能够给我们带来更多惊喜吧。


参考文献:
java实现的表达式求值算法
正则表达式回溯陷阱

猜你喜欢

转载自www.cnblogs.com/R-Xiley/p/12537044.html
今日推荐