小学四则运算题目生成与自动批改程序
小学四则运算题目生成与自动批改程序
| 项目 | 内容 |
|---|---|
| 所属课程 | 计科24级78班 - 广东工业大学 - 班级博客 |
| 作业要求 | 本次作业要求 |
| 作业类型 | 软件工程第二次作业 |
| 完成方式 | 单人完成 |
| GitHub 项目地址 | https://github.com/yyxyxx/youyong/tree/main/PairProject |
一、项目基本信息
| 成员 | 姓名 | 学号 |
|---|---|---|
| 成员一 | 叶浚轩 | 3124004447 |
本项目使用 Python 标准库实现小学四则运算题目的自动生成、标准答案计算和答案批改。程序支持自然数、真分数、带分数、括号以及 +、−、×、÷ 四则运算,一次可以生成 10000 道互不重复的题目。
程序提供两个命令行模式:
- 生成模式:根据
-n和-r生成Exercises.txt与Answers.txt。 - 批改模式:根据
-e和-a读取题目与答案,生成Grade.txt。
主要运行命令:
python Myapp.py -n 10 -r 10
python Myapp.py -e Exercises.txt -a Answers.txt
本项目只使用 Python 标准库,不依赖第三方包,也不读写参数规定之外的文件。
二、PSP2.1 时间记录
单位:分钟。
| PSP2.1 | Personal Software Process Stages | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 30 | 20 |
| · Estimate | 估计这个任务需要多少时间 | 30 | 20 |
| Development | 开发 | 370 | 355 |
| · Analysis | 需求分析 | 45 | 50 |
| · Design Spec | 生成设计文档 | 45 | 40 |
| · Design Review | 设计复审 | 20 | 20 |
| · Coding Standard | 代码规范 | 15 | 15 |
| · Design | 具体设计 | 40 | 35 |
| · Coding | 具体编码 | 150 | 140 |
| · Code Review | 代码复审 | 40 | 35 |
| · Test | 测试与修改 | 70 | 55 |
| Reporting | 报告 | 85 | 75 |
| · Test Report | 测试报告 | 35 | 30 |
| · Size Measurement | 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | 事后总结和改进计划 | 35 | 30 |
| 合计 | 总计 | 485 | 450 |
实际开发过程中,需求分析和代码复审花费的时间比预估略多,主要原因是需要反复确认“交换律查重”“除法结果必须为真分数”等规则的准确含义。编码阶段通过先设计表达式树、再实现生成和批改,减少了后期返工。
三、需求分析与实现约定
3.1 生成模式
python Myapp.py -n 10 -r 10
参数说明:
-n指定生成题目的数量,必须为正整数。-r指定题面中基础数值的范围,必须为正整数。- 自然数、真分数分子与分母、带分数整数部分都小于
r。 - 每道题包含 1~3 个运算符。
- 生成的题目和标准答案分别写入当前目录的
Exercises.txt与Answers.txt。
3.2 批改模式
python Myapp.py -e Exercises.txt -a Answers.txt
程序按行读取题目和答案,使用精确分数进行比较,并输出:
Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)
批改模式不需要 -r 参数,结果写入当前目录的 Grade.txt。
3.3 必须满足的约束
- 任意减法的左值必须大于等于右值,因此所有中间结果都不能为负数。
- 任意除法的除数不能为 0。
- 除法结果必须严格满足
0 < e1 ÷ e2 < 1,也就是必须为真分数。 - 每道题的运算符数量不超过 3。
- 不能通过有限次交换
+、×的左右操作数得到重复题目。 - 真分数输出为
3/5,带分数输出为2’3/8。 - 程序需要支持一次生成 10000 道题。
3.4 对需求中歧义的处理
除法结果的范围。 需求写明 e1 ÷ e2 的结果应是真分数,因此本项目采用严格判断:除数不能为 0,并且 0 < e1 ÷ e2 < 1。带分数可以作为操作数或其他运算的结果,但不能作为除法的真分数结果。
数值范围的含义。 -r 只限制题目中直接出现的基础数值,不限制中间结果和最终答案。例如 -r 10 时,8 + 9 仍然合法,答案是 17。
重复题目的判断。 去重只允许交换加法和乘法的左右子树,不额外使用结合律或分配律。例如 3 + (2 + 1) 和 1 + 2 + 3 重复,但 1 + 2 + 3 和 3 + 2 + 1 不重复。
四、设计与实现过程
4.1 模块划分
| 模块 | 主要职责 |
|---|---|
Expr |
不可变表达式树节点,保存精确值、运算符和左右子树 |
combine |
合并两个子表达式,检查减法和除法约束 |
build_atom_pool |
构造小于 r 的自然数、真分数和带分数 |
QuestionGenerator |
随机生成题目并维护查重集合 |
canonical_key |
按交换律生成表达式规范键 |
format_fraction |
输出自然数、真分数或带分数 |
format_expression |
根据优先级输出带必要括号的题面 |
_ExpressionParser |
将题面文本安全解析为表达式树 |
parse_grade_inputs |
读取并校验题目与答案文件 |
grade |
按题号比较答案并统计对错 |
run |
解析命令行参数并统一处理错误码 |
项目的核心数据结构为:
Expr
├── value:Fraction 精确数值
├── op:运算符
├── left:左子表达式
└── right:右子表达式
4.2 为什么使用表达式树
如果只拼接字符串,程序只能得到一段文本,很难判断某个中间减法是否为负数,也无法确定某个除法子表达式的商是否为真分数。表达式树保存了完整的计算结构,可以递归完成:
- 计算每个子表达式的精确值;
- 检查每个减法和除法节点;
- 统计运算符数量;
- 生成交换律规范键;
- 根据优先级渲染题目;
- 将题面重新解析后用于批改。
同一个表达式结构同时服务于生成、答案计算和批改,避免了多套逻辑产生不一致。
4.3 题目生成流程
约束在每次合并子表达式时立刻执行,而不是等整棵树生成完以后再统一检查。例如减法节点在合并时立即验证 left >= right,除法节点立即验证除数非零以及商为真分数。这样无效分支会尽早终止,减少无效递归。
五、关键代码与思路说明
5.1 使用 Fraction 精确计算
程序全程使用标准库 fractions.Fraction,不把分数转换成浮点数:
result = Fraction(1, 6) + Fraction(1, 8) # Fraction(7, 24)
输出时根据分母和整数部分分类处理:
def format_fraction(value: Fraction) -> str:
if value.denominator == 1:
return str(value.numerator)
if value.numerator < value.denominator:
return f"{value.numerator}/{value.denominator}"
whole, remainder = divmod(value.numerator, value.denominator)
return f"{whole}’{remainder}/{value.denominator}"
因此 Fraction(19, 8) 会输出为 2’3/8,并与题目规定的书写格式保持一致。
5.2 校验与运算合并
combine 函数在计算节点值的同时检查所有必要约束:
def combine(op: str, left: Expr, right: Expr) -> Expr | None:
if op == "−":
if left.value < right.value:
return None
value = left.value - right.value
elif op == "÷":
if right.value == 0:
return None
value = left.value / right.value
if not (0 < value < 1):
return None
# 其他运算符的计算
return Expr(value=value, op=op, left=left, right=right)
如果某个分支不满足约束,就返回 None 让生成器重新构造,不会把错误表达式写入最终结果。
5.3 按交换律查重
去重函数保留表达式树的层级,只对 + 和 × 节点排序左右子树:
@lru_cache(maxsize=None)
def canonical_key(expression: Expr) -> str:
if expression.is_atom:
return f"N:{expression.value.numerator}/{expression.value.denominator}"
left_key = canonical_key(expression.left)
right_key = canonical_key(expression.right)
if expression.op in {"+", "×"} and right_key < left_key:
left_key, right_key = right_key, left_key
return f"{expression.op}({left_key},{right_key})"
查重规则对应关系如下:
| 表达式对 | 判定 |
|---|---|
23 + 45 与 45 + 23 |
重复 |
6 × 8 与 8 × 6 |
重复 |
3 + (2 + 1) 与 1 + 2 + 3 |
重复 |
1 + 2 + 3 与 3 + 2 + 1 |
不重复 |
最后一项的原因是:1 + 2 + 3 按左结合解析为 (1 + 2) + 3,而 3 + 2 + 1 解析为 (3 + 2) + 1。仅允许交换根节点或子节点的左右位置时,两者无法互相转换。
5.4 按优先级渲染括号
题目输出时只在必要时添加括号:
- 子表达式优先级低于父节点时添加括号;
- 右侧子表达式与父节点优先级相同时添加括号;
- 其他位置利用四则运算的左结合规则省略括号。
这样可以保证输出题面重新解析后得到与原表达式相同的树结构。例如 1 + (2 + 3) 不能被错误地输出为 1 + 2 + 3,否则左结合解析后结构会改变。
5.5 自动批改
批改器将题目重新解析为 Expr,再将答案解析为 Fraction:
for number, (expression, answer) in enumerate(zip(expressions, answers), start=1):
if expression.value == answer:
correct.append(number)
else:
wrong.append(number)
因为比较的是约分后的 Fraction,所以 2/4 和 1/2 会被视为同一个正确答案。
六、测试与运行结果
6.1 测试环境与命令
实测环境:Windows 64 位,Python 3.12.14,仅使用标准库,测试框架为 unittest。
python -m unittest discover -s tests -v
测试结果:
Ran 22 tests in 0.917s
OK
22 个自动化测试覆盖分数格式化、带分数解析、运算优先级、减法非负、除法真分数、括号输出、交换律查重、随机生成约束、命令行参数、文件输出和批改统计。
6.2 代表性测试用例
| 编号 | 测试内容 | 预期结果 | 实际结果 |
|---|---|---|---|
| 1 | Fraction(3, 5) 格式化 |
输出 3/5 |
通过 |
| 2 | Fraction(19, 8) 格式化 |
输出 2’3/8 |
通过 |
| 3 | 解析 2’3/8 |
得到 19/8 |
通过 |
| 4 | 1 + 2 × 3 |
答案为 7 |
通过 |
| 5 | 3 ÷ 4 − 1/4 |
答案为 1/2 |
通过 |
| 6 | 1/2 − 1/3 |
答案为 1/6,过程非负 |
通过 |
| 7 | 2 ÷ 1 |
拒绝,商不是真分数 | 通过 |
| 8 | 1 ÷ 0 |
报告除数不能为 0 | 通过 |
| 9 | 1 + (2 + 3) |
输出时保留必要括号 | 通过 |
| 10 | 2 + 3 与 3 + 2 |
判定为重复 | 通过 |
| 11 | 3 + (2 + 1) 与 1 + 2 + 3 |
判定为重复 | 通过 |
| 12 | 1 + 2 + 3 与 3 + 2 + 1 |
判定为不重复 | 通过 |
| 13 | 生成 500 道随机题 | 无重复、无负过程、除法均为真分数 | 通过 |
| 14 | -n 20 -r 10 |
两个文件各 20 行 | 通过 |
| 15 | 批改一题正确、一题错误 | 输出正确和错误题号 | 通过 |
| 16 | 题目与答案行数不一致 | 返回错误码 1 | 通过 |
| 17 | 缺少 -r 参数 |
返回参数错误码 2 并打印帮助 | 通过 |
6.3 题目与答案展示
运行:
python Myapp.py -n 10 -r 10
一次实际运行生成的题目如下:
2’5/9 × 5/6 =
1’2/7 + 5’2/7 + (7’2/7 − 5’4/5) =
2’2/5 ÷ 3’3/4 =
8’2/9 − 7’3/7 =
2’5/7 × (8’3/8 + 4’8/9) − 1’8/9 =
2/3 × 4’4/5 =
8’3/4 + (8’3/5 − 2’2/5) =
1/2 × 5’2/3 × 9’6/7 =
5’3/7 − 3/5 × 8’7/8 =
7’4/7 ÷ 9’1/9 =
对应答案如下:
2’7/54
8’2/35
16/25
50/63
34’19/168
3’1/5
14’19/20
27’13/14
29/280
477/574
题面中包含整数、真分数、带分数、括号以及四种运算符,输出格式可直接作为程序的批改输入。
6.4 一万道题生成
python Myapp.py -n 10000 -r 10
实测两个输出文件均有 10000 行。独立校验脚本重新读取生成结果,逐题完成以下检查:
- 重新解析题面并计算答案;
- 比较计算结果与
Answers.txt; - 统计
canonical_key是否重复; - 检查运算符数量是否不超过 3;
- 检查所有减法过程是否非负;
- 检查所有除法是否满足真分数要求。
最终验证结果:
独立校验:10000 题全部满足范围、运算、查重和答案一致性要求。
6.5 正确性依据与限制
正确性依据主要包括:
- 使用精确分数计算,不存在浮点误差。
- 约束在表达式树合并时立即执行,错误分支不会进入结果集合。
- 查重只使用需求允许的交换规则,不额外引入结合律或分配律。
- 每批题目都可以重新解析并求值,题面、解析器、答案计算形成闭环验证。
- 自动化测试覆盖正常路径、边界路径和参数错误路径。
已知限制:极端小的 r 可能无法生成指定数量的不重复题目。程序会检测这种情况并报告错误,不会进入无限循环。当前版本重点满足课程功能要求,没有额外加入面向不同年级的难度模型。
七、效能分析与优化
7.1 分析方法
使用以下命令进行性能采样:
python -m cProfile -s cumulative Myapp.py -n 10000 -r 10
同时对 10、100、1000、10000 道题分别运行 5 次,记录“生成题目并写入文件”的平均耗时。
| 题目数量 | 平均总耗时(秒) |
|---|---|
| 10 | 0.001929 |
| 100 | 0.006597 |
| 1000 | 0.049816 |
| 10000 | 0.598650 |

10000 道题完整生成约 0.60 秒,峰值内存约 13.44 MB,满足一次生成一万道题的要求。
7.2 cProfile 热点
| 函数 | 累计耗时(秒) | 说明 |
|---|---|---|
run |
1.485 | 完整命令行流程 |
QuestionGenerator.generate |
1.225 | 随机生成、查重和维护集合 |
_build |
0.817 | 递归构造表达式树 |
combine |
0.303 | 精确计算与约束检查 |
random.choice |
0.252 | 随机选择基础数值和运算符 |
write_questions |
0.238 | 拼接并写出两个结果文件 |
cProfile 本身会带来额外开销,因此表中的时间不能与正常运行时间直接比较,只用于判断热点函数。
7.3 优化思路
- 约束前移。 最初先生成整棵表达式再校验,不合法的减法和除法会继续递归。改进后将约束放入
combine,不合法分支立即终止。 - 规范键缓存。 使用
lru_cache缓存canonical_key,避免同一个子树在查重、格式化和测试过程中重复计算。 - 哈希集合查重。 每道题的规范键存入
set,平均查找复杂度为O(1)。 - 批量拼接输出。 先构造完整字符串,再用一次文件写入完成输出,避免 10000 次小规模写盘。
7.4 批改性能
对 10000 道题及正确答案进行批改,核心答案比较约 0.0087 秒;包含文本读取、表达式解析和结果写入的总耗时约 0.73 秒。主要成本是重新解析题面,而不是分数比较。
八、源代码管理与运行说明
项目按照功能阶段进行提交,建议使用以下提交信息:
feat: 实现四则运算题目生成与自动批改test: 补充分数、查重和命令行测试docs: 补充设计、性能与测试文档docs: 完成第二次软件工程作业博客
运行方式:
cd PairProject
# 生成 10 道范围小于 10 的题目
python Myapp.py -n 10 -r 10
# 生成 10000 道题目
python Myapp.py -n 10000 -r 10
# 批改题目
python Myapp.py -e Exercises.txt -a Answers.txt
# 执行自动化测试
python -m unittest discover -s tests -v
项目文件:
PairProject/
├── Myapp.py
├── main.py
├── README.md
├── PSP.md
├── requirements.txt
├── pyproject.toml
├── docs/
│ ├── DESIGN.md
│ ├── PERFORMANCE.md
│ ├── TEST_REPORT.md
│ ├── performance.png
│ └── performance.svg
└── tests/
└── test_main.py
程序只使用 Python 标准库。__pycache__、测试缓存和运行时生成的 Exercises.txt、Answers.txt、Grade.txt 不提交到仓库。
九、项目总结与个人感受
9.1 项目收获
这次作业让我认识到,软件工程中的需求描述必须被逐句转换成可验证的规则。例如“除法结果应是真分数”不能只理解为除数不能为 0,还必须判断最终商是否严格小于 1;“题目不能重复”也不能简单地比较答案或字符串,而要按照题目允许的交换规则处理。
表达式树是本次设计中最关键的决定。生成、求值、校验、查重、括号渲染和批改都围绕同一种数据结构展开。后续增加测试或修改输出格式时,不需要重写核心算法,只需要在对应模块上扩展。
自动化测试也让我能够放心地调整实现。尤其是在修正子表达式运算符计数和批量批改路径时,单元测试可以快速确认原有功能没有被破坏。
9.2 不足与改进
- PSP 中的部分实际耗时是完成阶段后回顾估算的,后续应按开始和结束时间及时记录。
- 程序会保留一些语义上必要、但阅读体验一般的括号,后续可以优化为更接近人工书写习惯的渲染方式。
- 当前没有实现图形界面,只完成题目允许的命令行版本。
- 极端参数下的题目空间预测还可以进一步优化,例如在
r很小时直接计算理论上限,而不是通过多次尝试后报错。 - 可以继续增加更多真实输入文件的兼容性测试,以及针对解析器异常输入的回归测试。
9.3 实际分工
| 成员 | 主要承担的工作 | 参与的复审或测试 |
|---|---|---|
| 叶浚轩 | 独立完成需求分析、表达式树设计、题目生成、查重、分数格式化、文本解析、自动批改、命令行入口、测试和全部文档 | 完成代码审查、边界检查、10000 道题独立校验和性能采样 |
9.4 个人感受
本次作业采用单人完成的方式。最大的挑战不是实现四则运算,而是消除需求中的歧义,并把自然语言规则转换为稳定的数据结构与测试断言。通过先确定表达式树和精确分数模型,再围绕它实现生成、渲染和批改,最终避免了多套逻辑互相不一致的问题。
后续如果再完成类似任务,我会更早建立测试用例表,并在编码前把每条需求对应到至少一个可自动验证的测试,减少后期返工。

浙公网安备 33010602011771号