结对作业:基于 Python 的小学四则运算题目生成与自动批改程序
结对项目:小学四则运算题目生成与自动批改程序
一、项目基本信息
| 成员 | 姓名 | 学号 |
|---|---|---|
| 成员一 | 陈泽翔 | 3124004161 |
| 成员二 | 吕灿斌 | 3124004176 |
GitHub项目地址:
https://github.com/G1ngk020060530/ArithmeticGenerator
本项目使用Python实现小学四则运算题目的自动生成与批改,支持自然数、分数、带分数以及括号。程序能够生成一万道题目,并将题目、标准答案和批改结果分别保存到文本文件。
开发过程中使用了AI辅助提供代码示例、排查问题和整理文档,相关功能通过本地运行和自动化测试进行验证。
二、PSP2.1时间记录
单位:分钟。
| PSP2.1 | Personal Software Process Stages | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 20 | 14 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 14 |
| Development | 开发 | 590 | 676 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 62 |
| · Design Spec | · 生成设计文档 | 40 | 25 |
| · Design Review | · 设计复审 | 20 | 14 |
| · Coding Standard | · 代码规范 | 20 | 13 |
| · Design | · 具体设计 | 50 | 76 |
| · Coding | · 具体编码 | 240 | 334 |
| · Code Review | · 代码复审 | 40 | 33 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 120 | 119 |
| Reporting | 报告 | 110 | 102 |
| · Test Report | · 测试报告 | 40 | 36 |
| · Size Measurement | · 计算工作量 | 20 | 19 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 50 | 47 |
| 合计 | 720 | 792 |
预估列采用项目讨论初期给出的参考估算,合计720分钟。实际列根据开发过程回顾估算,合计792分钟。
性能分析与优化相关工作约耗时94分钟,其中部分已经包含在编码、测试等阶段,部分属于额外投入。
从各阶段看,具体设计和编码耗时超过预估。表达式结构、去重规则、异常输入处理和自动批改比最初设想复杂,说明前期对实现细节的估计还不够充分。
三、需求分析与实现约定
3.1 功能需求
程序提供两种运行模式。
题目生成模式:
python main.py -n 10 -r 10
其中:
-n指定生成数量,省略时默认生成10道。-r指定数值范围,生成模式下必须提供。- 题目中的数字及非整数的分母均小于范围上界。
- 每道题包含1~3个运算符。
- 减法中间结果不能为负数。
- 除法不能除以零,商应符合真分数要求。
- 同一次生成的题目按照规定去重。
生成结果保存到当前工作目录:
Exercises.txt
Answers.txt
答案批改模式:
python main.py -e Exercises.txt -a StudentAnswers.txt
程序按照题号比较答案,输出Grade.txt。批改模式不要求提供-r。
3.2 对题目中歧义的处理
题目将带分数也列入“真分数”示例,因此对除法结果的限制存在解释空间。本项目采用较严格的实现约定:
0 < 除法结果 < 1
带分数仍然可以作为操作数或其他运算的结果。该约定已在README中说明,若课程要求采用其他解释,需要对应调整校验规则。
数值范围只限制题目中的操作数,不限制中间结果和最终答案。例如,范围为10时,8 + 5合法,答案可以为13。
四、设计与实现过程
4.1 模块划分
| 模块 | 主要职责 |
|---|---|
main.py |
解析命令行参数,选择生成或批改模式 |
numbers.py |
分数、带分数的解析与格式化 |
expression.py |
表达式树、求值、合法性检查和去重标识 |
generator.py |
随机生成题目,处理小范围和重试上限 |
parser.py |
将题目文本解析为表达式树 |
grader.py |
读取编号文件,比较答案并统计 |
tests/ |
自动化测试 |
benchmark.py |
测量生成耗时 |
plot_benchmark.py |
根据测量报告生成性能图 |
核心类包括:
Number:表示一个非负数值。Binary:表示一个运算符及其左右子表达式。ExpressionParser:负责解析运算优先级与括号。
生成器构造Number和Binary对象,批改器则通过解析器还原这些对象,两条流程共用分数处理和表达式求值逻辑。
4.2 为什么使用表达式树
直接拼接字符串不方便检查每个中间结果,也不方便按照交换律去重。
例如:
(1 + 2) × 3
其结构为一个乘法节点,左侧是加法表达式,右侧是数字3。通过树结构可以递归完成:
- 计算左右子表达式。
- 检查当前运算是否合法。
- 统计运算符数量。
- 输出保持计算顺序的题目文本。
- 生成结构化去重标识。
4.3 题目生成流程
每道题先随机确定1~3个运算符,再将剩余运算符数量分配给左右子树。
范围为1时只能使用数字0,合法结构数量有限,因此单独枚举。其他范围采用有上限的随机重试,避免因重复题过多而无限循环。达到随机尝试上限不代表已经穷尽全部可能题目。
五、关键代码与思路说明
5.1 使用Fraction精确计算
内部使用标准库Fraction表示数值:
from fractions import Fraction
result = Fraction(1, 6) + Fraction(1, 8)
结果为精确的7/24,避免用浮点近似值判断答案。
输出时根据分母和整数部分分类处理:
if denominator == 1:
return str(numerator)
if numerator < denominator:
return f"{numerator}/{denominator}"
whole = numerator // denominator
remainder = numerator % denominator
return f"{whole}’{remainder}/{denominator}"
例如,Fraction(19, 8)输出为2’3/8。
5.2 检查每一步运算是否合法
求值时先递归计算左右子表达式,再检查当前节点:
left_value = self.left.evaluate()
right_value = self.right.evaluate()
减法检查:
if left_value < right_value:
raise ValueError("减法不能产生负数")
除法检查:
if right_value == 0:
raise ValueError("除数不能为零")
result = left_value / right_value
if not 0 < result < 1:
raise ValueError("除法结果必须是大于0且小于1的真分数")
这种方式能够拒绝(1 − 3) + 5:虽然最终算术结果为正数,但其内部减法已经违反要求。
5.3 按交换律生成去重标识
去重只允许交换加法、乘法的左右子表达式,不改变分组:
def canonical_key(self) -> tuple:
left_key = self.left.canonical_key()
right_key = self.right.canonical_key()
if self.operator in ("+", "*"):
left_key, right_key = sorted((left_key, right_key))
return ("binary", self.operator, left_key, right_key)
数字节点使用约分后的分子、分母作为标识。所有题目标识存入集合,重复标识不再加入题目列表。
因此:
| 表达式对 | 判定 |
|---|---|
2 + 3与3 + 2 |
重复 |
6 × 8与8 × 6 |
重复 |
3 + (2 + 1)与(1 + 2) + 3 |
重复 |
(1 + 2) + 3与(3 + 2) + 1 |
不重复 |
这里没有将多个加数或乘数展开排序,因而保留了题目要求的分组差异。
5.4 解析优先级与左结合
解析器分为加减、乘除、数字与括号三个层次。加减层先读取完整的乘除表达式:
def parse_addition(self):
expression = self.parse_multiplication()
while self.peek() in ("+", "-"):
operator = self.consume()
right = self.parse_multiplication()
expression = Binary(operator, expression, right)
return expression
这样能够保证乘除优先于加减,并将8 − 3 − 2解析为(8 − 3) − 2。括号通过递归解析处理,没有使用eval()执行输入文本。
5.5 按题号批改
批改器将学生答案解析成Fraction后比较:
try:
actual = parse_number(answers.get(number, ""))
except ValueError:
wrong.append(number)
continue
if actual == expected:
correct.append(number)
else:
wrong.append(number)
缺失、空白或无法解析的答案记为错误;等值分数按相同数值处理,例如2/4与1/2。
六、测试与运行结果
6.1 测试环境和命令
实测环境:
- Windows,系统标识为
Windows-10-10.0.26200-SP0。 - Python 3.9.13。
- 使用标准库
unittest。
执行:
python -m unittest discover -s tests -v
最终35项自动化测试全部通过。测试数量不等于输入数量,其中万题测试逐题检查了10000道题,多组参数测试也包含多个输入案例。

6.2 代表性测试用例
| 编号 | 测试内容 | 预期结果 | 实际结果 |
|---|---|---|---|
| 1 | 1/6 + 1/8 |
7/24 |
通过 |
| 2 | 3 − 1’1/2 |
1’1/2 |
通过 |
| 3 | (1 − 3) + 5 |
拒绝负数中间结果 | 通过 |
| 4 | 2 − 2 |
0 |
通过 |
| 5 | 1 ÷ 2 |
1/2 |
通过 |
| 6 | 1 ÷ 0 |
拒绝除零 | 通过 |
| 7 | 4 ÷ 2 |
按严格真分数约定拒绝 | 通过 |
| 8 | 2 + 3与3 + 2 |
标识相同 | 通过 |
| 9 | 两组指定的嵌套加法案例 | 去重判定符合题目示例 | 通过 |
| 10 | 1 + 2 × 3 |
7 |
通过 |
| 11 | (1 + 2) × 3 |
9 |
通过 |
| 12 | 8 − 3 − 2 |
3 |
通过 |
| 13 | 缺失右括号、非法字符等 | 拒绝解析 | 通过 |
| 14 | 标准答案1/2,提交2/4 |
判对 | 通过 |
| 15 | 缺失、空白或非法答案 | 对应题号判错 | 通过 |
| 16 | 重复答案题号 | 报错 | 通过 |
| 17 | 范围1请求10000道题 | 明确报错,不无限循环 | 通过 |
| 18 | 范围10生成10000道题 | 数量、范围、运算及去重检查通过 | 通过 |
此外,手动运行验证了生成模式缺少-r时会报错。
6.3 题目与答案展示
执行:
python main.py -n 10 -r 10 --seed 42

左侧为生成的10道题,右侧为标准答案。题目包含整数、分数、带分数、括号及不同运算符。
6.4 一万道题生成
执行:
python main.py -n 10000 -r 10 --seed 42
随后检查:
(Get-Content .\Exercises.txt).Count
(Get-Content .\Answers.txt).Count
两个文件均为10000行。

自动化测试还逐题检查了数值范围、运算符数量、中间结果和去重标识,并验证题目输出为文本后能够还原相同结构和答案。
6.5 对错混合批改
复制10道题的标准答案,手动将第2、4题改错,执行:
python main.py -e Exercises.txt -a StudentAnswers.txt
结果:
Correct: 8 (1, 3, 5, 6, 7, 8, 9, 10)
Wrong: 2 (2, 4)

6.3节截图右侧展示的是标准答案Answers.txt;终端批改使用的是另行修改过第2、4题的StudentAnswers.txt,因此两处展示的答案文件不同。
6.6 正确性依据与限制
正确性依据包括人工已知答案、边界案例、题目原文指定的去重案例、独立递归计算,以及万题批量检查。
生成和批改共用部分代码,所以“用程序自己的答案全部判对”只能说明流程一致,不能单独证明算法正确。人工案例和独立计算检查用于补充这一点。测试也无法保证覆盖所有输入,后续仍可以扩充异常文件和极端参数测试。
七、效能分析与优化
7.1 分析耗时与方法
性能分析、修改、测量和绘图约耗时94分钟,时间统计与PSP部分阶段存在交叉。
首先使用cProfile分析:
python -m cProfile -o reports/generation_before.prof main.py -n 10000 -r 10 --seed 42
优化前主要耗时如下:
| 函数 | 累计耗时 |
|---|---|
generate_exercises |
0.721秒 |
build_expression |
0.646秒 |
random_number |
0.356秒 |
Binary.evaluate |
0.238秒 |
Fraction.__new__ |
0.178秒 |
write_exercises |
0.168秒 |
其中Fraction.__new__调用198106次,自身耗时约0.129秒,是该报告中的显著热点。
累计时间包含子函数耗时,不能直接相加。cProfile本身也有额外开销,因此没有直接使用上述时间作为优化前后正常运行的对比数据。

7.2 优化思路
第一处优化是直接构造带分数对应的分子和分母。
优化前:
value = Fraction(whole) + Fraction(numerator, denominator)
优化后:
value = Fraction(
whole * denominator + numerator,
denominator,
)
这省去了额外分数对象及一次分数加法,数值保持不变。
第二处优化是避免重复复制已有的Fraction对象,并通过分子判断非负性:
if not isinstance(self.value, Fraction):
object.__setattr__(self, "value", Fraction(self.value))
if self.value.numerator < 0:
raise ValueError("数字不能为负数")
Fraction不可变,已有对象可以复用,其分母为正,因此可以通过分子的符号判断数值是否为负。
7.3 测量结果
固定测试条件:
- 数量10000,范围10,随机种子42。
- 预热一次,正式测量5次。
- 使用
time.perf_counter()计时,取中位数。 - 只统计
generate_exercises,不包含文件写入。 - 校验和摘要计算在计时范围外进行。
| 次数 | 优化前(秒) | 优化后(秒) |
|---|---|---|
| 1 | 0.309718 | 0.260573 |
| 2 | 0.299818 | 0.239190 |
| 3 | 0.304098 | 0.260400 |
| 4 | 0.291985 | 0.231302 |
| 5 | 0.288963 | 0.257830 |
| 中位数 | 0.299818 | 0.257830 |
本次测量耗时下降约14.00%,加速比约1.16倍。前后固定种子下的题目和答案SHA-256指纹相同,35项回归测试仍全部通过。

这个结论仅适用于当前机器与测量条件,不意味着所有规模和运行环境下都会获得相同提升。
八、源代码管理与运行说明
项目采用Git进行增量提交,记录了以下阶段:
- 初始化项目和验证分数计算。
- 实现分数解析与带分数格式化。
- 实现表达式求值和交换律去重。
- 实现题目生成与文件输出。
- 实现表达式文本解析。
- 实现答案批改。
- 添加万题生成检查。
- 性能优化及测量记录。
- 完善README。
代码、测试、性能报告和运行说明已经上传GitHub。虚拟环境、缓存和生成的运行结果通过.gitignore管理。
程序仅使用Python标准库,下载项目后可在根目录运行README中的命令。
九、项目总结与结对感受
9.1 项目收获
本项目让我们认识到,简单的功能描述也可能包含复杂的规则。分数精度、中间结果合法性和去重标准都需要在编码前明确,而不能等到程序运行后再凭结果猜测是否正确。
表达式树使求值、格式化和去重能够围绕同一种结构展开。自动化测试则让修改后的回归验证更方便,尤其是在性能优化后,可以检查已有行为是否受到影响。
环境配置和目录组织同样影响开发效率。过程中出现过Git命令无法识别、Python缩进错误以及测试目录放错位置等问题,说明工程实践不仅包括算法,也包括工具与项目结构的管理。
9.2 不足与改进
本次时间记录不够及时,导致部分工作事后难以准确归类。今后应按任务记录起止时间,并同步保存测试证据。
程序目前保留较多括号,题目可读性还有提升空间;随机生成在小范围、大数量时可能达到尝试上限。这些限制已经在文档中说明,后续可以改进排版和生成策略。
同时,AI提供的代码需要进一步理解。测试通过不等于已经掌握所有实现,后续应尝试独立修改解析规则和去重逻辑,并解释相应测试为什么能够发现错误。
9.3 实际分工
本项目中,陈泽翔主要负责项目计划、需求分析、设计与编码,吕灿斌主要负责设计和代码复审、规范制定、测试及报告整理。PSP中的“开发”包含多个子阶段,具体分工如下。
| 成员 | 实际承担的工作 | 参与的复审或测试 |
|---|---|---|
| 陈泽翔 | 项目计划与时间估算、需求分析、生成设计文档、具体设计和编码,实现分数处理、表达式树、题目生成、去重、解析及批改功能 | 根据复审和测试反馈修改代码,说明关键实现思路,并验证修改后的功能 |
| 吕灿斌 | 设计复审、代码规范制定、代码复审、测试与测试报告、工作量统计、事后总结及过程改进计划 | 检查实现与需求是否一致,验证正常输入、边界条件及异常情况,整理测试结果和改进建议 |
9.4 双方感受与互评
陈泽翔的感受与对吕灿斌的评价:
这次项目让我体会到,负责实现功能不仅需要把代码写出来,还需要把设计思路和模块之间的关系解释清楚。分数计算、表达式去重和文本解析之间存在联系,如果前期没有统一数据表示和接口,后续修改就容易影响其他模块。
吕灿斌负责的复审和测试工作,为代码实现提供了另一种检查角度。编写程序时,我容易关注正常输入能否运行,而测试还需要考虑除零、负数中间结果、错误答案和小范围生成等情况。我认为他的闪光点是重视需求与结果的对应关系,有助于避免仅凭“运行成功”就判断功能完成。
对后续合作的建议是,在具体编码前更早讨论测试用例和验收标准。这样可以在实现过程中提前考虑边界条件,减少后期反复调整的成本。同时,我们也需要及时记录各阶段用时,让PSP记录更加准确。
吕灿斌的感受与对陈泽翔的评价:
这次项目让我认识到,复审和测试同样需要理解程序设计。只有弄清表达式树、去重标识和分数表示,才能判断测试结果是否符合要求。例如,两个表达式的答案相同不代表题目重复,测试必须依据题目规定的交换规则进行设计。
陈泽翔主要承担了需求分析、设计和编码工作,将功能划分为分数处理、表达式、生成器、解析器和批改器等模块,使复审和测试能够围绕不同职责开展。我认为他的闪光点是能够将需求逐步落实为可运行、可验证的功能,并通过Git保存阶段性成果。
建议今后在实现关键算法后及时补充接口说明,特别是输入格式、异常处理和返回值约定,方便复审与测试同步进行。对于AI辅助提供的实现,双方还应通过相互讲解和独立修改加深理解,不能将测试通过等同于完全掌握代码。
浙公网安备 33010602011771号