第三周作业:结对项目
结对项目:小学四则运算题目生成器
| 成员 | 学号 |
|---|---|
| 吴与同 | 3124004108 |
| 赵海翔 | 3124004113 |
Github 项目地址:https://github.com/FLC-niko/3124004108/tree/main/pair-arithmetic
一、PSP
我们在动手前先把需求拆成表达式生成、精确计算、去重、文件输入输出和测试几部分,再估算时间。实际耗时需要结合两个人各自的记录,提交前把最后一列补上。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 45 |
| · Estimate | · 估计任务需要多少时间 | 20 | 10 |
| · Analysis | · 需求分析(包括学习新技术) | 90 | 78 |
| · Design Spec | · 生成设计文档 | 60 | 26 |
| · Design Review | · 设计复审 | 30 | 33 |
| · Coding Standard | · 制定代码规范 | 20 | 12 |
| · Design | · 具体设计 | 90 | 87 |
| · Coding | · 具体编码 | 300 | 264 |
| · Code Review | · 代码复审 | 60 | 39 |
| · Test | · 测试、修改和提交 | 180 | 102 |
| · Test Report | · 测试报告 | 60 | 47 |
| · Size Measurement | · 计算工作量 | 20 | 22 |
| · Postmortem & Process Improvement Plan | · 事后总结与改进计划 | 60 | 64 |
| 合计 | 1020 | 829 |
二、设计与实现
一开始最容易想到的是边生成字符串边计算,不过这样很难处理括号,也不方便判断两道题交换左右以后是不是重复。所以我们把一道题保存成一棵表达式树:数字是叶子,运算符是中间节点。每个节点都能给出自己的值、运算符数量、显示文本和用于判重的结构键。
项目里主要有下面几部分:
Main
├── ExpressionGenerator 生成表达式并检查约束
│ ├── Expression 表达式树接口
│ ├── Rational 精确分数运算
│ └── ExpressionKey 交换等价去重
├── ExerciseFiles 读写题目和答案文件
└── AnswerGrader 批改答案
└── ExpressionParser 重新解析并计算题目
1. 分数计算
Rational 用两个 BigInteger 保存分子、分母,每次构造都会处理正负号并约分。这样 1/6 + 1/8 会直接得到 7/24,不用考虑 double 的误差。输出时再根据大小显示成整数、真分数或带分数。
关键思路如下:
public Rational add(Rational other) {
return new Rational(
numerator.multiply(other.denominator)
.add(other.numerator.multiply(denominator)),
denominator.multiply(other.denominator));
}
2. 生成时保证题目合法
每道题随机使用 1~3 个运算符。构造减法节点时,如果左边小于右边就交换两棵子树;构造除法节点时,先排除 0 和相等的情况,再让较小的正数放在左边,最后确认结果严格大于 0 且小于 1。因为检查是在每个节点上做的,所以不只是最终答案,中间计算也不会出现负数或不符合要求的除法。
if (operator == Operator.SUBTRACT && left.value().compareTo(right.value()) < 0) {
Expression temporary = left;
left = right;
right = temporary;
}
if (operator == Operator.DIVIDE) {
// 排除 0、相等和大于等于 1 的结果
...
return division.value().isProperFraction() ? division : null;
}
3. 判重
ExpressionKey 按树形递归保存结构。加法和乘法比较时允许左右交换,减法和除法仍按原顺序比较。这里没有直接把连续加法全部排序,因为题目给的规则只是交换左右子表达式,不是任意使用结合律改变树形。
例如 3 + (2 + 1) 和 (1 + 2) + 3 能通过交换得到相同结构;但 (1 + 2) + 3 和 (3 + 2) + 1 的树形对应不上,仍保留为不同题目。
if (operator.commutative()) {
return sameOrder || left.equals(key.right) && right.equals(key.left);
}
return sameOrder;
4. 批改功能
批改时,程序读取 Exercises.txt,用递归下降解析器按“括号→乘除→加减”的顺序重新计算每道题,再和答案文件中相同编号的答案比较。答案会先约分,所以 2/4 和 1/2 会判为相同;缺失或格式错误的答案会记入 Wrong。
三、效能分析
我们用 JDK 自带的 JFR 做执行采样。为了避免程序一闪而过采不到数据,基准测试固定生成 10,000 题并连续运行 60 轮。
性能分析、修改和复测耗时:【请按两人的真实记录填写】分钟。
第一次采样里,Rational.toString 占项目热点样本的 40.7%。检查代码后发现,当时的去重方案会把整棵表达式拼成一条长字符串。它确实能判重,但每道候选题都会做分数转字符串和字符串拼接。
后来把判重改成 ExpressionKey 对象结构:数字节点直接保留约分后的分数,二元节点保留运算符和两个子键。加法、乘法的键允许左右交换,规则没有变化,但临时字符串少了很多。
| 阶段 | 60 轮总耗时 | 每轮 10,000 题平均耗时 | 生成速度 |
|---|---|---|---|
| 改进前 | 1.237 s | 20.611 ms | 485,185 题/s |
| 改进后 | 0.992 s | 16.528 ms | 605,036 题/s |
平均耗时减少约 19.8%。优化后的最大热点是 Rational.<init>,它主要在做分数约分。这个步骤不能直接省掉,而当前速度已经远高于一万题的要求,所以没有继续做会让代码变复杂的小优化。

四、测试
项目没有引入测试框架,用一个简单的反射测试器运行测试,因此在没有网络的机器上也能直接验证。目前共有 21 项自动测试,覆盖了下面这些情况:
| 类型 | 测试内容 |
|---|---|
| 分数 | 约分、带分数显示、普通/弯单引号解析、精确加法 |
| 去重 | 加法和乘法交换、减法保持顺序、题目给出的两组树形例子 |
| 生成约束 | 运算符不超过 3 个、减法非负、除法为真分数、-r 1 |
| 解析 | 运算优先级、括号、带分数 |
| 批改 | 正确答案、错误答案、非法答案、答案缺失、输出格式 |
| 命令行 | 正常生成、缺少 -r、生成后回读批改 |
运行命令:
bash scripts/test.sh
bash scripts/stress-test.sh
普通测试结果为 21/21 通过。压力测试另外生成了 10,000 道题,全部通过等价去重和递归约束检查;写入文件后再用批改功能回读,结果是 Correct 10,000、Wrong 0。
我们认为这些测试覆盖了程序最容易出错的边界:分数精度、运算优先级、每个中间节点的合法性、交换等价题目和一万题规模。随机生成测试使用固定种子,失败时也能稳定复现。
五、运行方法
生成题目:
java -jar Myapp.jar -n 10 -r 10
批改答案:
java -jar Myapp.jar -e Exercises.txt -a Answers.txt
Windows 下也可以运行仓库中的 Myapp.bat。生成结果分别在 Exercises.txt、Answers.txt,批改结果在 Grade.txt。
六、项目小结
这次最费时间的地方不是四则运算本身,而是把题目中的自然语言限制准确变成代码:特别是“中间过程不为负数”“除法节点的结果是真分数”,以及只允许交换 +、× 左右子树的去重规则。表达式树把这几个问题放到了同一种数据结构里,后面增加批改解析和性能优化也比较顺。
从过程上看,先把分数和表达式树测稳,再接命令行和文件,最后做一万题测试,比把全部功能堆在一起以后再排错省事。性能分析也提醒我们,直觉上很轻的字符串拼接,在大量随机候选题里会积累成主要开销。
结对分工与感受
这次结对我们是按功能模块分工的。吴与同主要负责精确分数、表达式树和题目生成部分,包括减法非负、除法真分数以及加法和乘法的交换等价去重。赵海翔主要负责命令行参数、文件输入输出、答案批改和测试脚本,并整理了运行说明和性能分析材料。
代码没有完全交给一个人独立完成。每完成一部分,我们都会一起用小规模样例检查输出,再运行完整测试;遇到 -r 1、带分数、括号表达式、答案缺失和一万题生成这些边界情况时,两个人一起看测试结果并修改。最后又用打包后的 JAR 做了一次从生成题目、保存答案到批改统计的完整回环测试。
给对方的闪光点或建议
吴与同写给赵海翔:赵海翔在命令行参数和文件处理部分比较细心,除了正常输入,还主动考虑了缺少参数、答案缺失和格式错误这些情况,让程序在评测时不容易因为一行异常输入直接退出。测试时他也比较愿意把生成、保存、读取和批改连起来跑一遍。后面可以继续加强对异常输入的分类整理,这样代码里的错误提示会更加统一。
吴与同在表达式树和分数计算部分投入比较多,尤其是把减法非负、除法真分数和交换等价去重这些容易混在一起的要求拆开处理,后面测试起来清楚了很多。代码复审时他能根据题目示例检查树形,而不是只看最后答案是否相同。建议后面在关键的生成规则旁边再补一两句注释,方便第一次阅读代码的人快速理解为什么要交换左右子树。
浙公网安备 33010602011771号