结对项目

小学四则运算题目自动生成程序(结对项目博客模板)

一、项目信息


二、目录结构

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 数值相减与除法都用 Fractionmyapp/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 为什么可以认为程序是正确的

  1. 约束是"构造时保证"的,而不是"事后过滤"的:负数、假分数商、运算符超限在生成阶段就不可能发生,
    测试再遍历所有子表达式做二次校验,两条路径互相印证。
  2. 数值用 Fraction 精确表示:1/6 + 1/8 严格等于 7/24,不存在浮点误差导致的判错。
  3. 判重规则直接对应需求原文的四个例子(见第八节表格),单元测试把原文例子固化为断言。
  4. 边界有明确行为:-r 1、-r 2 这类"数值太少"的情况不会死循环,而是给出提示并输出能生成的题目。
  5. 批改是对表达式求值后再比对,学生写未约分或带分数形式都能正确判定。

十、效能分析

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 看会轻松很多。

posted @ 2026-09-20 19:56  好好吃饭睡觉了  阅读(11)  评论(0)    收藏  举报