结对项目:自动生成小学四则运算题目的命令行程序

本作业属于:软件工程(计科 24 级 78 班)
作业要求:https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703
作业目标:结对完成一个命令行四则运算题目生成与判分程序,实践"需求 → 设计 → 编码 → 测试 → 性能 → 交付"全流程。

姓名:张吉安 学号:3124004489
姓名:侯剑锋 学号:3124004468
GitHub:https://github.com/2553a/arithmetic-generator

一、项目简介

本项目使用 Java 17 编写命令行程序,随机生成包含自然数、真分数和 1~3 个运算符的小学四则运算题。程序保证任何减法子表达式不产生负数、任何除法节点不除以零且商为真分数;通过表达式树 canonical form 去重,并提供独立批改模式。

# 生成
java -cp target\classes com.eso.arithmetic.Main -n 10 -r 10
# 批改
java -cp target\classes com.eso.arithmetic.Main -e Exercises.txt -a Answers.txt

程序生成 UTF-8 编码的 Exercises.txtAnswers.txtGrade.txt

二、PSP 表格

开始编码前,我们按照附录中的 PSP2.1 表格拆分并估计了工作量。项目结束后,两人结合开发、测试和博文整理过程,对实际用时进行了回溯记录。

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

最初预计生成算法风险最高,实际最容易忽略的是括号必须保持原树语义,以及去重只能交换 +/× 节点,不能使用结合律把整棵树展开排序。

三、设计实现过程

3.1 类与职责

项目采用 Maven 标准结构,共 12 个主要业务类或接口:

模块 职责
入口 Main 选择生成/批改模式,统一处理错误
CLI CommandLineParser 校验 -n/-r/-e/-a 和文件
模型 Fraction 精确运算、约分、解析、混合数格式化
模型 Expression 表达式统一接口
模型 NumberExpression 表达式树叶子
模型 BinaryExpression 二元节点、求值、格式化、canonical form
模型 Operator 运算符、优先级和计算行为
生成 ExerciseGenerator 随机树、递归约束、有界重试、去重
服务 ExerciseService 输出题目和答案
服务 ExpressionParser 把题目文本重新解析为表达式树
服务 GradeService 比较数学值并生成成绩
验证 ProjectVerifier 递归复核最终输出的全部约束
classDiagram Main --> CommandLineParser Main --> ExerciseGenerator Main --> ExerciseService Main --> GradeService GradeService --> ExpressionParser ExerciseGenerator --> Expression Expression <|.. NumberExpression Expression <|.. BinaryExpression NumberExpression --> Fraction BinaryExpression --> Fraction BinaryExpression --> Operator

3.2 生成流程

flowchart TD A[校验 -n 与 -r] --> B[随机选择 1~3 个运算符] B --> C[递归分配左右子树运算符数] C --> D[生成范围内自然数或真分数] D --> E{运算符} E -->|减法| F[比较左右值并按需交换] E -->|除法| G[检查除数非零且商为真分数] E -->|加法/乘法| H[构造节点] F --> I[计算 canonicalForm] G --> I H --> I I --> J{HashSet 已存在?} J -->|是| K[在最大次数内重试] J -->|否| L[加入结果] L --> M{达到 n?} M -->|否| B M -->|是| N[写入题目和答案]

我们没有直接拼接字符串,而是先构造表达式树,因此可以在每个内部节点检查约束,并按照真实树结构决定括号和重复关系。

四、关键代码说明

4.1 Fraction:精确运算

FractionBigInteger 保存分子、分母,不使用 double/float。构造时统一符号并约分:

if (denominator.signum() == 0) throw new IllegalArgumentException("分母不能为 0");
if (denominator.signum() < 0) {
    numerator = numerator.negate();
    denominator = denominator.negate();
}
BigInteger gcd = numerator.gcd(denominator);
this.numerator = numerator.divide(gcd);
this.denominator = denominator.divide(gcd);

因此 1/6 + 1/8 精确得到 7/24;批改时 5/610/12 相等;11/8 输出为 1’3/8

4.2 递归保证减法和除法约束

if (operator == Operator.SUBTRACT
        && left.evaluate().compareTo(right.evaluate()) < 0) {
    Expression temporary = left;
    left = right;
    right = temporary;
}
if (operator == Operator.DIVIDE) {
    if (right.evaluate().isZero()) throw new InvalidCandidate();
    Fraction quotient = left.evaluate().divide(right.evaluate());
    if (!quotient.isProperFraction()) throw new InvalidCandidate();
}

检查发生在每一个递归节点,而非只看最终答案。我们将除法真分数解释为严格满足 0 < numerator < denominator。无效候选会被丢弃,但尝试次数最多为 max(10000, n × 500),所以不会无限循环。

4.3 保留树结构的去重

String l = left.canonicalForm();
String r = right.canonicalForm();
if ((operator == Operator.ADD || operator == Operator.MULTIPLY)
        && l.compareTo(r) > 0) {
    String temporary = l; l = r; r = temporary;
}
return operator.name() + "(" + l + "," + r + ")";

生成器通过 canonicalForms.add(...) 同时完成查重和插入,平均查询为 O(1)。算法不展开整棵加法树:

  • 3 + (2 + 1)(1 + 2) + 3 重复;
  • (1 + 2) + 3(3 + 2) + 1 不重复。

4.4 括号和批改

格式化时,子节点优先级较低必须加括号;右子树优先级相同也保留括号,避免左结合改变原树:

boolean parentheses = binary.operator.precedence() < operator.precedence()
        || rightChild && binary.operator.precedence() == operator.precedence();

批改不是字符串比较,而是重新解析题目并比较分数值:

Fraction expected = ExpressionParser.parse(expressionText).evaluate();
Fraction actual = Fraction.parse(contentAfterNumber(answerLine));
(expected.equals(actual) ? correct : wrong).add(number);

非法答案只把当前题记为 Wrong,不会中断整次批改。

五、效能分析

5.1 方法与数据

我们分别生成 100、1000、5000、10000 题,每个规模运行三次;然后使用 Java Flight Recorder 对一次 -n 100000 -r 50 运行采样。性能测试、热点分析和图表整理实际用时约 45 分钟
image

题量 第1轮 第2轮 第3轮 平均
100 39 ms 39 ms 36 ms 38 ms
1,000 70 ms 60 ms 63 ms 64 ms
5,000 131 ms 119 ms 124 ms 125 ms
10,000 214 ms 219 ms 187 ms 207 ms

JFR 的 100000 题运行耗时 751 ms,得到 16 个执行样本:11 个主要位于 ExerciseService.writeBinaryExpression.formatFraction.format 及底层字符串/文件操作;3 个位于生成、canonical form 和 HashSet.add;2 个位于 BigInteger.gcd/divide。因此最大热点是输出与格式化。由于采样只有约 1 秒,这些比例用于定位方向,不作为精确 CPU 百分比。

5.2 改进思路

最重要的改进是把两两比较的 O(n²) 去重改成 canonicalForm + HashSet,使单次查重平均为 O(1),整体生成接近 O(n)。其他措施包括:

  • 设置最大尝试次数,避免小范围无限重试;
  • 批量构造文本行并以 UTF-8 写入;
  • 单题最多 3 个运算符,限制树和 canonical form 成本;
  • 保留 BigInteger 精确运算,因为数学正确性优先于微小的速度提升。

-n 10000 -r 10 压力测试中,两个输出文件均为 10000 行;重新解析验证后,canonical 重复、非法表达式、减法违规、除法违规均为 0。

六、测试运行

JUnit 5 最终结果:7 个测试类、22 个测试方法,Failures: 0, Errors: 0, Skipped: 0,Maven 为 BUILD SUCCESS

# 测试 输入/构造 预期 意义
1 自动约分 2/4 1/2 验证 gcd
2 精确加法 1/6+1/8 7/24 排除浮点误差
3 除零 1/2÷0 受控异常 保证安全
4 带分数 11/8 1’3/8 验证格式
5 两种引号 1'3/81’3/8 都等于 11/8 兼容输入
6 优先级 2×(3+4) 括号保留,值14 保持树语义
7 加法交换 23+4545+23 canonical相同 验证交换律
8 指定结合例 3+(2+1)(1+2)+3 相同 对应题目要求
9 禁止扁平化 (1+2)+3(3+2)+1 不同 保留树结构
10 非交换运算 8-33-8 不同 减法有序
11 批量生成 固定种子500题 全部唯一合法 递归检查约束
12 范围边界 -r 1 有限时间结束 排除死循环
13 等价答案 5/610/12 Correct 非字符串比较
14 非法答案 bad 当前题 Wrong 错误隔离
15 缺少参数 只给 -n 10 错误和帮助 CLI健壮性
16 压力验收 -n 10000 -r 10 10000行、0违规 性能与完整性

除单元测试外,ProjectVerifier 会把最终题目文本重新解析为新表达式树,再递归检查 canonical 唯一性、每个减法/除法节点、叶子范围和运算符数。这能发现仅检查内存对象无法发现的括号格式错误,因此我们能够较有把握地说明程序正确。

七、项目小结与结对感受

成功之处

  1. 先完成 Fraction 再构建表达式树,降低调试复杂度。
  2. 表达式树统一解决求值、括号、计数和去重,避免字符串拼接失控。
  3. 为题目强调的结合结构单独编写测试。
  4. HashSet 与有界重试支持 10000 题且不会死循环。
  5. 批改比较数学值,能处理等价分数和非法答案。

不足与教训

  1. “真分数”示例存在歧义,今后应更早向老师确认;本项目已在 README 说明解释。
  2. 极小范围与极大题量仍可能无法产生足量唯一题目,目前会清晰报错;可进一步研究枚举与随机混合策略。
  3. JFR 样本时间较短,后续可以引入 JMH,分别测量生成、格式化和 I/O。
  4. PSP 实际时间应开发过程中同步记录,而不是结束后回忆。

分工、感受及相互评价

  • 张吉安主要负责程序代码的设计与实现,包括精确分数、表达式树、随机题目生成、canonical form 去重、批改功能、命令行参数处理以及单元测试。
  • 侯剑锋主要负责博文撰写与项目材料整理,包括需求和设计过程说明、PSP 表格、性能数据整理、测试报告以及项目总结。
  • 两人共同完成需求核对、边界条件讨论、运行结果检查和最终材料复审。

张吉安的结对感受:
这次结对让我认识到,完成代码并不等于完成整个软件工程任务。编写程序时我更关注算法和测试是否正确,而侯剑锋会从读者和答辩者的角度追问“为什么这样设计”“数据能否证明程序正确”,这促使我把原本只存在于代码中的思路讲清楚。尤其是在整理去重规则、除法限制和性能测试结果时,文档视角帮助我重新检查了实现是否真正对应题目原文。两人的分工比较明确,但并不是简单地各做一半,而是代码和博文互相验证,这一点对最终质量帮助很大。

侯剑锋的结对感受:
在撰写博文的过程中,我需要先真正理解程序结构,才能准确解释 Fraction、表达式树、canonical form 和 HashSet 去重。张吉安在代码中划分的模块比较清晰,测试也覆盖了许多容易遗漏的边界情况,使我整理设计过程和测试依据时有充分材料。通过这次合作,我体会到技术文档不能只是罗列代码,而要说明需求、设计决策、验证方法和实际数据之间的联系。今后我希望在编码阶段更早参与讨论和测试,这样能减少项目结束后集中整理材料的压力。

彼此的闪光点与建议:

  • 侯剑锋的闪光点是信息整理能力强,能够把分散的需求、代码结构、测试结果和性能数据组织成清晰的博文,并能从读者角度发现说明不充分的地方。建议今后在项目开始时就同步建立博文框架和 PSP 记录,而不是等编码基本完成后再集中补充,这样实际用时和设计演变会记录得更准确。
  • 张吉安的闪光点是编码思路严谨,对减法、除法、括号和去重等关键约束都设计了对应测试,而且遇到边界问题时愿意回到需求原文分析。建议今后在实现复杂模块时及时记录关键决策和性能数据,并增加阶段性的代码说明,这会让搭档更快理解代码,也能降低后期编写文档和答辩准备的成本。

这次项目最大的收获是:不能只让程序“跑起来”,还要把数学规则转化成可验证的不变量,再用测试、性能数据和文档证明实现符合要求。结对的意义也不只是平分代码,更是让设计假设尽早接受另一位开发者的检查。

八、构建与复现

mvn "-Dmaven.repo.local=$PWD\.m2repo" clean test
java "-Dfile.encoding=UTF-8" -cp target\classes com.eso.arithmetic.Main -n 10 -r 10
java "-Dfile.encoding=UTF-8" -cp target\classes com.eso.arithmetic.Main -e Exercises.txt -a Answers.txt

项目地址:https://github.com/2553a/arithmetic-generator

posted @ 2026-09-20 21:37  2253  阅读(5)  评论(0)    收藏  举报