结对作业
这个作业属于哪个课程 | |
---|---|
这个作业要求在哪里 | |
这个作业的目标 | |
结对成员 |
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 生成表达式算法实现细节:
生成算法使用递归构造二叉树来生成表达式。
生成流程:
- 随机得到本次生成字符串 opNum 为(1 ~ 符号数上限)之间的随机数
- 生成结点,若opNum=0,进入 [3],否则进入 [4];
- 将此结点type设为NUM(数),Value设为随机分数/整数
- 若opNum>0,将此结点Type设为操作符中任意1种(+-×÷),opNum减一,生成两个子结点设为此结点的左右节点,并将opNum随机分配给它们(两个结点的生成分别进入 [2])
流程图:
GIF图演示:
2.2.1 计算表达式算法实现细节:
生成表达式后,会计算它的值,而且在计算的同时,并为了保证表达式中不出现负数,和不出现重复,将对表达式二叉树进行修改。
计算流程(假设计算方法为cal())
- 进入结点,若该节点为数,返回value。否则进入 [2]
- 计算出 leftVal = cal(左节点), rightVal = cal(右结点),检查它们任一是否为null,若是,返回null,否则进入 [3] (定义除法运算中,出现 n÷0 时,返回null)
- 该节点是计算符,res = leftVal【计算符】rightVal。若计算符为【-】,进入 [4],若计算符是【*】或【+】,进入 [5]
- 检查是否leftVal < rightVal,交换左右子树,res = -res,value = res,返回value(此步保证表达中任意子树的值不出现负数)
- 检查是否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.单元测试
-
生成题目正确性测试
使用Mathematica软件测试生成的答案是否正确(需要稍微更改生成格式源码):
-
生成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(); } }
-
测试生成的题目不会重复(测试原理:压缩运算符数量和数字范围)
@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这么几种。
执行结果:
-
检查生成的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 节选内容:
-
对错答案混杂测试
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 内容:
-
检查非法问题(出现除以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 内容:
运行结果:
-
检查题目答案数目不一致测试
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!在给队友打下手的时间可以学习队友的思路和逻辑,队友一开始就给出了非常清晰的算法逻辑,太感动了🤤,我只需要跟着喝口汤就好了