小学四则运算程序

结对项目——小学四则运算题目生成程序

项目 内容
姓名 / 学号 **【请填写】同学 刘心怡 / 学号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 + 33 + 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 + 4545 + 23 重复、3 + (2 + 1)1 + 2 + 3
重复、但 1 + 2 + 33 + 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 = 1r = 2
    题目数超过范围内不重复题目的上限——这三类都是事后补的。
  • 批改功能的输入容错可以做得更细:目前答案格式错误一律记为答错,如果能区分
    「格式错误」和「答案错误」,对使用者更友好。
  • 可以把 -r 的上限压力测试补上:目前只测到 -r 100,更大范围下的表现没有覆盖。
posted @ 2026-09-22 00:13  刘心怡  阅读(4)  评论(0)    收藏  举报