四则运算生成器

参与者
姓名:黄凯祺 学号: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

performance_chart

2.2 改进思路

  1. 表达式采用“森林合并”策略构造:先创建 operatorCount + 1 个叶子节点,然后逐步选择两个满足约束的子树合并。这样每次新增的 -÷ 在构造阶段就保证合法,避免“随机生成整棵树再整体重试”。
  2. 去重使用 HashSet<String>,键是表达式树的规范形式。规范形式只交换 +× 的直接左右子树,避免每次与已有题目逐题比较,使 10000 道题生成保持在线性哈希查找复杂度。
  3. 分数使用不可约的 Fraction,减少中间数值膨胀和答案格式归一化成本。
  4. 文件输出使用一次性 StringBuilder 拼接再 Files.writeString,避免 10000 次小写盘。

从代码路径和耗时数据看,消耗最大的函数是 ProblemSetGenerator.generate();其中 Expression.canonicalKey() 的字符串构建和 Fraction 的四则运算占主要比例。

3. 设计实现过程

3.1 类设计

职责
Fraction 不可约非负分数,提供加减乘除、比较、混合数格式化与解析
Expression 不可变表达式树,保存运算符、左右子树、值和规范去重键
ExpressionGenerator 生成满足“减法非负、除法为真分数”的表达式树
ExpressionFormatter 按优先级输出最少括号的中缀表达式
ExpressionParser 递归下降解析整数、真分数、带分数、括号和四种运算
ProblemSetGenerator 批量生成不重复题目,并写出题目与答案文件
Grader 读取题目和答案文件,统计正确与错误题号
Myapp 命令行参数解析、模式分派和帮助信息
ArithmeticSelfTest 无第三方依赖的回归测试入口

3.2 主要流程

flowchart TD A[Myapp 解析参数] --> B{是否 -e/-a} B -->|是| C[Grader 读取两个文件] C --> D[逐题解析并比较] D --> E[写 Grade.txt] B -->|否| F[检查 -r] F --> G[ProblemSetGenerator 循环生成] G --> H[ExpressionGenerator 构造合法表达式] H --> I[canonicalKey 去重] I --> J[写 Exercises.txt 和 Answers.txt]

3.3 关键函数流程

ExpressionGenerator.generate(int operatorCount)

  1. 建立 operatorCount + 1 个叶子节点森林。
  2. 森林节点数大于 1 时,随机取两个节点。
  3. 根据两个节点的值选择可行运算符:
    • +-× 基本总可行;
    • ÷ 仅当两个值都为正且不相等时可行。
  4. - 将大值放在左,÷ 将小值放在左。
  5. 合并后放回森林,直到只剩一个表达式。

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 + 4545 + 236 × 88 × 6 会得到相同键;-÷ 保持顺序。由于不进行结合律展平,1 + 2 + 33 + 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 × 88 × 6 去重键 相同 通过
9 23 + 4545 + 23 去重键 相同 通过
10 1+2+33+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+33+2+1
  • 输出、解析和计算共用同一套分数模型,减少格式不一致。

可改进的地方:

  • 当前 -r 很小时(如 r=1)可去重表达式数量有限,程序会明确报错;若需求继续收紧,可预先枚举可行表达式数量并给出更早提示;
  • 大批量生成时仍会构造字符串去重键,若题目规模再增大,可进一步使用结构化哈希缓存。

6.2 结对感受与闪光点

本次结对开发中,一人主要完成表达式模型、分数计算和生成器,另一人负责解析器、文件读写、判题和测试。双方在“减法非负、除法为真分数”两条约束上先统一了定义,后续编码冲突很少。

黄凯祺的闪光点:对表达式树和交换律去重键的分析很清晰,避免了一个容易写错的重复判断。

许俊曦的闪光点:测试覆盖了优先级、括号和带分数,发现问题后能快速定位到解析层。

建议:后续可以加入自动生成随机参考实现,对两个程序进行交叉验证;也可以在命令行增加随机种子,便于复现测试用例。

posted @ 2026-09-21 22:25  cagehuang  阅读(10)  评论(0)    收藏  举报