第三周作业:结对项目
| 姓名 | 学号 |
|---|---|
| 朱海碲 | 3124004150 |
| 邓兴荣 | 3124004128 |
| 这个作业属于哪个班级 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694 |
| 这个作业的目标 | 结对完成题目为自动生成小学四则运算题目的图像界面程序。 |
GitHub仓库:https://github.com/shiningbrightlydarkmoon/shiningbrightlydarkmoon/tree/main/MathExercise
结对项目:小学四则运算题目生成器
一、项目简介
本项目实现了一个小学四则运算题目生成和批改程序。程序使用 Python 编写,完全使用精确分数计算,不依赖浮点数。它支持按指定范围生成题目、限制运算符数量和中间结果、按题目结构去重,以及根据题目文件和答案文件自动判分。
主要功能如下:
- 通过
-n指定生成题目的数量。 - 通过
-r指定题目和分数分母的取值范围。 - 每道题最多包含 3 个运算符。
- 减法不会产生负数。
- 除法结果必须是正的真分数。
- 题目不能通过交换
+、×左右子树变成重复题。 - 生成结果写入
Exercises.txt。 - 使用
-e和-a对题目和答案文件进行判分。 - 判分结果写入
Grade.txt。
关键规则:
- 减法
e1 - e2要求e1 >= e2,不能出现负数。 - 除法
e1 ÷ e2的除数不能为 0,结果必须是正的真分数。 - 两道题如果可以通过交换
+、×的左右子树互相转换,则视为重复题。 2+3与3+2重复,1+2+3与3+(2+1)重复。1+2+3与3+2+1不重复,因为两者表达式树结构不同。
二、PSP2.1 表格
下表中的预估时间可以按实际开发情况调整。实际时间必须在开发、测试和博客完成后据实填写。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 20 |
| Estimate | 估计这个任务需要多少时间 | 20 |
| Development | 开发 | |
| Analysis | 需求分析(包括学习新技术) | 60 |
| Design Spec | 生成设计文档 | 60 |
| Design Review | 设计复审(和同事审核设计文档) | 30 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 |
| Design | 具体设计 | 60 |
| Coding | 具体编码 | 440 |
| Code Review | 代码复审 | 80 |
| Test | 测试(自我测试,修改代码,提交修改) | 180 |
| Reporting | 报告 | |
| Test Report | 测试报告 | 60 |
| Size Measurement | 计算工作量 | 20 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 |
| 合计 | 1110 |
三、设计实现过程
3.1 总体结构
程序采用“命令行层、业务模块、领域模型”的结构:
main.py
└── src/cli.py
├── src/generator.py
│ └── src/expression.py
│ └── src/rational.py
├── src/exercise_io.py
│ └── src/parser.py
└── src/grader.py
└── src/expression.py
└── src/rational.py
3.2 模块说明
| 模块 | 主要职责 |
|---|---|
rational.py |
精确分数运算、约分、格式化和解析 |
expression.py |
表达式树、求值、括号渲染和去重标识 |
parser.py |
解析题目文件中的表达式 |
generator.py |
随机生成合法题目并进行去重 |
exercise_io.py |
读写题目文件和答案文件 |
grader.py |
计算标准答案、比较答案、输出判分统计 |
cli.py |
解析 -n、-r、-e、-a 参数并调用相应流程 |
3.3 关键流程
生成题目的流程:
读取 -n 和 -r
-> 随机生成 1 到 3 个运算符的表达式树
-> 递归检查减法和除法约束
-> 计算规范化标识
-> 判断是否重复
-> 题目数量达到要求后写入 Exercises.txt
判分的流程:
读取 Exercises.txt
-> 解析每道题的表达式
-> 读取 Answers.txt
-> 使用表达式树计算标准答案
-> 解析并比较用户答案
-> 统计正确和错误题号
-> 写入 Grade.txt
四、性能分析
4.1 测试环境
- Python 版本:3.14.7
- 操作系统:Windows
- 测试范围:
r=10 - 测试内容:题目生成、约束检查和去重,不包含写文件时间
运行命令:
python tools/benchmark.py --r 10 --counts 1000,5000,10000 --profile-count 10000
4.2 性能数据
| 题目数量 | 生成耗时(秒) | 去重后数量 |
|---|---|---|
| 1000 | 0.024164 | 1000 |
| 5000 | 0.120939 | 5000 |
| 10000 | 0.255370 | 10000 |
从结果可以看出,题目数量从 1000 增加到 10000 时,耗时接近线性增长。10000 道题在不到 1 秒的时间内完成生成和去重。

4.3 最大耗时函数
使用 cProfile 分析 10000 道题后,累积耗时最高的函数是:
src/generator.py: _build_random_expression
主要热点函数统计如下:
| 函数 | 调用次数 | 累积耗时 |
|---|---|---|
generate |
1 | 1.031 秒 |
_build_random_expression |
102708/20506 | 0.622 秒 |
_random_leaf |
61607 | 0.347 秒 |
evaluate |
93669/18908 | 0.232 秒 |
canonical_key |
102708/20506 | 0.111 秒 |
cProfile 本身会带来额外开销,因此这里的总时间和正常一键运行的时间不同。正常运行时,10000 道题生成约需 0.255 秒。

4.4 性能优化思路
- 使用
canonical_key集合去重,查找平均为O(1),没有使用题目之间的两两比较。 - 每道题最多 3 个运算符,表达式树最多 7 个节点,递归深度固定。
- 所有计算使用
Fraction,避免浮点数误差和重复转换。 - 在
r很小时采用枚举方式,避免随机重试导致长时间循环。 - 在内存中完成生成后一次性写入文件,减少文件 I/O 次数。
五、关键代码说明
5.1 精确分数
Rational 内部使用 fractions.Fraction,所有加减乘除都由精确分数完成。
@dataclass(frozen=True)
class Rational:
value: Fraction
@classmethod
def from_parts(cls, numerator: int, denominator: int = 1) -> "Rational":
if denominator == 0:
raise RationalError("分母不能为 0")
return cls(Fraction(numerator, denominator))
def format(self) -> str:
numerator = self.numerator
denominator = self.denominator
sign = "-" if numerator < 0 else ""
absolute = abs(numerator)
whole, remainder = divmod(absolute, denominator)
if remainder == 0:
return f"{sign}{whole}"
if whole:
return f"{sign}{whole}’{remainder}/{denominator}"
return f"{sign}{absolute}/{denominator}"
这样既能支持 3/5,也能支持 2’3/8。
5.2 表达式求值
每个运算节点都会递归计算左右子树,并在减法、除法时检查题目约束。
def evaluate(self) -> Rational:
if self.is_leaf:
return self.value
left = self.left.evaluate()
right = self.right.evaluate()
if self.operator == "+":
return left + right
if self.operator == "-":
if left < right:
raise ExpressionError("减法产生了负数")
return left - right
if self.operator == "×":
return left * right
if self.operator == "÷":
if right.is_zero():
raise ExpressionError("除数不能为 0")
result = left / right
if not result.is_proper():
raise ExpressionError("除法结果必须为正的真分数")
return result
只要任意子表达式不满足规则,整道题就不会被生成。
5.3 去重标识
去重不使用字符串直接比较,也不把整棵表达式树全部摊平,而是在每个 + 和 × 节点对子树标识排序。
def canonical_key(self) -> str:
if self.is_leaf:
return f"N{self.value.numerator}/{self.value.denominator}"
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 f"({self.operator}{left_key},{right_key})"
这可以保证:
2 + 3 == 3 + 2
2 × 3 == 3 × 2
1 + 2 + 3 == 3 + (2 + 1)
1 + 2 + 3 != 3 + 2 + 1
5.4 判分实现
判分模块复用表达式求值逻辑,避免“生成时一种算法、判分时另一种算法”导致结果不一致。
expected = item.expression.evaluate()
actual = Rational.parse(raw_answer)
is_correct = actual == expected
比较结果后分别收集正确题号和错误题号,最后生成规定格式:
Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)
六、测试运行
6.1 自动化测试
运行命令:
python -m unittest discover -s tests -v
实际结果:
Ran 17 tests in 0.395s
OK

6.2 测试用例
| 编号 | 测试内容 | 输入或操作 | 预期结果 | 实际结果 |
|---|---|---|---|---|
| 1 | 分数解析 | 3/5 |
输出 3/5 |
通过 |
| 2 | 带分数解析 | 2’3/8 |
转换为 19/8 |
通过 |
| 3 | 分数加法 | 1/6 + 1/8 |
7/24 |
通过 |
| 4 | 交换律去重 | 2+3 与 3+2 |
规范化标识相同 | 通过 |
| 5 | 结合与交换去重 | 1+2+3 与 3+(2+1) |
规范化标识相同 | 通过 |
| 6 | 不同树结构 | 1+2+3 与 3+2+1 |
规范化标识不同 | 通过 |
| 7 | 渲染回读 | 生成表达式渲染后再解析 | 规范化标识不变 | 通过 |
| 8 | 减法约束 | 1-2 |
抛出“减法产生负数”错误 | 通过 |
| 9 | 除法约束 | 1÷2 |
结果为 1/2 |
通过 |
| 10 | 非法除法 | 2÷1 |
判定为非法题目 | 通过 |
| 11 | 1000 道题生成 | ProblemGenerator(10).generate(1000) |
1000 道题且不重复 | 通过 |
| 12 | 小范围空间不足 | r=1,n=10000 |
报告无法生成足够题目 | 通过 |
| 13 | 编号答案判分 | 1. 7/24 |
题号统计正确 | 通过 |
| 14 | 无编号答案判分 | 1/2 |
按顺序判分 | 通过 |
| 15 | 缺少 -r |
-n 5 |
输出帮助信息并非零退出 | 通过 |
| 16 | 端到端生成与判分 | -n 12 -r 10 后判分 |
Correct: 12 |
通过 |

6.3 为什么可以判断程序正确
- 所有计算都使用精确分数,不依赖浮点数。
- 每个表达式都会递归检查所有子表达式,非法中间结果不会进入题目文件。
- 去重同时覆盖
+和×的交换律,同时保留不同树结构之间的区别。 - 生成结果会再次被解析,验证输出格式和表达式树可以正常回读。
- 判分模块复用题目求值模块,降低两套算法结果不一致的风险。
- 自动化测试覆盖了分数、表达式、生成器、判分和命令行。
- 10000 道题测试同时检查了数量、唯一性和最大运算符数量。
七、运行与示例
7.1 生成题目
python main.py -n 10 -r 10

生成的 Exercises.txt 示例:
1. 7 - 1’1/2 - 4/7 + 3/4 =
2. 3 ÷ (6’1/4 + 5) =
3. 7/8 × 0 =
4. 6 × (7’4/9 - 5) =
5. 2/3 + 4/5 =

7.2 生成 10000 道题
python main.py -n 10000 -r 10
python tools/validate.py Exercises.txt --r 10
校验结果:
校验通过: exercises=10000 unique=10000 max_ops=3

7.3 批改答案
python main.py -e Exercises.txt -a Answers.txt

Grade.txt 示例:
Correct: 7 (1, 2, 3, 4, 5, 6, 8)
Wrong: 3 (7, 9, 10)

7.4 缺少 -r 时的帮助信息
python main.py -n 5
程序会提示必须提供 -r,并打印帮助信息。

八、项目小结
本项目最重要的两个部分是精确分数运算和题目去重。分数运算如果使用浮点数,容易出现答案比较不一致的问题;因此我们使用 Fraction 保存精确值,并在输出时转换成普通分数或带分数。
去重没有采用简单的字符串比较。我们为每个表达式树生成规范化标识,只在 + 和 × 节点交换左右子树,不把整棵表达式树直接摊平。这样既能识别 2+3 和 3+2,也能正确处理题目中特别说明的 1+2+3、3+(2+1) 和 3+2+1。
程序还处理了小范围 r 的边界情况。当 r=1 或合法题目空间不足时,程序不会无限尝试,而是输出明确错误,方便用户调整 -r 或 -n。
九、总结
本次结对项目完成了题目生成、去重、文件输出、答案判分、自动化测试和性能分析。我们将复杂规则拆分成可单独测试的模块,并使用精确分数避免计算误差。最终程序可以生成 10000 道互不重复的合法题目,能够正确完成判分,并给出了明确的运行说明和测试记录。
通过这次合作,我们认识到结对项目不只是把代码分成两半,还需要共同确认接口、交叉测试、检查提交记录,并把实现过程中的设计思路、问题解决方法和经验总结写进博客。后续如果继续扩展,可以考虑增加图形界面、支持更多运算符,或者生成除题目之外的提示和解析步骤。
| PSP2.1 | Personal Software Process Stages | 实际耗时(分钟) |
|---|---|---|
| Planning | 计划 | 15 |
| Estimate | 估计这个任务需要多少时间 | 15 |
| Development | 开发 | |
| Analysis | 需求分析(包括学习新技术) | 55 |
| Design Spec | 生成设计文档 | 60 |
| Design Review | 设计复审(和同事审核设计文档) | 25 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 25 |
| Design | 具体设计 | 65 |
| Coding | 具体编码 | 450 |
| Code Review | 代码复审 | 70 |
| Test | 测试(自我测试,修改代码,提交修改) | 180 |
| Reporting | 报告 | |
| Test Report | 测试报告 | 55 |
| Size Measurement | 计算工作量 | 15 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 65 |
| 合计 | 1095 |
GitHub仓库截图:

浙公网安备 33010602011771号