结对项目
小学四则运算题目自动生成程序(结对项目博客模板)
一、项目信息
- 班级:24级计算机科学与技术8班
- 同学 A:3124004426黄国轩
- 同学 B:3124004433林梓建
- GitHub 项目地址:https://github.com/embrace897/embrace897/tree/main/Myapp
- 项目运行环境:Python 3.14,编译器为pycharm 2025 3.3
二、目录结构
Myapp/
├── main.py # 程序入口,等价于 Myapp.exe
├── Myapp.bat # Windows 启动脚本(免打 exe)
├── build_exe.bat # 用 PyInstaller 打包成 Myapp.exe
├── .gitignore # 忽略 __pycache__ / dist / 运行时生成的 txt
├── README.md # 本文件:运行说明 + 设计说明 + 测试记录 + 效能分析
├── samples/ # 示例输出(第四节展示的那份真实结果)
│ ├── Exercises.txt
│ ├── Answers.txt
│ └── Grade.txt
├── myapp/ # 源代码包
│ ├── __init__.py # 版本号与模块说明
│ ├── values.py # 数值:解析、格式化、取值池
│ ├── expression.py # 表达式树:求值 / 去重规范形 / 最少括号渲染 / 解析
│ ├── generator.py # 出题器:带约束的随机生成 + 去重
│ ├── grader.py # 批改:统计对错、写 Grade.txt
│ └── cli.py # 命令行参数解析与调度
├── tests/
│ └── test_all.py # 26 个单元测试 / 集成测试
└── tools/
├── profile_demo.py # cProfile 性能分析脚本
└── benchmark_sampling.py # 优化前后性能对比脚本
三、命令行参数
| 参数 | 含义 |
|---|---|
-n <个数> |
生成题目的个数,默认 10;支持 10000 |
-r <范围> |
题目中数值(自然数、真分数及其分母)的范围,必须给定;题目中出现的数值都小于该值 |
-e <题目文件> |
批改模式:题目文件,需与 -a 同时使用 |
-a <答案文件> |
批改模式:答案文件,需与 -e 同时使用 |
-o <目录> |
输出目录,默认为运行程序的当前目录 |
--max-operators <个数> |
每道题的运算符个数上限,默认 3(需求 5 要求不超过 3) |
--div-mode proper|any |
除法约束:proper(默认)商必须是真分数;any 只要求除数不为 0 |
--apostrophe ascii|unicode |
带分数撇号风格:ascii 输出 2'3/8,unicode 输出题目原文的 2’3/8 |
--seed <整数> |
固定随机种子,便于复现与测试 |
不带 -r 直接运行会报错并打印帮助信息(需求 2):
错误:必须使用 -r 参数指定数值范围。
usage: Myapp [-h] [-n <题目个数>] [-r <范围>] [-e <题目文件>] [-a <答案文件>] [-o <输出目录>] ...
小学四则运算题目自动生成 / 自动批改程序
options:
-h, --help show this help message and exit
-n <题目个数> 生成题目的个数,默认为 10
-r <范围> 题目中数值(自然数、真分数及其分母)的范围,数值都小于该值;必须给定
...
(退出码为 2,方便脚本判断"参数错误"。)
四、输出文件格式
python main.py -n 10 -r 10 --seed 2026 的真实输出:
Exercises.txt
1. 9 + 6 =
2. (8/9 + 1/9 - 0) ÷ 5 =
3. (5 + 1/2) × 2/3 =
4. 3/5 ÷ 5/6 + (2 + 8) =
5. 4/7 ÷ (8 + 4 × 8) =
6. (1/3 - 1/7) ÷ (2/5 ÷ 8/9) =
7. 7/8 × 4/5 ÷ 9 =
8. 5 ÷ 8 - 1/2 =
9. 5/9 + 1 =
10. 1/8 - (1/8 + 0) × 1 =
Answers.txt
1. 15
2. 1/5
3. 3'2/3
4. 10'18/25
5. 1/70
6. 80/189
7. 7/90
8. 1/8
9. 1'5/9
10. 0
Grade.txt(把第 4、9 题答案改错后批改)
Correct: 8 (1, 2, 3, 5, 6, 7, 8, 10)
Wrong: 2 (4, 9)
格式约定:
- 题目行:序号. 表达式 = (等号后留空格给答题者填写);
- 真分数写成 3/5,带分数写成 2'3/8(
--apostrophe unicode时输出 2’3/8); - 读取答案时 2'3/8、2’3/8、4/6(与 2/3`等值的未约分写法)都能正确识别;
- 所有文件都是 UTF-8 编码。
五、需求实现对照表
| 需求 | 实现位置 | 说明 |
|---|---|---|
| 1. -n 控制题目个数 | myapp/cli.py | argparse 参数 -n,默认 10 |
| 2. -r控制数值范围,必须给定 | myapp/cli.py、myapp/values.py | 缺失时报错 + 帮助信息并返回退出码 2;数值池只包含小于 r 的数 |
| 3. 计算过程不出现负数 | myapp/generator.py(- 分支) | 先定左操作数,再要求右操作数 <= 左操作数 |
| 4. e1 ÷ e2 的结果是真分数 | myapp/generator.py(÷ 分支) | 要求 e1 > 0 且 e2 > e1,商必然落在 (0, 1) |
| 5. 运算符个数不超过 3 | myapp/generator.py | 随机取 1~3 个运算符并逐层切分,--max-operators 可调 |
| 6. 同一次运行不出现重复题目 | myapp/expression.py + generator.py | 用 canonical_key() 判重,见第八节 |
| 7. 题目与答案分别写入 Exercises.txt / Answers.txt | myapp/cli.py | 写入"执行程序的当前目录"(可用 -o 指定) |
| 8. 支持一万道题 | myapp/generator.py | 哈希去重 + O(log n) 采样,实测 10000 道约 0.2 秒 |
| 9. -e / -a批改并输出 Grade.txt | myapp/grader.py、myapp/cli.py | 逐题比对精确值,输出四舍五入前的 Correct / Wrong 统计 |
补充说明:-r 约束的是题目中出现的数值(自然数、真分数、真分数分母),
计算过程中的中间结果与最终答案不受该范围限制(例如 -r 10 时允许出现 9 × 8 = 72)。
六、设计与实现
6.1 模块划分与关系
main.py
│
myapp.cli ────────────────┐
│ │
┌───────┴────────┐ myapp.grader
│ │ │
myapp.generator myapp.values myapp.expression
│ │ │
└────────► myapp.expression ◄──┘
values.py:只管"值"。parse_value() / format_value() 负责 3、3/5、2'3/8 的读写,
build_value_pool(r)生成小于 r 的自然数与真分数取值池。expression.py:表达式树 Num / BinOp,提供求值、去重规范形、最少括号渲染、字符串解析。- 和 ÷ 不可交换,+ 和 × 可交换,这些规则集中在这里,其它模块只调用。
generator.py:ProblemGenerator 负责"带约束的随机生成",
generate_problems()负责"批量生成 + 去重 + 边界提示"。grader.py:读题目与答案、逐题比对、写 Grade.txt。cli.py:参数解析与两种模式的调度,是唯一与"文件和屏幕"打交道的模块。
6.2 关键流程
出题流程
开始
│
├─ 解析参数:n 道题、范围 r?(缺失则打印帮助并退出码 2)
│
├─ 构造取值池:自然数 0..r-1,真分数 分子/分母 < r
│
├─ 循环直到凑够 n 道题:
│ ├─ 随机决定运算符个数 k ∈ [1, 3]
│ ├─ 随机切分 k 得到左右子树规模、随机选择运算符
│ ├─ 带约束采样填数值:
│ │ e1 - e2 → e2 <= e1 (不出现负数)
│ │ e1 ÷ e2 → e2 > e1 > 0 (商必为真分数)
│ │ 其余 → 用上界约束裁剪,最后统一校验
│ ├─ 计算 canonical_key()
│ └─ key 已存在 → 丢弃重来;否则加入结果集
│
├─ 写 Exercises.txt、Answers.txt
└─ 结束
批改流程
开始
│
├─ 读取题目文件每一行 → 去掉序号 → 取 "=" 左边 → 解析成表达式树 → 精确求值
├─ 读取答案文件每一行 → 去掉序号 → 解析成有理数
├─ 逐题比较(精确比较,4/6 与 2/3 判为相同)
├─ 统计答对 / 答错题号
└─ 写 Grade.txt:Correct: x (...) Wrong: y (...)
七、关键代码说明
7.1 判重:canonical_key()(myapp/expression.py)
def canonical_key(self) -> str:
left_key = self.left.canonical_key()
right_key = self.right.canonical_key()
if self.op in COMMUTATIVE and right_key < left_key:
left_key, right_key = right_key, left_key # + 和 × 可交换
return "%s(%s,%s)" % (self.op, left_key, right_key)
思路:递归求出左右子树的"规范形",只有 + 和 × 才把两个孩子排序后拼接,
因此等价关系正好是"有限次交换 +/× 左右操作数",不含结合律。
7.2 最少括号渲染(myapp/expression.py)
def _render_child(self, child, is_right):
text = child.render()
if not isinstance(child, BinOp):
return text
child_precedence = PRECEDENCE[child.op]
self_precedence = PRECEDENCE[self.op]
if child_precedence < self_precedence: # (1 + 2) × 3
return "(%s)" % text
if child_precedence == self_precedence and is_right: # 1 + (2 + 3)
return "(%s)" % text
return text # (1 + 2) + 3 写成 1 + 2 + 3
这样既能去掉多余括号,又能保证"一棵树恰好对应一个字符串",
不会把两棵不同的树打印成同一道题。
7.3 带约束采样(myapp/generator.py)
if op == "-":
left = self._build(left_count, lo_exc, hi_inc)
right_hi = min(left.value, left.value - lo_exc - _EPS) if lo_exc > _UNBOUNDED else left.value
right = self._build(right_count, _UNBOUNDED, right_hi) # 保证 e2 <= e1
if op == "÷": # div_mode == "proper"
left = self._build(left_count, max(lo_exc, Fraction(0)), hi_inc) # 被除数 > 0
right = self._build(right_count, left.value, right_hi) # 除数 > 被除数 ⇒ 商是 真分数
7.4 数值相减与除法都用 Fraction(myapp/expression.py)
self.value = self._evaluate() # Fraction 精确运算,不会出现 1/6 + 1/8 ≠ 7/24 的误差
八、两个最容易理解错的需求
需求 4(除法结果是真分数):本程序默认理解为"任何除法子表达式的商必须满足 0 < 商 < 1",
于是 6 ÷ 8、1/2 ÷ 3 合法,6 ÷ 2、1/2 ÷ 1/2 不合法(后者商为 1,不是真分数)。
如果你更希望"只要除得尽、商不是假分数即可",可以加 --div-mode any,
此时只要求除数不为 0。
需求 6(重复题目):题目给的例子说明等价关系是"有限次交换 +/× 的左右操作数",不是数学上的恒等变形。
所以本程序的判重结果与题目原文完全一致:
| 题目 A | 题目 B | 是否重复 | 原因 |
|---|---|---|---|
23 + 45 |
45 + 23 |
重复 | 交换加法左右操作数 |
6 × 8 |
8 × 6 |
重复 | 交换乘法左右操作数 |
3 + (2 + 1) |
1 + 2 + 3 |
重复 | 左结合下 1+2+3 = (1+2)+3 = 3+(1+2) = 3+(2+1) |
1 + 2 + 3 |
3 + 2 + 1 |
不重复 | (1+2)+3 与 (3+2)+1 之间无法通过交换得到 |
6 - 8 |
8 - 6 |
不重复 | 减法不可交换 |
九、测试运行
9.1 自动化测试
项目自带 26 个单元测试 / 集成测试,覆盖数值格式、表达式解析与渲染、判重规则、
生成约束、边界范围、批改逻辑与命令行行为:
cd Myapp
python -m unittest discover -s tests -v
实际运行结果:
Ran 26 tests in 0.552s
OK
9.2 手工测试用例(≥10 个)
| # | 测试内容 | 输入 / 操作 | 期望结果 | 实测 |
|---|---|---|---|---|
| 1 | 生成 10 道 10 以内的题 | Myapp.exe -n 10 -r 10 | 生成 10 道题,两个文件各 10 行 | 通过 |
| 2 | 缺 -r 必须报错 | Myapp.exe -n 5 | 打印错误 + 帮助,退出码 2 | 通过 |
| 3 | -r 最小边界 | Myapp.exe -n 10 -r 1 | 只有数值 0,不出现除法,答案全为 0 | 通过(题目形如 0 - 0 = ) |
| 4 | r=2 时除法不可行 | -n 20 -r 2 | 自动退化为 + - ×,不出现 ÷ | 通过 |
| 5 | 一万道题(需求 8) | Myapp.exe -n 10000 -r 100 | 10000 行且无重复,秒级完成 | 通过,实测 0.22 秒 |
| 6 | 题目不重复(需求 6) | 对 10000 道题统计 canonical_key() | 去重后仍是 10000 | 通过 |
| 7 | 减法不产生负数 | 遍历随机生成的题目所有子表达式 | 全部 >= 0 | 通过 |
| 8 | 除法商是真分数 | 遍历所有 ÷ 结点 | 0 < 商 < 1 | 通过 |
| 9 | 运算符不超过 3 个 | 遍历生成的题目 | 全部 <= 3 | 通过 |
| 10 | 带分数格式 | -r 30 生成并查看输出 | 形如 2'3/8;2’3/8 也能被读入 | 通过 |
| 11 | 全部答对 | -e Exercises.txt -a Answers.txt | Correct: 10 ...、Wrong: 0 () | 通过 |
| 12 | 部分答错 | 故意改错第 4、9 题 | Correct: 8 (1, 2, 3, 5, 6, 7, 8, 10)、Wrong: 2 (4, 9) | 通过 |
| 13 | 等值答案判定 | 学生答案写 4/6(正确值是 2/3) | 判为正确 | 通过 |
| 14 | 题号与行数不一致 | -e 3 行、-a 2 行 | 报错并退出码 2 | 通过 |
| 15 | 命令行入口 | python main.py -n 5 -r 20 | 生成 5 行、每行以 序号. 开头并以 = 结尾 |
通过 |
| 16 | 学生直接在题目等号后写答案 | -e 作答后的题目.txt -a 作答后的题目.txt | 能正确判分(实测 Correct: 2 (1, 2)、Wrong: 2 (3, 4)) | 通过 |
9.3 为什么可以认为程序是正确的
- 约束是"构造时保证"的,而不是"事后过滤"的:负数、假分数商、运算符超限在生成阶段就不可能发生,
测试再遍历所有子表达式做二次校验,两条路径互相印证。 - 数值用
Fraction精确表示:1/6 + 1/8 严格等于 7/24,不存在浮点误差导致的判错。 - 判重规则直接对应需求原文的四个例子(见第八节表格),单元测试把原文例子固化为断言。
- 边界有明确行为:-r 1、-r 2 这类"数值太少"的情况不会死循环,而是给出提示并输出能生成的题目。
- 批改是对表达式求值后再比对,学生写未约分或带分数形式都能正确判定。
十、效能分析
10.1 分析工具
python tools/profile_demo.py -n 10000 -r 100 -t 10
优化后的实测输出(节选):
生成题目数:10000,范围 -r:100
总耗时:0.937 秒(平均 0.094 毫秒/题)
------------------------------------------------------------------------
3759407 function calls (3651231 primitive calls) in 0.936 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.011 0.011 0.936 0.936 generator.py:234(generate_problems)
10064 0.004 0.000 0.827 0.000 generator.py:77(new_problem)
54276/10064 0.036 0.000 0.810 0.000 generator.py:136(_build)
64032/10177 0.060 0.000 0.788 0.000 generator.py:153(_attempt)
41671 0.043 0.000 0.388 0.000 generator.py:94(_sample)
74508 0.065 0.000 0.287 0.000 generator.py:114(_first_greater)
(用 cProfile 统计会放大耗时,关闭分析器时同一负载约 0.22 秒。)
10.2 发现的热点
- 出题耗时几乎全部集中在 _sample()(在取值池里按区间随机取数)上,
进一步定位到它调用的二分查找 bisect_right:每次比较都要构造/比较 Fraction,
而 Fraction.lt 内部还要做交叉相乘,是最贵的一环(第一次剖析时占约 60% 的累计时间)。 - 另一处问题是"线性扫描 + 过滤"的朴素写法:r 较大时取值池有几千个元素,
每取一个数就扫一遍,代价是 O(池大小)。
10.3 改进思路与效果
| 版本 | 做法 | 生成 10000 道题耗时 |
|---|---|---|
| v0(朴素) | 每次采样都遍历整个取值池做过滤 | 明显更慢(O(池大小) × 采样次数) |
| v1(二分) | 取值池按大小排序,用二分查找定位合法区间,随机取下标 | 0.417 秒 |
| v2(当前) | 二分时先用 float 键定位近似下标,再用精确的 Fraction 比较向两侧修正 |
0.216 秒 |
v2 的关键在于:浮点二分只是"近似定位",修正循环保证结果与直接用 Fraction 二分 完全一致,
但平均只需要 0~2 次昂贵的精确比较。实测数据(python tools/benchmark_sampling.py):
Fraction 二分(慢路径) 生成 10000 道题耗时 0.417 秒
浮点二分 + 精确修正(快路径) 生成 10000 道题耗时 0.216 秒
------------------------------------------------------------
提速约 1.93 倍(0.417 秒 -> 0.216 秒)
结论:一万道题 0.2 秒左右完成,内存上只需保存"题目字符串 + 判重用的 key",
最坏情况(-r 1)也通过"连续失败次数上限"在 0.05 秒内结束,不会死循环。
十一、PSP2.1 表格
下表中的"预估"是示例值,请按你们自己的真实情况修改;
"实际"一栏请在做完项目后补上,这部分是作业要求的记录点。
| PSP2.1 | 阶段 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 45 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 45 |
| Development | 开发 | 600 | 675 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 60 |
| · Design Spec | · 生成设计文档 | 40 | 30 |
| · Design Review | · 设计复审(和同事审核设计文档) | 30 | 30 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 20 | 30 |
| · Design | · 具体设计 | 60 | 75 |
| · Coding | · 具体编码 | 240 | 300 |
| · Code Review | · 代码复审 | 60 | 60 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 | 90 |
| Reporting | 报告 | 120 | 120 |
| · Test Report | · 测试报告 | 40 | 30 |
| · Size Measurement | · 计算工作量 | 20 | 30 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 60 | 60 |
| 合计 | 750 | 840 |
十二、项目小结与结对说明
技术上的收获
- 需求 4 与需求 6 是本题最"抠字眼"的两处:前者要在生成阶段就把"商必须是真分数"变成硬约束,
后者要意识到判重是"交换律下的表达式树同构",而不是"数学上恒等"(1+2+3 与 3+2+1 不重复)。 - 用
Fraction而不是浮点数,才让"答案对不对"这件事变得没有争议。 - 先写测试再调参,边界(-r 1、-r 2、一万道题)反而比主流程更早暴露问题。
结对感受
- 黄国轩(代码贡献者):这次结对让我最大的收获是"被质疑"的价值。我一开始把需求 6 理解成数学上的化简,打算把 1+2+3 和 3+2+1 也当成同一道题去重,是搭档拿着题目原文那句"1+2+3 和 3+2+1 是不重复的两道题"把我拦下来的,我们才一起把规则改成"只允许交换 + 和 × 的左右操作数",并把原文的四个例子全部固化成单元测试。另一个印象深刻的坑是 -r 1:我一度以为程序死循环了,其实是取值池里只有数值 0,根本凑不出不重复的题目,最后靠"连续失败次数上限 + 明确提示"才算真正处理干净。写代码的时候我以为自己是在解决"怎么出题",
- 林梓建(测试者):作为负责测试的一方,这次我体会到测试不是"跑一遍看有没有报错",而是替需求挑刺。我拿着 -n 10 -r 10 之外的各种组合去试:-r 1、-r 2、n=10000、答案写成 4/6、答案直接写在题目等号后面、两个文件行数故意不一致……其中"答案写在等号后"和"行数不一致要报错"最后都变成了代码里的真实改动,前者还是搭档专门为我提的场景加了兼容处理。看着自己提的问题一条条变成代码、测试和文档,比自己闷头写代码更有成就感;也正因为站在使用者一侧,我才更早发现"少给 -r 时提示信息够不够清楚"这类开发者容易忽略的细节。
对方的闪光点 / 建议
-
黄国轩:
我认为搭档林梓建做得好的地方:他从不轻易接受"应该没问题"这种结论。我说"一万道题大概几秒吧",他直接跑 cProfile 把热点函数贴出来,逼着我去看 _sample 里的二分查找和 Fraction 比较,最后改成"浮点键二分 + 精确修正",一万道题从 0.42 秒降到 0.22 秒,提速约 1.9 倍,也顺手成了博客里效能分析的素材。遇到 LF/CRLF 那种 warning,他也习惯先确认 git add 到底成功没有、再下结论,这种"先验证再断言"的习惯很值得我学。我想给搭档林梓建的建议:可以把测试用例整理成一份固定的"回归清单"(比如 -r 1、-r 2、一万道题、行数不一致),每次改完代码一次性跑完,而不是每轮重新想该测什么;另外提问题时如果能顺手给出最小复现命令,我定位起来会更快。
-
林梓建:
我认为搭档黄国轩做得好的地方:代码分层很清楚,values / expression / generator / grader / cli 各管一件事,我加测试时几乎不用问"这段逻辑在哪个文件";注释写的是"为什么"而不是"是什么",比如判重为什么不含结合律、为什么用 Fraction 而不是浮点,直接写在函数上方,我看一遍就懂了,这也让我在写测试时少走了很多弯路。我想给搭档黄国轩的建议:需求 4 那种有歧义的条款,希望下次能先把理解写成两三行结论再动手(例如"商必须是真分数,所以 6÷8 合法、6÷2 不合法"),否则我按自己的理解写测试,很容易出现"代码和测试各错一半"的返工;另外 _EPS 这类魔法值建议补一句取值理由,提交时也可以把"功能改动"和"格式调整"分开,我顺着 diff 看会轻松很多。

浙公网安备 33010602011771号