结对作业

这个作业属于哪个课程
信安1912-软件工程
这个作业要求在哪里
结对项目
这个作业的目标
四则运算生成器程序设计
结对成员
3119000608 苏泓晖 & 3119005473 苏泽

1. GitHub 链接

2. 设计实现过程

2.1 程序结构

  • model 包:

    • Expression.java 表达式类
    • Infix.java 中缀类
    • Fraction.java 分数类
  • frame 包:

    • Frame.java 生成问题Swing界面
    • AnsCheckFrame 检查答案Swing界面
  • utils 包:

    • FileUtil.java 文件读写工具类
    • MathUtil 数学工具类

类图:

2.2 生成与检查算法原理

本程序用二叉树来存储表达式,结点若是叶子,则此结点表示数,否则表示运算符。(如图)


图源:wiki [1]

2.2.1 生成表达式算法实现细节:

生成算法使用递归构造二叉树来生成表达式。

生成流程

  1. 随机得到本次生成字符串 opNum 为(1 ~ 符号数上限)之间的随机数
  2. 生成结点,若opNum=0,进入 [3],否则进入 [4]
  3. 将此结点type设为NUM(数),Value设为随机分数/整数
  4. 若opNum>0,将此结点Type设为操作符中任意1种(+-×÷),opNum减一,生成两个子结点设为此结点的左右节点,并将opNum随机分配给它们(两个结点的生成分别进入 [2]

流程图

GIF图演示

2.2.1 计算表达式算法实现细节:

生成表达式后,会计算它的值,而且在计算的同时,并为了保证表达式中不出现负数,和不出现重复,将对表达式二叉树进行修改。

计算流程(假设计算方法为cal())

  1. 进入结点,若该节点为数,返回value。否则进入 [2]
  2. 计算出 leftVal = cal(左节点), rightVal = cal(右结点),检查它们任一是否为null,若是,返回null,否则进入 [3] (定义除法运算中,出现 n÷0 时,返回null)
  3. 该节点是计算符,res = leftVal【计算符】rightVal。若计算符为【-】,进入 [4],若计算符是【*】或【+】,进入 [5]
  4. 检查是否leftVal < rightVal,交换左右子树,res = -res,value = res,返回value(此步保证表达中任意子树的值不出现负数)
  5. 检查是否leftVal < rightVal,交换左右子树,value = res,返回value(此步保证对满足交换律(*、+)的表达式只会生成左子树<右子树,以满足不会出现生成多个表达式时不会重复的要求,具体见)

流程图

3.效能分析

3.1 生成表达式性能分析:

分析来自生成10000个表达式(出现)的单元测试(结果不输出至文件)

总览:

内存占用:

时间占用:

  • 总时间占用:723ms(其中JUnit的单元测试模块占用了158ms)

  • 各方法时间占用:

3.2 检查答案性能分析:

分析针对检查生成的10000个表达式与其答案的单元测试(结果会输出至文件),单元测试使用反射调用Frame中私有方法。

总览:

内存占用:

时间占用:

  • 总时间占用:2s 110ms(其中大部分来自Swing用户界面)

3.3 改进记录

最初版本中,我将生成表达式放在了一个递归方法中,但是为了保证不出现负数和n/0的非法情况,还要加入修改功能,导致算法很复杂,还因为多次递归访问下层结点产生了更高的时间复杂度,后来我将生成与计算修改方法分离,先生成表达式,再在计算方法中对非叶节点计算他的值且根据题目要求进行修改。

4.代码说明

生成主要过程代码:

	 /**
     * @param maxOpNum: 运算符数量限制
     * @param bound:    表达式中最大允许出现数字
     * @return model.Expression
     * @description 随机生成表达式
     */
    public static Expression genExpr(int maxOpNum, int bound) {
        Expression expr = genSubExpr(MathUtil.getRandomNum(1, maxOpNum), bound, false);

        //必须先计算再生成表达式字符串,这是因为负数出现时,会在计算时修改表达式
        expr.value = calExprResult(expr);
        expr.str = getInfixString(expr, null);
        return expr;
    }

	/**
     * @param opNum:  剩余符号个数
     * @param bound:  表达式中最大允许出现数字
     * @param noZero: 要求值不可为零
     * @return model.Expression
     * @description 生成子表达式
     */
    private static Expression genSubExpr(int opNum, int bound, boolean noZero) {

        //初始化
        Expression expr = new Expression();

        if (opNum == 0) {
            expr.type = NUM;
            expr.value = new Fraction(bound, noZero);
        } else {
            expr.type = MathUtil.getRandomNum(1, 4); //加减乘除
            
            //随机给左右结点分配opNum,即剩余符号个数
            int leftOpNum = opNum == 1 ? 0 : MathUtil.getRandomNum(1, opNum - 1);
            int rightOpNum = opNum - leftOpNum - 1;
            
            if (expr.type == DIV) { // ÷
                expr.left = genSubExpr(leftOpNum, bound, noZero);
                expr.right = genSubExpr(rightOpNum, bound, true); //除号时规定分母不能为0
            } else {
                expr.left = genSubExpr(leftOpNum, bound, noZero);
                expr.right = genSubExpr(rightOpNum, bound, noZero);
            }
        }

        return expr;
    }

代码思路见此处

计算主要过程代码:

 /**
     * @param expr: 表达式
     * @return model.Fraction
     * @description 计算表达式结果,并会对出现负数的部分进行修正。
     */
    public static Fraction calExprResult(Expression expr) {

        if (expr == null) return null;

        Fraction res = null;
        Fraction left = null;
        Fraction right = null;
                
        if (expr.type == NUM) {
            res = expr.value;
        } else {
            // 计算出左右子表达式的值
            left = calExprResult(expr.left);
            right = calExprResult(expr.right);
            if (left == null || right == null) return null; //出现null说明出现了除以0的算式
        }
        
        switch (expr.type) {
            case NUM:
                break;
            case ADD: {
                // 保证只出现 少+多
                if (Fraction.calSub(left, right).getNumerator() > 0) swapLR(expr);
                res = Fraction.calAdd(left, right);
                break;
            }
            case SUB: {
                res = Fraction.calSub(left, right);
                if (res.getNumerator() < 0) { // 出现负号,交换左右子树并对res取负
                    swapLR(expr);
                    res.setDenominator(-res.getNumerator());
                }
                break;
            }
            case MUL: {
                // 保证只出现 少×多
                if (Fraction.calSub(left, right).getNumerator() > 0) swapLR(expr);
                res = Fraction.calMul(left, right);
                break;
            }
            case DIV:
                res = Fraction.calDiv(left, right);
                if (res.getDenominator() == 0) return null; // 保证最外层也不会分母为0
                break;
        }
        expr.value = res;
        return res;
    }

代码思路见此处

/**
     * @description 生成题目并写入文件
     * @param exprNum: 生成个数
     * @param bound: 题目中出现数字的范围
     */
    private void genStart(int exprNum, int bound) {

        try {
            // 初始化set、vector
            exprStrSet = new HashSet<>();
            expressions = new Vector<>();
            textArea.setText("");
            
            // 清除文件
            FileUtil.clearFile("Exercises.txt");
            FileUtil.clearFile("Answer.txt");

            // 循环生成
            for (int i = 0; i < exprNum; ) {
                Expression expr = Expression.genExpr(3, bound); //生成
                
                //若已出现过此题目或题目中会出现除以0,抛弃该题目
                if (exprStrSet.contains(expr.str) || expr.value == null) continue;
                expressions.add(expr);
                exprStrSet.add(expr.str);
                
                String str = i + 1 + ". " + expr.str;
                
                textArea.append(str + "\n"); //输出至textArea
                textArea.selectAll(); //保证textArea总在最下显示
                
                i++; // 计数
            }

            textArea.append("生成完毕,写入文件中...\n");
            textArea.selectAll();

            //用stream()将题目和答案组合成字符串并写入至文件
            FileUtil.appendStr2File(expressions.stream().map(expr -> expressions.indexOf(expr) + 1 + ". " + expr.str)
                            .collect(Collectors.joining("\n")),
                    "Exercises.txt");

            FileUtil.appendStr2File(expressions.stream().map(expr -> expressions.indexOf(expr) + 1 + ". " + expr.value)
                            .collect(Collectors.joining("\n")),
                    "Answer.txt");

            textArea.append("生成与写入完毕!\n");
            textArea.selectAll();

        } catch (IOException ioException1) {
            JOptionPane.showMessageDialog(null, "文件读写异常:\n" + ioException1.getMessage());
        }
    }

为满足不能生成重复题目的要求,首先在计算方法中会将满足交换律的+和×的子表达式进行判断并修改,如果左子树更大则交换左右,因此只会出现[少 +/*多]的情况,再在外层的此方法中,将已生成的题目放入HashSet,若已出现过则抛弃这条题目。有出现除以0的表达式如[2/(3-3)]也会在计算方法中判断并将值置为null,在此方法内也将其抛弃。直到到达要求的题目数量,将它们组合成字符串写入文件中。

检查过程主要代码

检查过程的重点在于将字符串转化成表达式二叉树,这是这个过程的代码:

/**
 * @description 递归中缀表达式字符串转表达式二叉树
 * @param infix: 构造过程中的infix,需要其中pos和infixStr成员变量
 * @return model.Expression
 */
private static Expression infix2Expr(Infix infix) {
    Expression node = infix2ExprTerm(infix);
    while (infix.pos<infix.exprStrings.size() &&
            (infix.exprStrings.get(infix.pos).equals("+") ||
                    infix.exprStrings.get(infix.pos).equals("-"))) {
        int op = infix.exprStrings.get(infix.pos).equals("+") ?
                Expression.ADD : Expression.SUB;
        infix.pos++;
        node = new Expression(op, node, infix2ExprTerm(infix));
    }
    return node;
}

private static Expression infix2ExprTerm(Infix infix) {
    Expression node = infix2ExprFactor(infix);
    while (infix.pos<infix.exprStrings.size() &&
            (infix.exprStrings.get(infix.pos).equals("×") ||
                    infix.exprStrings.get(infix.pos).equals("÷"))) {
        int op = infix.exprStrings.get(infix.pos).equals("×") ?
                Expression.MUL : Expression.DIV;
        infix.pos++;
        node = new Expression(op, node, infix2ExprFactor(infix));
    }
    return node;
}

private static Expression infix2ExprFactor(Infix infix) {
    Expression node = null;
    if (!infix.exprStrings.get(infix.pos).equals("(")) {
        node = new Expression(Expression.NUM,
                new Fraction(infix.exprStrings.get(infix.pos)));
        infix.pos++;
    } else if (infix.exprStrings.get(infix.pos).equals("(")) {
        infix.pos++;
        node = infix2Expr(infix);
        infix.pos++;
    }
    return node;
}

将字符串转化为二叉树的递归算法由三个方法的互相递归调用完成。思路是先将字符串切割成一个包含数、符号、括号的数组,然后从左子树开始构建树,遇到符号再根据符号优先级去递归创建右子树(因为是递归所以创建过程一致),右子树创建完成后去找下一个符号,将这一完整的树作为新符号的左子树的。之所以有三个方法是因为有不同的符号优先级。

5.单元测试

  1. 生成题目正确性测试

    使用Mathematica软件测试生成的答案是否正确(需要稍微更改生成格式源码):

  1. 生成1w条题目测试

    @Test
        public void genTest() {
            try {
                Frame frame = new Frame();
                frame.setVisible(false);
                Class<? extends Frame> clz = frame.getClass();
                Method genStartMethod = clz.getDeclaredMethod("genStart", int.class, int.class);
                genStartMethod.setAccessible(true);
                genStartMethod.invoke(frame, 10000, 10);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    

  1. 测试生成的题目不会重复(测试原理:压缩运算符数量和数字范围)

    @Test
    public void genTest3() {
    
        Vector<Expression> expressions = new Vector<>();
        HashSet<String> exprStrSet = new HashSet<>();
    
        Thread thread = new Thread(() -> {
            while (true) {
                Expression expr = Expression.genExpr(1, 1);
                if (exprStrSet.contains(expr.str) || expr.value == null) continue;
                expressions.add(expr);
                exprStrSet.add(expr.str);
            }
        });
    
        thread.start();
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread.interrupt();
        for (Expression e : expressions) {
            System.out.println(e.str);
        }
    }
    

    原理:限定最多出现1个元素,数字上限1,如果不能重复特性有效,在5秒内while(true)生成的表达式最多只有0 + 0、0 + 1、1 + 1、0 - 0、1 - 0、0 × 1、1 × 1、0 × 0、0 ÷ 1、1 - 1、1 ÷ 1这么几种。

    执行结果:

  2. 检查生成的1w条题目测试**

    @Test
    public void genTest4() {
        try {
            Frame frame = new Frame();
            frame.setVisible(false);
            Class<? extends Frame> clz = frame.getClass();
            Method genStartMethod = clz.getDeclaredMethod("genStart", int.class, int.class);
            genStartMethod.setAccessible(true);
            genStartMethod.invoke(frame, 10000, 10);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    

Exerises4.txt 节选内容:

Answer4.txt 节选内容:

  1. 对错答案混杂测试

    public void checkAnsTestByPath(String exercisePath, String answerPath) {
            try {
                AnsCheckFrame frame = new AnsCheckFrame();
                frame.setVisible(false);
                Class<? extends AnsCheckFrame> clz = frame.getClass();
                Field exerciseFile = clz.getDeclaredField("exerciseFile");
                exerciseFile.setAccessible(true);
                Field ansFile = clz.getDeclaredField("ansFile");
                ansFile.setAccessible(true);
    
                exerciseFile.set(frame, new File(exercisePath));
                ansFile.set(frame, new File(answerPath));
    
                Method method = clz.getDeclaredMethod("checkAns");
                method.setAccessible(true);
                method.invoke(frame);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        @Test
        public void checkAnsTest5() {
            checkAnsTestByPath("test\\Exercises5.txt", "test\\Answer5.txt");
        }
    

    Exerises5.txt 内容:

    Answer5.txt 内容:

    检查完毕 Grade.txt 内容:

  2. 检查非法问题(出现除以0)测试

    public void checkAnsTestByPath(String exercisePath, String answerPath) {
            try {
                AnsCheckFrame frame = new AnsCheckFrame();
                frame.setVisible(false);
                Class<? extends AnsCheckFrame> clz = frame.getClass();
                Field exerciseFile = clz.getDeclaredField("exerciseFile");
                exerciseFile.setAccessible(true);
                Field ansFile = clz.getDeclaredField("ansFile");
                ansFile.setAccessible(true);
    
                exerciseFile.set(frame, new File(exercisePath));
                ansFile.set(frame, new File(answerPath));
    
                Method method = clz.getDeclaredMethod("checkAns");
                method.setAccessible(true);
                method.invoke(frame);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        @Test
        public void checkAnsTest6() {
            checkAnsTestByPath("test\\Exercises6.txt", "test\\Answer6.txt");
        }
           
    

    Exerises.txt 内容:

    运行结果:

  3. 检查题目答案数目不一致测试

    public void checkAnsTestByPath(String exercisePath, String answerPath) {
            try {
                AnsCheckFrame frame = new AnsCheckFrame();
                frame.setVisible(false);
                Class<? extends AnsCheckFrame> clz = frame.getClass();
                Field exerciseFile = clz.getDeclaredField("exerciseFile");
                exerciseFile.setAccessible(true);
                Field ansFile = clz.getDeclaredField("ansFile");
                ansFile.setAccessible(true);
    
                exerciseFile.set(frame, new File(exercisePath));
                ansFile.set(frame, new File(answerPath));
    
                Method method = clz.getDeclaredMethod("checkAns");
                method.setAccessible(true);
                method.invoke(frame);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    
        @Test
        public void checkAnsTest6() {
            checkAnsTestByPath("test\\Exercises7.txt", "test\\Answer7.txt");
        }
    

    Exercises.txt 内容:

    Answer.txt 内容:

    运行结果:

PSP表格

PSP2.1 Personal Software Process Stages 预计耗时(分钟) 实际耗时(分钟)
Planning 计划 10 5
Estimate 估计这个任务需要多少时间 10 5
Development 开发 500 560
Analysis 需求分析(包括学习新技术) 60 50
Design Spec 生成设计文档 30 45
Design Review 设计复审 15 30
Coding Standard 代码规范(为目前的开发制定合适的规范) 30 10
Design 具体设计 30 45
Coding 具体编码 500 530
Code Review 代码复审 30 45
Test 测试(自我测试,修改代码,提交修改) 120 75
Reporting 报告 30 45
Test Reporting 测试报告 30 10
Size Measurement 计算工作量 15 5
Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 15 25
合计 1225 1285

项目小结

苏泓晖:

本次作业是我的第一次结对编程体验,感到了一些新奇感,相比于单枪匹马写代码查资料,和搭档两人合作的话,既可以坐在一台电脑前讨论思路,也可以在两台电脑上分别开发不同的类。我感受到的结对编程的优点是:“副驾驶”可以帮敲键盘的”主驾驶“发现并揪出一些简单低级的错误,一些简单的问题比如语言语法、基础API等也可以互相询问,节约了一些时间;交流需求实现方法时两人能集思广益,讲给对方听的时候也能帮助自己将思路缕清。总之我从这次结对作业中学到了很多,也感谢搭档的配合!

苏泽:

这一次的结对作业跟着我的大腿队友学到了很多,全靠队友C!在给队友打下手的时间可以学习队友的思路和逻辑,队友一开始就给出了非常清晰的算法逻辑,太感动了🤤,我只需要跟着喝口汤就好了


  1. https://en.wikipedia.org/wiki/Binary_expression_tree#/media/File:Exp-tree-ex-11.svg ↩︎

posted @ 2021-10-25 23:53  優曇華院  阅读(30)  评论(0编辑  收藏  举报