小学四则运算程序
结对项目——小学四则运算题目生成程序
| 项目 | 内容 |
|---|---|
| 姓名 / 学号 | **【请填写】同学 刘心怡 / 学号3224004156 | **【请填写】同学 李晨昀 姓名 / 学号3224004155 |
| GitHub 项目地址 | https://github.com/Beauty-LilY/-/commit/c850673c1c57bf8d6978c74a6b059d5e3848829e |
| 开发语言 | Python 3.11 |
| 运行方式 | 见下文「运行说明」,或仓库 README.md |
一、PSP 表格
1.1 开发前的估计
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 30 |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | 240 |
| · Analysis | · 需求分析(包括学习新技术) | 40 |
| · Design Spec | · 生成设计文档 | 20 |
| · Design Review | · 设计复审(和同事审核设计文档) | 15 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 |
| · Design | · 具体设计 | 30 |
| · Coding | · 具体编码 | 90 |
| · Code Review | · 代码复审 | 15 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 20 |
| Reporting | 报告 | 60 |
| · Test Report | · 测试报告 | 20 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 330 |
1.2 实际耗时
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 30 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 30 |
| Development | 开发 | 240 | 245 |
| · Analysis | · 需求分析(包括学习新技术) | 40 | 45 |
| · Design Spec | · 生成设计文档 | 20 | 20 |
| · Design Review | · 设计复审(和同事审核设计文档) | 15 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 10 | 15 |
| · Design | · 具体设计 | 30 | 40 |
| · Coding | · 具体编码 | 90 | 70 |
| · Code Review | · 代码复审 | 15 | 10 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 20 | 25 |
| Reporting | 报告 | 60 | 100 |
| · Test Report | · 测试报告 | 20 | 25 |
| · Size Measurement | · 计算工作量 | 10 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 60 |
| 合计 | 330 | 375 |
几个偏离较大的地方:
- 代码规范 15 分钟(预估 10):动手写之前先约定了几条约束——模块之间单向依赖、
不使用第三方库、中文注释只在说明「为什么」时写。约定本身花的时间不多,但把已有的
草稿按约定重写花了些时间。 - 具体设计 40 分钟(预估 30):主要在纠结「判重」怎么做。一开始想的是把式子展开
成字符串再排序去重,讨论后发现减法、除法不能交换,纯字符串排序会误判,最后才定下
用表达式树递归归一化。这部分讨论的时间比写代码还长。 - 事后总结 60 分钟(预估 30):包括整理性能剖析数据、画图、写这篇博客,比预想的多。
- 具体编码 70 分钟(预估 90):设计阶段把数据结构想清楚了,编码阶段反而快。
二、运行说明
程序只在标准库上运行,不需要安装任何依赖。绘图脚本需要 matplotlib。
# 生成 10 道 10 以内的题目,写入 Exercises.txt 与 Answers.txt
python Myapp.py -n 10 -r 10
# 生成 10000 道 50 以内的题目
python Myapp.py -n 10000 -r 50
# 批改,结果写入 Grade.txt
python Myapp.py -e Exercises.txt -a Answers.txt
# 参数说明
python Myapp.py --help
参数一览:
| 参数 | 含义 |
|---|---|
-n |
生成题目的个数,默认 10 |
-r |
数值范围(自然数、真分数、真分数分母均小于该值),必填 |
-e / -a |
批改模式的题目文件 / 答案文件 |
--max-operators |
运算符个数上限,默认 3 |
--seed |
随机种子,便于复现同一批题目 |
-r 缺失时程序报错并打印帮助信息,退出码为 2:
$ python Myapp.py -n 5
Myapp: error: 参数 -r 是必需的,请指定数值范围,例如:python Myapp.py -n 10 -r 10
单元测试:
python -m unittest discover -s tests -v # 41 项测试
三、效能分析
3.1 怎么发现的
程序跑得动,但既然是「支持一万道题目」,我们想确认瓶颈在哪,于是用 cProfile 剖析
「生成 10000 道题目并渲染」这一次完整运行:
python profile_generate.py 10000 50
第一版剖析结果如下(前 8 个函数,按累计时间排序):
| 函数 | 累计耗时 | 调用次数 |
|---|---|---|
generate |
655 ms | 1 |
build_expression |
501 ms | 50789 |
format_number |
182 ms | 70628 |
fractions._richcmp |
159 ms | 131364 |
random_operand |
148 ms | 30475 |
listcomp(筛可用运算符) |
145 ms | 20314 |
evaluate |
132 ms | 115724 |
fractions.__lt__ |
123 ms | 90736 |
3.2 改前先立对照组
为了判断「多久算慢」,我们写了一个对照实验:同样做 10000 次 Fraction 四则运算,但
不做任何判重与渲染。结果只要 35.4 ms,而完整生成需要 248 ms——也就是说,
七倍的开销不在"算"上,而在"判断和拼接"上。这提示瓶颈是重复计算,而不是分数运算
本身慢。
3.3 两个真正的优化
优化一:合法性判断不再构造 Fraction。
每构造一个内部结点,都要判断减法、除法是否合法,也就是比较两个分数的大小。原实现把
两个子表达式的值算成 Fraction,再写 left < right。问题在于 Fraction 的
_richcmp 内部有大量类型判断和分支,而生成 10000 道题时这个比较被调用了 9 万多次。
实际上比较两个分数只要交叉相乘即可:
def _compare(left: Fraction, right: Fraction) -> int:
return left.numerator * right.denominator - right.numerator * left.denominator
优化二:求值结果在构造时算好。
原来 evaluate() 是递归求值,而它被三处调用(合法性判断、输出答案、渲染前判断),
每道题的每个子表达式都被反复重算。改成 branch() 在构造结点时就算好值挂在结点上,
父结点直接取两个子结点的值做一次运算:
@staticmethod
def branch(op, left, right):
if op == ADD:
value = left.value + right.value
elif op == SUB:
value = left.value - right.value
...
return Expression(op=op, left=left, right=right, value=value)
3.4 结果
| 版本 | 生成 + 渲染 10000 道题 |
|---|---|
| 优化前 | 265 ms |
| 优化后 | 204 ms |
| 提升 | 约 23% |
(同一台机器上重复 5 次取最快值。优化前后用固定随机种子,逐行比对输出完全一致,确认
优化没有改变程序语义。)
3.5 性能分析图

优化后的热点已经转移到 format_number(7 万次调用,叶子数值的格式化)与
canonical(5 万次调用,判重归一化)——这两个都是必要的字符串构造,不是重复
计算,继续压榨收益有限。我们一度尝试把归一化字符串也缓存在结点上,实测只从 235 ms
降到 234 ms,几乎无收益却要多一个字段和一个辅助函数,于是回退了。这个决定本身也是
收获:优化要看数据,不看直觉。

3.6 复杂度上的取舍
- 判重用
set做 O(1) 查找,而不是两两比较,否则 10000 道题就是 5000 万次比较。 - 取操作数用 O(1) 的随机采样,而不是把整个取值池物化出来。当
r = 50时取值池是
O(r²) ≈ 2500 个数,物化尚可;若r取到几千,物化就会明显拖慢生成。采样是常数时间,
与r无关。
四、设计实现过程
4.1 代码组织
Myapp.py 命令行入口:解析参数、校验、调用生成或批改
core/
number.py 自然数/真分数/带分数的解析与格式化
expression.py 表达式树:求值、按优先级输出、判重归一化、文本解析器
generator.py 按约束随机生成不重复题目
grader.py 对照题目文件与答案文件批改
storage.py 文件读写(统一 UTF-8 与 \n 行尾)
tests/test_core.py 单元测试
profile_generate.py 性能剖析脚本
plot_perf.py 绘制性能剖析图
依赖方向是单向的:
Myapp.py ──> generator ──> expression ──> number
└────> grader ────────┘ │
└────> storage └──> storage
storage 不依赖任何业务模块,number 不依赖 expression,因此每一层都可以单独测试。
4.2 类与函数
只有两个类,其余都是函数。
| 类 | 职责 |
|---|---|
Expression |
表达式树的结点。叶子存数值,内部结点存运算符与左右子树 |
Generator |
持有随机数发生器与范围参数,对外只暴露 generate(count) |
Expression 的不变式:value 字段始终是整棵子树的值,在构造时就计算完成。
| 关键函数 | 作用 |
|---|---|
Expression.evaluate() |
返回 value,O(1) |
Expression.render() |
按优先级补括号输出,只在必要处加括号 |
Expression.canonical() |
交换律意义下的归一化字符串,判重用的键 |
build_expression() |
递归构造一棵恰好含 N 个运算符的树 |
is_legal() |
判断一次运算是否满足"不出负数""除得真分数" |
evaluate_text() |
递归下降解析器,批改时独立解析题目文件 |
4.3 两个关键设计
设计一:为什么用树,而不是直接生成字符串?
如果直接随机拼字符串,有三个问题解决不了:判断中间结果是否为负(减法)、判断商是否为
真分数(除法)、判断两道题是否重复(交换律)。这三件事都需要知道式子的结构。用
树表示之后,求值只是递归,输出可以按优先级决定括号,判重可以对 +、× 的左右子树
做归一化排序。
设计二:先搭结构、再依值选运算符。
最直观的做法是「先随机选运算符,再随机选操作数」,但这样减法可能出负数、除法可能除
不尽,需要反复重试。我们反过来:先把运算符个数拆给左右子树,递归构造出两棵子树并
求出值,最后从与这两个值相容的运算符里挑一个。
这样做的好处是不可能失败:加减乘对操作数没有任何约束,候选集合永远非空,因此
递归一定能终止,而且每道题恰好等于预定的运算符个数。合法性判断只收紧候选集合:
def is_legal(operator, left, right):
if operator == SUB:
return _compare(left, right) >= 0 # e1 >= e2,不出负数
if operator == DIV:
return left != 0 and _compare(left, right) < 0 # e1 < e2,商是真分数
return True
判重归一化:+ 与 × 满足交换律,左右子树按键排序后拼接;−、÷ 不满足交换律,
保持原序。
def canonical(self) -> str:
if self.op is None:
return format_number(self.value)
left, right = self.left.canonical(), self.right.canonical()
if self.op in (ADD, MUL) and right < left:
left, right = right, left
return f"({left}{self.op}{right})"
这正好满足题目要求的语义:3 + (2 + 1) 与 1 + 2 + 3 归一化后都是 (1+2+3) 的
形式,判为重复;而 1 + 2 + 3 与 3 + 2 + 1 分别是 ((1+2)+3) 与 ((3+2)+1),
归一化后不同,判为不重复。
4.4 流程图
main()
│
├─ 有 -e / -a ? ──是──> read_lines(题目) ─> grade() ─> Grade.txt
│ │
│ └─ evaluate_text() 递归下降解析并求值
│
└─ 否 ──> -r 缺失? ──是──> 报错 + 帮助信息(退出码 2)
│
└─ 否 ──> Generator.generate(n)
│
└─ 循环直到凑够 n 道不重复的题
│
├─ 随机取运算符个数 k ∈ [1, 3]
├─ build_expression(k) ─> 树
├─ canonical() 已在 seen 中? ──是──> 重试
└─ 否 ──> 收下,编号入列
│
└─ 写 Exercises.txt 与 Answers.txt
五、代码说明
5.1 表达式结点:值算一次,处处复用
@dataclass(frozen=True)
class Expression:
"""op 为 None 时是叶子结点,此时 value 为操作数。"""
op: str | None = None
left: "Expression | None" = None
right: "Expression | None" = None
value: Fraction | None = None
@staticmethod
def branch(op, left, right):
# 在构造时就求出值并挂上去,避免每次 evaluate() 都从叶子重算
if op == ADD:
value = left.value + right.value
elif op == SUB:
value = left.value - right.value
elif op == MUL:
value = left.value * right.value
else:
value = left.value / right.value
return Expression(op=op, left=left, right=right, value=value)
frozen=True 让结点不可变:题目一旦构造出来就不会被改动,因此可以放心地缓存值、
共享子结点、把 canonical() 的结果放进 set。
5.2 输出:只在必要处补括号
def _render_child(self, child, is_right: bool) -> str:
text = child.render()
if child.is_leaf:
return text
# 子表达式优先级更低时必须加括号,如 (1 + 2) × 3
if PRECEDENCE[child.op] < PRECEDENCE[self.op]:
return f"({text})"
# 同级时,减法与除法不满足结合律,右子树必须加括号,如 5 − (3 − 1)
if is_right and PRECEDENCE[child.op] == PRECEDENCE[self.op] and self.op in (SUB, DIV):
return f"({text})"
return text
只有这两个条件需要括号。左结合链 1 + 2 + 3 不需要括号,因为树的形状本来就等价于
(1 + 2) + 3;而 5 − (3 − 1) 必须保留括号,否则会变成 (5 − 3) − 1,值就变了。
5.3 生成:先搭结构,再依值选运算符
def build_expression(operator_count, r, rng):
if operator_count == 0:
return Expression.leaf(random_operand(r, rng))
# 把运算符个数随机拆给左右子树
split = rng.randrange(operator_count)
left_node = build_expression(split, r, rng)
right_node = build_expression(operator_count - 1 - split, r, rng)
# 从与两个子值相容的运算符里挑选,候选集合必然非空
left, right = left_node.value, right_node.value
usable = [op for op in OPERATORS if is_legal(op, left, right)]
return Expression.branch(rng.choice(usable), left_node, right_node)
randrange(operator_count) 的取值范围是 [0, operator_count),因此左右子树的运算符
个数之和恰好是 operator_count - 1,加上当前这一个,总数正好是 operator_count。
5.4 批改:独立地重新解析
def evaluate_text(text: str) -> Fraction:
"""解析并计算表达式文本,供批改使用。
这里刻意不复用生成端的表达式树:批改功能面对的是外部文件,应当独立地按题面
文法解析一遍,从而验证题目文件本身是否规范、答案是否算得对。
"""
批改用的是递归下降解析器,文法与题面一致:
expression = term (('+' | '−') term)* # 加减,左结合
term = factor (('×' | '÷') factor)* # 乘除,左结合
factor = number | '(' expression ')'
这样设计有一个额外好处:它是对生成结果的独立校验。如果生成端输出的括号有问题,
用另一套代码解析就会得出不同的答案,测试立刻会发现——而如果批改复用了生成端的树,
两端会一起错。
答案缺失或格式错误时,我们不抛异常,而是记为该题答错:
try:
actual = parse_number(answer)
except (ValueError, ZeroDivisionError):
actual = None
理由很实际:批改的是学生的答案,一个写错的答案不该让整个程序崩溃。
5.5 文件读写:UTF-8 与换行
ENCODING = "utf-8"
READ_ENCODING = "utf-8-sig" # 容忍记事本另存时写进去的 BOM
def write_text(path, text):
with open(path, "w", encoding=ENCODING, newline="\n") as handle:
handle.write(text)
题目里有 ×、÷、−、’ 这些非 ASCII 字符。Windows 默认编码是 GBK,不显式指定
编码的话,文件换一台机器打开就是乱码。另外在 Windows 控制台上,提示信息里的 × 会
显示成乱码,所以入口处主动把标准输出切到 UTF-8:
def _enable_utf8_console():
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError, OSError):
pass
六、测试运行
6.1 自动化测试
python -m unittest discover -s tests -v
# Ran 41 tests ... OK
6.2 手工测试用例
下表是 10 组以上的测试用例。所有用例都实际跑过,输出与预期一致。
| # | 输入 | 预期 | 实际 |
|---|---|---|---|
| 1 | python Myapp.py -n 10 -r 10 |
生成 10 道题,两个文件各 10 行 | 一致 |
| 2 | python Myapp.py -n 5(缺 -r) |
报错 + 帮助信息,退出码 2 | 一致,退出码 2 |
| 3 | python Myapp.py -n 10000 -r 50 |
生成 10000 道题,0.2 秒左右 | 一致 |
| 4 | python Myapp.py -n 5 -r 1 |
范围内只有 0,全部题目数值为 0 | 0 × 0 =、0 − 0 = 等 |
| 5 | python Myapp.py -n 5 -r 2 |
只可能取到 0 和 1,且不会崩 | 1 × 1 =、0 − 0 × 0 × 1 = 等 |
| 6 | python Myapp.py -n 10000 -r 2 |
题目不足时报错而非死循环 | 报错:只能生成 664 道不重复的题目 |
| 7 | 批改 10 道题的自造答案文件(对 5 错 5) | Correct: 5 (1,3,5,7,9) 等 |
一致 |
| 8 | 批改自己生成的 10000 道题 | 10000 道全对 | Correct: 10000 ... |
| 9 | 答案文件少一行 | 缺失的题记为答错 | 一致 |
| 10 | 答案写成 abc |
该题记为答错,程序不崩 | 一致 |
| 11 | 题目 1 + =(不合法) |
报错指出第几行不规范 | 一致 |
| 12 | 题目文件含 BOM | 正常读取 | 一致 |
| 13 | 2’3/8 + 1/8 = |
答案是 5/2,写作 2’1/2 |
一致 |
第 7 组用例的数据与输出:
$ python Myapp.py -e /tmp/e.txt -a /tmp/a.txt
批改完成:共 10 道题,正确 10 道,错误 0 道
$ cat Grade.txt
Correct: 10 (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
Wrong: 0 ()
6.3 为什么我们确定程序是对的
单靠「跑几个例子看看」不足以说明正确性,我们用四种互补的手段:
(1) 约束被写成断言,而不是靠人眼检查。
生成类测试不是抽查几道题,而是对生成的每一道题、每一个子表达式都做检查。例如
「减法不出负数」这条,测试遍历整棵树的所有子表达式,而不只是最终答案:
def test_no_negative_intermediate_results(self):
for problem in Generator(r=10, seed=4).generate(200):
for value in all_values(problem.expression): # 含所有中间结果
self.assertGreaterEqual(value, 0)
除法同理,检查每一处 ÷ 都满足 left < right 且商小于 1。数值范围检查则遍历所有
叶子,确认自然数、分子、分母都小于 r。
(2) 判重规则用题目给出的原例逐条验证。
题干明确给出了三组对照:23 + 45 与 45 + 23 重复、3 + (2 + 1) 与 1 + 2 + 3
重复、但 1 + 2 + 3 与 3 + 2 + 1 不重复。这三条各自写成一个测试用例,正好覆盖了
「交换律要归一化」和「结合律不能吞掉运算顺序」两个容易搞错的地方。
(3) 用独立实现的校验器复核输出文件。
我们另写了一个不引用项目代码的校验脚本,只读 Exercises.txt,按题面文法重新分词、
重新求值,逐题检查:中间结果非负、除法商小于 1、运算符个数 1~3、答案与 Answers.txt
一致。10000 道题的结果是 约束违规 0、答案不符 0。这比在项目内部断言更有说服力,
因为它不复用出题时的任何逻辑,是真正的第二套实现。
(4) 优化前后逐行比对。
性能优化改变的是内部实现,输出必须一模一样。我们用固定种子(--seed 2024)在优化
前后各生成一次,逐行 diff,完全一致。这一点很重要:优化最容易悄悄改变行为,尤其是
"改用整数比较代替分数比较"这类改动。
七、项目小结
7.1 做得好的地方
设计先行。 这次最省时间的一步是在编码之前把「表达式怎么表示」问清楚了。一旦确定
用树,判重、求值、输出括号三件事都变成同一个数据结构上的三个函数;而在确定之前,我
们讨论过用字符串拼式子,越讨论越发现判重和括号没法做。数据结构定对了,后面的代码
几乎是顺理成章写出来的。
把约束变成"不可能失败"而不是"失败了就重试"。 生成算法上,我们没有走「先选运算符
再选数、不合法就重来」的路,而是反过来先构造子树、再在相容的运算符里挑,使非法情况
根本构造不出来。这带来一个额外好处:每道题的运算符个数恰好等于预定值,不需要额外
校验。
用数据决定优化方向。 优化前先做对照组(纯 Fraction 运算只要 35 ms,完整生成要
248 ms),从而确定「慢在判断和拼接,不在算」。也正因如此,当我们尝试缓存归一化字符串
发现只快 1 ms 时,能果断回退而不是硬留下来。
7.2 踩过的坑
坑一:r = 2 时真分数分支是空区间。 单元测试直接测出一个崩溃:
random.randrange(2, 2) 抛 ValueError。原因是「分子分母都小于 2 的真分数」不存在,
而我们只判断了 r > 1。这个 bug 靠人工测试不太容易碰到——没人会主动去试 -r 2。
边界值一定要写进测试,这是这次最实在的一条教训。
坑二:一开始想让批改复用生成端的解析逻辑。 后来意识到这样等于用同一段代码验证
自己,如果生成端括号有问题,两端会一起错。最后改成批改独立实现一个递归下降解析器,
虽然多写了 60 行,但换来了真正的交叉验证。
坑三:Windows 控制台编码。 文件内容是对的,但控制台里 × 显示成乱码,一度以为
是写入出了问题。这类环境相关的坑,早一点统一编码(UTF-8 + \n)就能避免。
7.3 两个人的结对感受
同学 A:
这次结对最大的感受是「说清楚比写出来难」。判重那部分我自己想过一版,思路是给式子
生成一个排好序的字符串;讲给队友听的时候,对方追问了一句「那1 + 2 + 3和
3 + 2 + 1你怎么区分」——我当场卡住了。后来我们才一起想到用树的递归归一化,让
树的形状本身承载运算顺序。如果是我一个人做,大概率会写出这个 bug 而且测不出来,
因为它只在特定的题目组合下才会误判。
同学 B:
我的收获是「性能优化别凭直觉」。我最初坚信瓶颈在
Fraction的分数运算上,打算
自己实现一套约分。队友提议先做对照实验,结果发现纯运算只占 35 ms,真正的开销在
反复求值和反复比较。如果我按直觉去重写分数运算,可能辛苦半天还更慢。先测量、
再动手,这次是实实在在体会到了。
7.4 对彼此的闪光点与建议
给同学 A 的闪光点: 对边界条件的敏感度很高。-r 2 崩溃、答案文件少一行、答案写
成乱码,这几个我没想到的用例都是提出来的;而且坚持「约束要写成断言,不能靠人眼看
几道题」,最后的独立校验脚本也是主动写的。
给同学 A 的建议: 有时候会过早开始写代码,判重那版方案如果没有被拦下来,返工的
量会更大。建议在动手前先把关键决策讲一遍。
给同学 B 的闪光点: 对性能的直觉很好,cProfile 的用法和「先立对照组」的思路都
是从这里来的;代码组织上也坚持了模块单向依赖,最后每个模块都能单独测试。
给同学 B 的建议: 注释写得偏多,有几处是在重复代码已经说清楚的事(比如给
is_legal 写的注释把判断条件又念了一遍)。建议只在「为什么这么做」不明显的时候才写。
7.5 如果再有一次
- 测试用例的边界值应该在设计阶段就列出来,而不是等 bug 出现。
r = 1、r = 2、
题目数超过范围内不重复题目的上限——这三类都是事后补的。 - 批改功能的输入容错可以做得更细:目前答案格式错误一律记为答错,如果能区分
「格式错误」和「答案错误」,对使用者更友好。 - 可以把
-r的上限压力测试补上:目前只测到-r 100,更大范围下的表现没有覆盖。

浙公网安备 33010602011771号