第三次作业
结对项目:小学四则运算题目生成与批改程序
姓名:王嵘烨 学号:3124004179
- GitHub 项目地址:https://github.com/lokidundun/SEWork3
一、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 个,并且同一次生成中不能出现可通过交换 +、× 左右子树得到的重复题。
我们将需求拆成五类不变量:
- 数值不变量:所有叶子小于
-r,分母也小于-r,所有计算使用精确有理数。 - 结构不变量:每道题有 1~3 个二元运算符,括号必须准确表达树结构。
- 运算不变量:减法节点满足左值大于等于右值;除法节点满足
0 < 左值 ÷ 右值 < 1。 - 唯一性不变量:只允许在
+、×节点交换左右孩子,不把不同结合结构强行展开或合并。 - 文件契约:题目、答案和批改结果均按题号顺序输出,并使用 UTF-8 编码。
批改模式不能直接比较字符串,因为 1/2 与 2/4 数值相等。程序需要安全地解析表达式,再用有理数比较计算结果。同时,解析器不能使用 eval,否则输入文件中的任意代码会带来安全风险。
三、设计与实现过程
3.1 模块组织
| 模块 | 主要类型或函数 | 职责 |
|---|---|---|
main.py |
build_parser、run_generation、run_grading |
参数校验、模式分派和文件输出 |
numbers.py |
parse_number、format_number |
自然数、分数、带分数的双向转换 |
expression.py |
Number、Binary、evaluate、canonical_key |
表达式树、精确求值、格式化和判重 |
parser.py |
_Parser、parse_expression |
按优先级递归下降解析题目 |
generator.py |
_build_candidate、generate_exercises |
构造满足约束且不重复的题目 |
grader.py |
grade_files、format_grade |
读取文件、重新计算并统计对错 |
profile_generation.py |
benchmark_sizes、profile_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 + 45与45 + 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 |

5.2 热点函数

cProfile 本次运行总耗时为 1.139168 秒。项目代码中累计耗时最大的函数是 _build_candidate,累计 0.953676 秒,占分析总耗时 83.7%。这符合程序结构:每个表达式节点都从该递归函数构造,它还包含随机选择、分数构造和节点初始化等子调用。
图中使用累计耗时,因此父函数的时间包含子函数,百分比之和可能超过 100%,不能把它们直接相加。详细函数表和可复现数据保存在 docs/performance-results.md。
5.3 改进思路
性能改进集中在减少重复工作:
- 构造和值同步返回:原本可以先生成树、再完整求值,但这会在每个约束节点重复遍历;现在递归结果直接携带精确值。
- 规范化键只计算一次:候选题生成后只构造一个键,并放入
set完成查重。 - 复用左右子树:随机得到除法但不满足真分数条件时,不重新生成整棵树,只替换当前运算符。
- 按需加括号:格式化阶段不创建多余中间树,只根据优先级输出必要括号。
优化后,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.txt、Answers.txt、Grade.txt,本地 Python 环境以及过程性文件均已加入 .gitignore。
八、项目小结与结对感受
成败得失
做得比较好的地方是先确定表达式树和精确有理数模型,再写文件输入输出。这样生成、判重、格式化、重新解析和批改都围绕同一个数据模型展开,没有出现多个模块各自解释表达式的情况。测试也不是只核对最终答案,而是遍历所有子树验证局部约束,能更快定位错误。
不足之处是最初对极小 -r 的可用题目空间考虑不够。随机生成器如果没有停滞上限,会在题目空间不足时长时间运行。增加明确的失败条件和不覆盖旧文件的行为后,异常路径才完整。性能分析阶段也发现,单看总耗时无法解释瓶颈,加入 cProfile 热点图后结论更有依据。
后续如果继续改进,可以加入可选随机种子便于用户复现实例、增加命令行输出目录参数,并把不同题型比例做成配置;同时可以为 10000 题以上的极端场景研究分层抽样,进一步降低高重复率范围下的重试次数。
总体而言,本次结对完成了题目生成、精确求值、去重、文件批改、万题规模测试和性能分析。更重要的是,我们把需求中的自然语言规则转成了数据结构、不变量和自动化测试,使程序的正确性能够被持续验证。

浙公网安备 33010602011771号