第三次作业

结对项目:小学四则运算题目生成与批改程序

姓名:王嵘烨 学号:3124004179

一、PSP2.1

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
· Estimate · 估计任务所需时间 30 25
Development 开发
· Analysis · 需求分析(包括学习新技术) 80 75
· Design Spec · 生成设计文档 60 70
· Design Review · 设计复审 30 35
· Coding Standard · 制定代码规范 20 15
· Design · 具体设计 70 80
· Coding · 具体编码 260 240
· Code Review · 代码复审 50 55
· Test · 测试、修改与回归 100 110
Reporting 报告
· Test Report · 测试报告 60 65
· Size Measurement · 计算工作量 30 25
· Postmortem & Process Improvement Plan · 事后总结与改进计划 60 70
合计 850 865

实际总耗时比预估多 15 分钟,偏差主要来自边界测试和性能图表的整理。编码时间少于预估,而测试与文档耗时略多,说明前期设计减少了实现返工,但我们低估了“证明程序正确”所需的时间。

二、需求分析

这道题的难点不是随机拼接字符串,而是让每一棵表达式子树都满足约束:减法不能产生负数、除法结果必须是真分数、运算符不超过 3 个,并且同一次生成中不能出现可通过交换 +× 左右子树得到的重复题。

我们将需求拆成五类不变量:

  1. 数值不变量:所有叶子小于 -r,分母也小于 -r,所有计算使用精确有理数。
  2. 结构不变量:每道题有 1~3 个二元运算符,括号必须准确表达树结构。
  3. 运算不变量:减法节点满足左值大于等于右值;除法节点满足 0 < 左值 ÷ 右值 < 1
  4. 唯一性不变量:只允许在 +× 节点交换左右孩子,不把不同结合结构强行展开或合并。
  5. 文件契约:题目、答案和批改结果均按题号顺序输出,并使用 UTF-8 编码。

批改模式不能直接比较字符串,因为 1/22/4 数值相等。程序需要安全地解析表达式,再用有理数比较计算结果。同时,解析器不能使用 eval,否则输入文件中的任意代码会带来安全风险。

三、设计与实现过程

3.1 模块组织

模块 主要类型或函数 职责
main.py build_parserrun_generationrun_grading 参数校验、模式分派和文件输出
numbers.py parse_numberformat_number 自然数、分数、带分数的双向转换
expression.py NumberBinaryevaluatecanonical_key 表达式树、精确求值、格式化和判重
parser.py _Parserparse_expression 按优先级递归下降解析题目
generator.py _build_candidategenerate_exercises 构造满足约束且不重复的题目
grader.py grade_filesformat_grade 读取文件、重新计算并统计对错
profile_generation.py benchmark_sizesprofile_snapshot 性能测量及 SVG 报告生成

项目共有 16 个 Python 文件(含测试和包初始化文件),合计约 1810 行。业务代码与测试代码分开存放,模块之间通过表达式树和 Fraction 值传递数据。

3.2 生成流程

生成过程可以概括为:

读取并校验 -n、-r
        │
        ▼
随机选择 1~3 个运算符槽位
        │
        ▼
递归划分左右子树的槽位,并同步得到子树精确值
        │
        ├─ 减法:较大值放左侧
        ├─ 除法:较小正值放左侧;条件不满足则换用其他运算符
        └─ 加/乘:直接构造
        │
        ▼
计算只对 +、× 交换不敏感的规范化键
        │
        ├─ 键已存在:丢弃候选题并继续
        └─ 键不存在:加入结果和集合
        │
        ▼
格式化表达式与答案,写入两个文本文件

-r 很小、要求的题量过大时,可用题目空间可能耗尽。生成器设置连续失败上限,达到上限后给出“增大 -r 或减小 -n”的提示,而不是无限循环;生成失败时也不会覆盖已有输出文件。

3.3 精确数值模型

所有数值统一使用 fractions.Fraction。这样 1/6 + 1/8 会直接得到约分后的 7/24,不会出现二进制浮点误差。输出时再根据整数部分和余数选择自然数、真分数或带分数格式。

3.4 唯一性定义

表达式树的规范化键递归保存运算符和两个子树。遇到 +× 时,只对当前节点的左右键排序;遇到 -÷ 时保持顺序。该算法不会应用结合律,因此它正好对应题目所要求的“有限次交换左右表达式”:

  • 23 + 4545 + 23 的键相同;
  • 3 + (2 + 1) 可在两个加法节点交换后变成 (1 + 2) + 3,两者的键相同;
  • (1 + 2) + 3(3 + 2) + 1 的树结构不能仅靠交换得到,键不同。

3.5 批改流程

批改器逐行检查题号,用递归下降解析器读取表达式,再按 ×÷ 高于 +- 且同级左结合的规则构造表达式树。答案文件中的缺失项、非法数值或重复题号统一判错,多余题号不影响已有题目。只有读取和计算全部成功后才写入 Grade.txt,避免错误输入破坏旧的批改结果。

四、关键代码说明

4.1 构造时维护运算约束

if operator == "÷":
    if Fraction(0) < left_value < right_value:
        return Binary("÷", left, right), left_value / right_value
    if Fraction(0) < right_value < left_value:
        return Binary("÷", right, left), right_value / left_value
    operator = rng.choice(_DIVISION_FALLBACKS)

if operator == "-" and left_value < right_value:
    left, right = right, left
    left_value, right_value = right_value, left_value

每次递归同时返回“表达式树、精确值”,所以当前节点无需再次遍历整棵子树。除法通过调整左右位置保证结果是真分数;两个值相等或含零时,当前节点切换为其他运算符。

4.2 交换等价判重

def canonical_key(node: Expression) -> CanonicalKey:
    if isinstance(node, Number):
        return ("number", node.value.numerator, node.value.denominator)

    left_key = canonical_key(node.left)
    right_key = canonical_key(node.right)
    if node.operator in COMMUTATIVE and right_key < left_key:
        left_key, right_key = right_key, left_key
    return (node.operator, left_key, right_key)

规范化键是可哈希元组,可直接放入集合。生成一个候选题只计算一次键,集合查重平均为常数时间。

4.3 必要括号

needs_parentheses = precedence < parent_precedence
if is_right_child and precedence == parent_precedence:
    needs_parentheses = True

优先级较低的子表达式必须加括号;位于右侧且与父节点同优先级时也保留括号,从而区分 a - (b - c)a - b - c,也能完整保留用于判重的结合结构。

4.4 安全解析与数值比较

解析器只识别数字、分数、带分数、四种运算符和括号。批改时比较 Fraction,因此输入 2/4 可以匹配标准答案 1/2,同时不会执行题目文件中的其他文本。

五、效能分析

5.1 测量方法

效能分析与优化共花费约 45 分钟。我们使用 time.perf_counter 测量不同题量的端到端生成耗时,并使用 Python cProfile 统计 10000 道题时各函数的自身耗时和累计耗时。复现命令为:

python profile_generation.py

测试环境为 Windows 11、Python 3.12.13,参数为 -n 10000 -r 50。一次实测结果如下;不同机器和后台负载会造成小幅波动。

题目数 耗时(秒) 吞吐量(题/秒)
10 0.000390 25,673.94
100 0.002802 35,687.52
1,000 0.035293 28,334.55
10,000 0.301567 33,160.13

performance

5.2 热点函数

performance-hotspots

cProfile 本次运行总耗时为 1.139168 秒。项目代码中累计耗时最大的函数是 _build_candidate,累计 0.953676 秒,占分析总耗时 83.7%。这符合程序结构:每个表达式节点都从该递归函数构造,它还包含随机选择、分数构造和节点初始化等子调用。

图中使用累计耗时,因此父函数的时间包含子函数,百分比之和可能超过 100%,不能把它们直接相加。详细函数表和可复现数据保存在 docs/performance-results.md

5.3 改进思路

性能改进集中在减少重复工作:

  1. 构造和值同步返回:原本可以先生成树、再完整求值,但这会在每个约束节点重复遍历;现在递归结果直接携带精确值。
  2. 规范化键只计算一次:候选题生成后只构造一个键,并放入 set 完成查重。
  3. 复用左右子树:随机得到除法但不满足真分数条件时,不重新生成整棵树,只替换当前运算符。
  4. 按需加括号:格式化阶段不创建多余中间树,只根据优先级输出必要括号。

优化后,10000 道题的普通计时约为 0.30 秒,满足一次生成一万道题的要求。分析模式会记录每次函数调用,因此耗时高于普通运行,这是分析工具本身的正常开销。

六、测试运行

我们先为数值、表达式、生成器和批改器编写单元测试,再添加命令行集成测试与 10000 题压力测试。完整命令为:

python -m unittest discover -s tests -v

本次实际运行 46 项测试,全部通过,总耗时 1.180 秒。下面列出其中 14 个代表性测试场景:

编号 输入或场景 预期结果 实际结果
1 -r 10,不传 -n 生成 10 道题和 10 个答案 通过
2 -n 5 -r 10 两个文件各 5 行,题号连续 通过
3 -n 2,缺少 -r 显示帮助信息并以参数错误退出 通过
4 -n 0-r -1、非数字参数 拒绝非法参数 通过
5 -n 10 -r 1 仅使用数值 0,仍生成小规模不同结构 通过
6 固定随机种子生成 500 道 全部满足范围、运算和唯一性约束 通过
7 检查所有减法子树 每个节点左值大于等于右值 通过
8 检查所有除法子树 每个商严格大于 0 且小于 1 通过
9 -n 10000 -r 50 10000 个规范化键全部不同 通过
10 1/6 + 1/8 精确结果为 7/24 通过
11 a - (b - c) 等右结合结构 格式化后重新解析仍保持原树 通过
12 正确、错误、缺失和非法答案混合 分别进入 Correct 或 Wrong 通过
13 同一答案题号出现两次 该题判错 通过
14 题目文件语法错误且已有 Grade.txt 返回错误,不覆盖旧文件 通过

能够判断程序正确,主要依靠四层证据:基础函数给出明确输入输出;固定种子批量检查每棵子树的不变量;规范化键专门覆盖交换和结合结构;命令行测试从参数一直验证到文件内容。最后再用 10000 题场景覆盖规模要求,而不只依赖少量人工样例。

七、源代码管理

项目按可独立检查的增量组织为 6 次提交:

次序 Commit message 内容
1 chore: scaffold the arithmetic quiz project 项目骨架、忽略规则和测试入口
2 feat: implement exact expression parsing and formatting 数值与表达式核心
3 feat: generate valid unique arithmetic exercises 约束生成与判重
4 feat: add exercise answer grading 文件批改与统计
5 test: strengthen edge cases and benchmark generation 边界测试、性能测量和图表
6 docs: add usage guide and project report README 与项目报告

每次提交只覆盖一个清晰阶段,便于复审和回退。运行生成的 Exercises.txtAnswers.txtGrade.txt,本地 Python 环境以及过程性文件均已加入 .gitignore

八、项目小结与结对感受

成败得失

做得比较好的地方是先确定表达式树和精确有理数模型,再写文件输入输出。这样生成、判重、格式化、重新解析和批改都围绕同一个数据模型展开,没有出现多个模块各自解释表达式的情况。测试也不是只核对最终答案,而是遍历所有子树验证局部约束,能更快定位错误。

不足之处是最初对极小 -r 的可用题目空间考虑不够。随机生成器如果没有停滞上限,会在题目空间不足时长时间运行。增加明确的失败条件和不覆盖旧文件的行为后,异常路径才完整。性能分析阶段也发现,单看总耗时无法解释瓶颈,加入 cProfile 热点图后结论更有依据。

后续如果继续改进,可以加入可选随机种子便于用户复现实例、增加命令行输出目录参数,并把不同题型比例做成配置;同时可以为 10000 题以上的极端场景研究分层抽样,进一步降低高重复率范围下的重试次数。

总体而言,本次结对完成了题目生成、精确求值、去重、文件批改、万题规模测试和性能分析。更重要的是,我们把需求中的自然语言规则转成了数据结构、不变量和自动化测试,使程序的正确性能够被持续验证。

posted @ 2026-09-22 01:29  lokijinjin  阅读(4)  评论(0)    收藏  举报