第三次作业

结对项目:小学四则运算题目生成器


这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694
这个作业的目标 让我们体验结对编程的感觉,提升我们的携手作战能力

一、介绍

项目 内容
成员一 马肇哲 学号:3124004138
成员二 陈绎谦 学号:3124004125
GitHub 项目地址 https://github.com/mazhaozhe/mazhaozhe/tree/main/Myapp

二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 60 50
· Estimate 估计这个任务需要多少时间 30 20
Development 开发 145 100
· Analysis 需求分析(包括学习新技术) 70 60
· Design Spec 生成设计文档 60 45
· Design Review 设计复审(和同伴审核设计文档) 45 40
· Coding Standard 代码规范(为开发制定合适的规范) 30 25
· Design 具体设计 180 150
· Coding 具体编码 380 300
· Code Review 代码复审 90 90
· Test 测试(自我测试,修改代码,提交修改) 40 30
Reporting 报告 50 55
· Test Repor 测试报告 40 30
· 效能分析与性能优化 cProfile 定位热点、优化与回测 20 10
· Size Measurement 计算工作量 20 15
· Postmortem & Process Improvement Plan 事后总结,提出过程改进计划 20 10
合计 1280 1030

三、运行效果

python Myapp.py -n 10 -r 10        # 生成 10 道 10 以内(不含 10)的题目
python Myapp.py -e Exercises.txt -a Answers.txt   # 判分统计

实际运行(-n 10 -r 10)生成的 Exercises.txt(节选):

4/5 × 1/5 × 1/2 = 
(6 + (2 + 2/3)) ÷ 6'1/2 = 
8 + 5 + 6 = 
(7 - (3/8 - 1/5)) ÷ 3/8 = 
1/2 ÷ 5 = 
4'1/2 ÷ 1 ÷ 4/5 = 
7 - 3/4 = 
8 × (3/5 + 4) = 

对应 Answers.txt(节选):

2/25
1'1/3
19
18'1/5
1/10
5'5/8
6'1/4
36'4/5

判分统计(故意把第 2、5 题答案写错):

> python Myapp.py -e my_exercises.txt -a my_answers.txt
共判定 5 道题目:正确 3 道,错误 2 道,统计结果已写入 Grade.txt

Grade.txt:

Correct: 3 (1, 3, 4)
Wrong: 2 (2, 5)

四、效能分析

我们在保证正确性的第一个版本完成之后,使用 cProfile 对 -n 10000 -r 10 场景做了性能画像,并编写了可复现的基准脚本 tools/benchmark.py(固定随机种子)。在改进程序性能上约花费 10 分钟。

性能分析图

消耗最大的函数:random_tree(含其子调用 random_operand)。这符合预期——程序采用"生成—验证"(拒绝采样)范式:每道题要随机构造 1~3 个运算符的表达式树(平均约 1.8 棵候选项/题,含约 7 个节点),构造与判重开销自然集中在树的递归构造上。其次是 format_value(分数→文本,被 canonical 与 to_text 重复调用约 9.6 万次)。

改进思路与两次尝试:

  1. 整数快速路径(保留):画像显示 fractions.Fraction 的混合运算开销显著(仅 lt 比较就被调用约 6 万次)。我们让自然数操作数与运算结果直接用 int 表示,÷ 的 int/int 分支直接以 Fraction(left, right) 构造结果并预判整除。优化后 cProfile 热点榜上 Fraction 的比较/运算方法消失。
    但我们也发现:墙钟时间在万题规模(约 0.25 s)下受机器噪声影响(重复测试波动 ±10%),收益不明显——瓶颈在随机树构造而非分数运算。这让我们确认:0.25 s 已远超"支持一万道"的需求,继续深挖属于过度优化。
  2. format_value 记忆化(尝试后回退):给分数→文本的函数加 lru_cache。cProfile 下该函数确实从热点榜消失,但实测 r=50 时反而变慢(约 0.21s → 0.26s):答案值域大、缓存命中率低,产生了缓存抖动。我们回退了该改动。这次"负优化"让我们体会到:缓存收益取决于值域与命中率,不是加了就快。

结论:以正确性与可测性优先,性能上保留确定有效的函数级优化;万题生成实测 0.2~0.3 s、且耗时随规模近似线性增长,远超需求。


五、设计实现过程

5.1 代码组织

Myapp.py                 # 命令行入口
myapp/
  fraction_utils.py      # 分数层:真分数/带分数的格式化与解析
  expression.py          # 表达式层:随机生成、带约束求值、序列化、规范化判重
  generator.py           # 生成层:拒绝采样循环 + 文件输出
  checker.py             # 判题层:词法分析 + 递归下降解析 + 判分统计
  cli.py                 # 交互层:参数解析与流程控制
tests/                   # 45 个单元/功能测试
tools/benchmark.py       # 性能基准脚本

五个模块自底向上分层,只允许上层调用下层(cli → generator/checker → expression → fraction_utils),职责单一、互不耦合,方便各自测试。

核心数据结构只有两个类(myapp/expression.py):

  • Value:叶子节点,保存一个非负数值(自然数用 int,分数用 Fraction);
  • Op:内部节点,保存运算符(+ - × ÷)与左右子树。

题目即一棵表达式二叉树,生成、求值、输出、判重全部围绕这棵树进行。

5.2 三个关键设计决策

① 判重:树的规范化键,而不是答案值比较。

"交换 + / × 左右算式"作用的对象是表达式树:交换某个 +/× 节点的左右子树。据此,两题等价 ⟺ 两棵树可以通过有限次这样的交换互相变换。为每棵树计算一个规范化键(等价类的唯一代表):

  • +、× 节点:两个子键按字典序排序后拼接(交换律);
  • -、÷ 节点:保持左右顺序(不满足交换律)。

验证题目给的例子:1+2+3 左结合为 (1+2)+3,顶层子键为 ["(1+2)", "3"],排序后 3+(2+1) 得到相同键 → 判为重复 ✔;而 3+2+1 是 (3+2)+1,顶层分裂方式不同({3,2} | {1} ≠ {1,2} | {3}),无法通过交换变换 → 键不同,不重复 ✔。注意不能用"答案相同"判重——1+2+3 与 3+2+1 答案相同但不重复。

② 约束检查:求值时逐节点校验。

对树做后序遍历求值,在每个节点处检查:减法要求左 ≥ 右(保证全程无负数);除法要求除数非 0 且商"不整除"(保证结果是分数)。违反即抛异常,整题作废重来(拒绝采样)。操作数本身在生成时就保证在范围内(自然数 0~r-1,分数分子分母 < r)。

③ 范围过小的退化保护。

-r 1 时合法题目总数有限(实测约 80 道)。生成循环设尝试次数上限,超限报错提示增大 -r,而不是死循环。

5.3 关键流程图

题目生成流程图


六、代码说明

① 带约束求值(myapp/expression.py)——三条硬约束都在这里落地:

def evaluate(node):
    if isinstance(node, Value):
        return node.value                  # 自然数以 int 表示(快速路径)
    left, right = evaluate(node.left), evaluate(node.right)
    op = node.op
    if op == "+":
        return left + right                # int/Fraction 可直接混合运算
    if op == "-":
        if left < right:                   # 约束1:计算过程不能出现负数
            raise ExpressionError("减法出现负数")
        return left - right
    if op == "×":
        return left * right
    if op == "÷":
        if right == 0:                     # 约束2:除数不能为 0
            raise ExpressionError("除数为 0")
        if isinstance(left, int) and isinstance(right, int):
            if left % right == 0:          # 约束3:结果必须是分数(不能整除)
                raise ExpressionError("除法结果不是分数")
            return Fraction(left, right)
        quotient = left / right
        if quotient.denominator == 1:
            raise ExpressionError("除法结果不是分数")
        return quotient

② 规范化判重(myapp/expression.py)—— exchange 等价类的唯一代表:

def canonical(node):
    if isinstance(node, Value):
        return format_value(node.value)
    left, right = canonical(node.left), canonical(node.right)
    if node.op in ("+", "×") and right < left:   # 交换律:子键排序
        left, right = right, left
    return f"({left}{node.op}{right})"           # -、÷ 保持左右顺序

③ 恰好 k 个运算符的随机树(myapp/expression.py):

def random_tree(rng, limit, operator_count):
    if operator_count == 0:
        return Value(random_operand(rng, limit))
    if operator_count == 1:
        return Op(rng.choice(_OPERATORS), Value(...), Value(...))
    left_ops = rng.randint(0, operator_count - 1)   # 剩余运算符随机分给两棵子树
    right_ops = operator_count - 1 - left_ops
    return Op(rng.choice(_OPERATORS),
              random_tree(rng, limit, left_ops),
              random_tree(rng, limit, right_ops))

④ 最少括号序列化(myapp/expression.py):

def _to_text(node, parent, is_right):
    if isinstance(node, Value):
        return format_value(node.value)
    text = f"{_to_text(node.left, node.op, False)} {node.op} " \
           f"{_to_text(node.right, node.op, True)}"
    # 四个运算符均左结合:子级优先级更低,或同级但处于右侧 → 需要括号
    if parent is not None and (
            _PRECEDENCE[node.op] < _PRECEDENCE[parent]
            or (_PRECEDENCE[node.op] == _PRECEDENCE[parent] and is_right)):
        return f"({text})"
    return text

因此 (1+2)+3 输出为 1 + 2 + 3,而 3+(2+1) 输出为 3 + (2 + 1)。

⑤ 判题的递归下降分析(myapp/checker.py)——独立于生成器的第二套实现:

# 文法(四则运算符均为左结合):
#   expr   := term (('+' | '-') term)*
#   term   := factor (('×' | '÷') factor)*
#   factor := 数值 | '(' expr ')'
def _expr(self):
    value = self._term()
    while self._peek() in ("+", "-"):
        if self._next() == "+":
            value = value + self._term()
        else:
            value = value - self._term()
    return value

生成器与判题器是两套独立实现(前者基于内存中的表达式树,后者从文本重新解析),二者互验是正确性的重要来源。


七、测试运行

测试框架为标准库 unittest,共 45 个用例,全部通过;关键用例如下(节选 14 个):

# 测试内容 输入 / 方法 预期 结果
1 分数格式化 Fraction(19,8) 2'3/8 ✅
2 分数解析往返 "2'3/8" 解析后格式化 还原 "2'3/8" ✅
3 作业示例:分数加法 1/6 + 1/8 7/24 ✅
4 负数拒绝 1 - 2 求值 抛出 ExpressionError ✅
5 整除拒绝 4 ÷ 2(结果为自然数) 抛出 ExpressionError ✅
6 带分数除法合法 3 ÷ 2 1'1/2(即 3/2) ✅
7 判重·作业示例 3+(2+1) vs 1+2+3 规范化键 相同(重复) ✅
8 判重·作业示例 1+2+3 vs 3+2+1 规范化键 不同(不重复) ✅
9 判重·乘法交换 6×8 vs 8×6 相同(重复) ✅
10 随机性质测试 3000 棵随机树:结果非负、÷ 结果必为分数 全部满足 ✅
11 操作数范围 500 题:所有自然数/分子/分母 < r 全部满足 ✅
12 万题压测 -n 10000 -r 10 10000 道、互不重复、答案可复算 ✅
13 参数校验 只给 -n 10 缺 -r 退出码 2 + 帮助信息 ✅
14 判分统计 4 题故意错 2 题 Correct: 2 (1, 4) / Wrong: 2 (2, 3) ✅

为什么我们能确定程序是正确的?

  1. 双实现互验:题目由表达式树直接求值生成答案;判题器则用一套独立的"文本 → 递归下降解析 → 求值"重新计算。测试断言"判题器复算的答案 == 生成器给出的答案",两条路径一致才可能都错,可能性极低;
  2. 性质测试代替抽样:不满足于几个手选例子,用固定种子的 3000 棵随机树验证三条约束的全局性质(无负数、除法为分数、运算符 ≤ 3、操作数 < r);
  3. 边界用例来自作业文本:判重的两组示例(3+(2+1)≡1+2+3、1+2+3≢3+2+1)、分数运算示例(1/6+1/8=7/24)直接做成单元测试;
  4. 端到端验证:CLI 以子进程方式真实运行(生成模式、判题模式、缺参报错、退出码),覆盖用户实际看到的行为;
  5. 退化路径有测试:-r 1 可正常生成少量题目,题目数超出上限时报错而非死循环。

完整测试运行方式:

python -m unittest discover -s tests -v

八、源代码管理

项目按模块增量提交,commit 信息说明"做了什么、为什么",便于回溯:

52fe8e0 docs: rename project title to English for GitHub repository
6ac83f1 perf: 求值热路径整数快速路径,减少 Fraction 对象构造
cc0d809 test: 添加生成性能基准脚本 tools/benchmark.py
23792ec feat: 实现命令行接口,支持 -n/-r/-e/-a
39b4f89 feat: 实现题目解析与判分统计(Grade.txt)
100122b feat: 实现题目批量生成与 Exercises/Answers 文件输出
0a0795d feat: 实现表达式树生成、约束求值与规范化判重
43400cb feat: 实现真分数/带分数的格式化与解析工具
ec8b1db chore: 初始化项目结构、README 与 .gitignore

九、项目小结与结对感受

马肇哲:这次结对最大的收获是"先设计、后编码"的踏实感。判重是最难的点,我们一开始想用"答案相同即重复",随着深入发现答案一样但不重复,最后落到表达式树的规范化键上,越写越顺。结对编程时两个人盯着同一块屏幕,低级错误当场就被抓住,编码阶段的返工比一个人单干少了很多。

陈绎谦:我印象最深的是效能分析那一段。加 lru_cache 时 cProfile 上很好看,一跑基准反而变慢了,我们做了 A/B 对比才定位到是缓存抖动,果断回退。这个"负优化"经历比任何成功优化都值钱。另外3000 棵随机树做性质测试,把我在解析器里埋的一个括号边界问题直接炸了出来。

彼此的闪光点与建议:

  • 闪光点一:对需求逐字抠——"除法结果应是真分数"这类歧义条款,我们先查题目对"真分数"的定义,再落地成代码注释与测试,避免了想当然;
  • 闪光点二:git 习惯好——小步提交、信息规范,出问题能秒级回滚定位;
  • 建议:结对时两人轮换"驾驶员/领航员"可以更频繁一些,前期有一段时间一个人写得太久,另一个人参与感略低;下次可以尝试更严格的乒乓编程。

经验与教训:

  • 难点提前设计:判重的等价关系想清楚了,代码只有 6 行;想不清楚,写多少都是补丁;
  • 用测试锁定需求歧义:把作业原文里的例子直接变成单元测试,需求即规格;
  • 性能优化必须测量:画像 → 假设 → 优化 → 回测,缺一步都可能做出"看起来快了"的负优化。
posted @ 2026-09-21 13:11  MaZhaozhe  阅读(19)  评论(0)    收藏  举报