四则运算生成器
参与者
姓名:黄凯祺 学号:3124004056
姓名:许俊曦 学号:3124004070
github链接:https://github.com/CageHuang1220/sizeyunsuan
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694 |
| 这个作业的目标 | 通过实现一个小学四则运算题目生成器,练习从需求分析、设计、编码、测试到性能优化的完整软件开发流程,并体验结对编程的分工与协作。 |
1. PSP 2.1
| PSP2.1 Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning - 计划 | 30 | 35 |
| Estimate - 估计任务时间 | 20 | 20 |
| Development - 开发 | ||
| Analysis - 需求分析 | 40 | 45 |
| Design Spec - 生成设计文档 | 30 | 30 |
| Design Review - 设计复审 | 20 | 20 |
| Coding Standard - 代码规范 | 10 | 10 |
| Design - 具体设计 | 60 | 70 |
| Coding - 具体编码 | 180 | 190 |
| Code Review - 代码复审 | 40 | 45 |
| Test - 测试 | 120 | 130 |
| Reporting - 报告 | ||
| Test Report - 测试报告 | 50 | 55 |
| Size Measurement - 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan - 事后总结 | 30 | 35 |
| 合计 | 650 | 705 |
2. 效能分析
2.1 数据
测试环境为 JDK 21,range = 10,每次取三次运行的中位数。生成过程包括文件写出。
| 题目数 | 中位耗时(ms) |
|---|---|
| 100 | 128 |
| 500 | 136 |
| 1000 | 170 |
| 5000 | 220 |
| 10000 | 296 |
| 20000 | 385 |

2.2 改进思路
- 表达式采用“森林合并”策略构造:先创建
operatorCount + 1个叶子节点,然后逐步选择两个满足约束的子树合并。这样每次新增的-和÷在构造阶段就保证合法,避免“随机生成整棵树再整体重试”。 - 去重使用
HashSet<String>,键是表达式树的规范形式。规范形式只交换+和×的直接左右子树,避免每次与已有题目逐题比较,使 10000 道题生成保持在线性哈希查找复杂度。 - 分数使用不可约的
Fraction,减少中间数值膨胀和答案格式归一化成本。 - 文件输出使用一次性
StringBuilder拼接再Files.writeString,避免 10000 次小写盘。
从代码路径和耗时数据看,消耗最大的函数是 ProblemSetGenerator.generate();其中 Expression.canonicalKey() 的字符串构建和 Fraction 的四则运算占主要比例。
3. 设计实现过程
3.1 类设计
| 类 | 职责 |
|---|---|
Fraction |
不可约非负分数,提供加减乘除、比较、混合数格式化与解析 |
Expression |
不可变表达式树,保存运算符、左右子树、值和规范去重键 |
ExpressionGenerator |
生成满足“减法非负、除法为真分数”的表达式树 |
ExpressionFormatter |
按优先级输出最少括号的中缀表达式 |
ExpressionParser |
递归下降解析整数、真分数、带分数、括号和四种运算 |
ProblemSetGenerator |
批量生成不重复题目,并写出题目与答案文件 |
Grader |
读取题目和答案文件,统计正确与错误题号 |
Myapp |
命令行参数解析、模式分派和帮助信息 |
ArithmeticSelfTest |
无第三方依赖的回归测试入口 |
3.2 主要流程
3.3 关键函数流程
ExpressionGenerator.generate(int operatorCount):
- 建立
operatorCount + 1个叶子节点森林。 - 森林节点数大于 1 时,随机取两个节点。
- 根据两个节点的值选择可行运算符:
+、-、×基本总可行;÷仅当两个值都为正且不相等时可行。
-将大值放在左,÷将小值放在左。- 合并后放回森林,直到只剩一个表达式。
4. 代码说明
4.1 Fraction 的约分与显示
public String toDisplayString() {
if (isWhole()) {
return numerator.toString();
}
if (numerator.compareTo(denominator) < 0) {
return numerator + "/" + denominator;
}
BigInteger[] qr = numerator.divideAndRemainder(denominator);
return qr[0] + "'" + qr[1] + "/" + denominator;
}
分数在构造时就约分,并保持非负;显示时区分整数、真分数和带分数。
4.2 表达式去重键
public String canonicalKey() {
if (isLeaf()) {
return "N:" + value.toDisplayString();
}
String leftKey = left.canonicalKey();
String rightKey = right.canonicalKey();
if (operator == Operator.ADD || operator == Operator.MULTIPLY) {
if (leftKey.compareTo(rightKey) > 0) {
String tmp = leftKey;
leftKey = rightKey;
rightKey = tmp;
}
}
return operator.name() + "(" + leftKey + "," + rightKey + ")";
}
+ 和 × 的直接左右子树被排序,所以 23 + 45 与 45 + 23、6 × 8 与 8 × 6 会得到相同键;- 和 ÷ 保持顺序。由于不进行结合律展平,1 + 2 + 3 与 3 + 2 + 1 的键不同,符合题目要求。
4.3 合法表达式生成
Expression.Operator chooseOperator(Fraction a, Fraction b) {
List<Expression.Operator> options = new ArrayList<>();
options.add(Expression.Operator.ADD);
options.add(Expression.Operator.SUBTRACT);
options.add(Expression.Operator.MULTIPLY);
if (!a.isZero() && !b.isZero() && !a.equals(b)) {
options.add(Expression.Operator.DIVIDE);
}
return options.get(random.nextInt(options.size()));
}
只有满足除法结果为正真分数的两个操作数才进入 ÷ 候选。合并时对 - 和 ÷ 自动调整左右顺序,使约束在生成期就被保证。
5. 测试运行
test.cmd 实际输出:
Passed: 20, Failed: 0
主要测试用例:
| 编号 | 用例 | 预期 | 结果 |
|---|---|---|---|
| 1 | Fraction.of(3,5) 显示 |
3/5 |
通过 |
| 2 | Fraction.of(19,8) 显示 |
2'3/8 |
通过 |
| 3 | 4/6 约分 |
2/3 |
通过 |
| 4 | 解析 2'3/8 |
19/8 |
通过 |
| 5 | 1/6 + 1/8 |
7/24 |
通过 |
| 6 | 1 + 2 * 3 |
7 |
通过 |
| 7 | (1 + 2) * 3 |
9 |
通过 |
| 8 | 6 × 8 与 8 × 6 去重键 |
相同 | 通过 |
| 9 | 23 + 45 与 45 + 23 去重键 |
相同 | 通过 |
| 10 | 1+2+3 与 3+2+1 去重键 |
不同 | 通过 |
| 11 | 3+(2+1) 与 1+2+3 去重键 |
相同 | 通过 |
| 12 | 5000 道随机题的结构约束 | 减法非负、除法为真分数、运算符数 ≤ 3 | 通过 |
| 13 | 5000 道题格式化再解析 | 值与答案一致 | 通过 |
| 14 | 1000 道批量生成 | 全部不重复 | 通过 |
| 15 | 判题样例 | 正确 2、错误 1,题号正确 | 通过 |
| 16 | 解析带弯引号的 2’3/8 |
19/8 |
通过 |
| 17 | 解析 Unicode 减号 5 − 2 |
3 |
通过 |
| 18 | -r 缺失 |
报错并输出帮助 | 通过 |
| 19 | -n 10000 -r 10 |
成功生成 10000 道 | 通过 |
| 20 | 一万道题自判 | 10000 道全部判为正确 | 通过 |
正确性依据:
- 生成器在构造时保证每个
-左侧值不小于右侧值,每个÷结果为小于 1 的正分数; - 去重键只交换
+和×的直接操作数,与题面定义一致; - 格式化/解析往返测试覆盖了优先级、括号、真分数和带分数;
- 对 10000 道题逐题自判,结果全部正确。
6. 项目小结
6.1 成败得失
做得较好的地方:
- 将“约束保证”前移到表达式生成阶段,而不是生成后反复重试;
- 去重键只对交换律排序,清楚地区分了
1+2+3与3+2+1; - 输出、解析和计算共用同一套分数模型,减少格式不一致。
可改进的地方:
- 当前
-r很小时(如r=1)可去重表达式数量有限,程序会明确报错;若需求继续收紧,可预先枚举可行表达式数量并给出更早提示; - 大批量生成时仍会构造字符串去重键,若题目规模再增大,可进一步使用结构化哈希缓存。
6.2 结对感受与闪光点
本次结对开发中,一人主要完成表达式模型、分数计算和生成器,另一人负责解析器、文件读写、判题和测试。双方在“减法非负、除法为真分数”两条约束上先统一了定义,后续编码冲突很少。
黄凯祺的闪光点:对表达式树和交换律去重键的分析很清晰,避免了一个容易写错的重复判断。
许俊曦的闪光点:测试覆盖了优先级、括号和带分数,发现问题后能快速定位到解析层。
建议:后续可以加入自动生成随机参考实现,对两个程序进行交叉验证;也可以在命令行增加随机种子,便于复现测试用例。

浙公网安备 33010602011771号