第三周作业:结对项目

结对项目:小学四则运算题目生成器

成员 学号
吴与同 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>,它主要在做分数约分。这个步骤不能直接省掉,而当前速度已经远高于一万题的要求,所以没有继续做会让代码变复杂的小优化。

JFR 性能采样图

四、测试

项目没有引入测试框架,用一个简单的反射测试器运行测试,因此在没有网络的机器上也能直接验证。目前共有 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 做了一次从生成题目、保存答案到批改统计的完整回环测试。

给对方的闪光点或建议

吴与同写给赵海翔:赵海翔在命令行参数和文件处理部分比较细心,除了正常输入,还主动考虑了缺少参数、答案缺失和格式错误这些情况,让程序在评测时不容易因为一行异常输入直接退出。测试时他也比较愿意把生成、保存、读取和批改连起来跑一遍。后面可以继续加强对异常输入的分类整理,这样代码里的错误提示会更加统一。

吴与同在表达式树和分数计算部分投入比较多,尤其是把减法非负、除法真分数和交换等价去重这些容易混在一起的要求拆开处理,后面测试起来清楚了很多。代码复审时他能根据题目示例检查树形,而不是只看最后答案是否相同。建议后面在关键的生成规则旁边再补一两句注释,方便第一次阅读代码的人快速理解为什么要交换左右子树。

posted @ 2026-09-20 11:10  FLC_Niko  阅读(31)  评论(0)    收藏  举报