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

软件工程项目:实现一个自动生成小学四则运算题目的命令行程序

一、基本内容

这个作业属于哪个课程 软件工程
这个作业要求在哪里 结对项目
这个作业的目标 两人结对完成一个命令行四则运算题目生成器,满足生成题目、控制数值范围、去重、批改答案等 9 项功能需求,熟悉结对开发全流程。

成员信息:

结对成员 姓名 学号
成员一 谭添睿 3224004344
成员二 李采云 3224004382

Github 仓库地址: https://github.com/ttr-rui/Pair-Project-Math-Exercise-Generator


二、PSP 表格(预估时间)

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 30
· Estimate · 估计任务时间 30
Development 开发 300
· Analysis · 需求分析 40
· Design Spec · 生成设计文档 20
· Design Review · 设计复审 20
· Coding Standard · 代码规范 10
· Design · 具体设计 30
· Coding · 具体编码 100
· Code Review · 代码复审 20
· Test · 测试 60
Reporting 报告 60
· Test Report · 测试报告 20
· Size Measurement · 计算工作量 10
· Postmortem & Process Improvement Plan · 事后总结 30
合计 390

三、效能分析

1. 测试环境与测试方法

项目 说明
CPU Windows x64
Python 3.13
工具 cProfile + pstats(累计/自身耗时双口径)
测试命令 python profile_perf.py(-n 10000 -r 10)

2. 性能分析图

image-20260919180035546

image-20260919183623008


四、设计实现过程

1. 代码组织

Pair-Project-Math-Exercise-Generator/
├── main.py             # 命令行入口:参数解析 + 调度
├── fraction_util.py    # 分数 ↔ 字符串 的双向转换
├── calculator.py       # 表达式树求值
├── generator.py        # 表达式树生成 + 约束校正 + 规范化去重 + 转字符串
├── checker.py          # 批改:读题求值 → 比对 → 写 Grade.txt
├── test_main.py        # 单元测试(19 个用例)
├── .gitignore
└── README.md

依赖关系(箭头表示"调用"):

            main.py
          /   |    \
   generator  checker  fraction_util
      |        |          ▲
   calculator ─┘           │
      └────────────────────┘

五个模块的职责边界很清楚:

模块 唯一职责 不做什么
fraction_util 格式 ↔ 数值 的转换 不做算术
calculator 对已有表达式树求值 不管字符串、不管生成
generator 造树、纠错、去重、加括号 不管文件读写
checker 读文件、求值比对、输出统计 不管生成
main 参数解析、调度、写 Exercises/Answers 不含任何业务逻辑

generator 里的建树已经顺手把值算出来了,calculator.evaluate_tree 仍然单独存在——因为测试用例和批改模块需要独立复算,不能用生成器自己给出的值,否则就是"自己证明自己"。

2. 核心数据结构

表达式统一用二叉树(嵌套元组)表示:

叶子节点:("num", Fraction)
运算节点:(op, left, right)     # op ∈ {"+", "-", "×", "÷"}

选元组而不是类,是因为它天然可哈希、可直接进 set 去重、可整棵递归传递,省掉了一整个类的样板代码。代价是没有字段名,所以代码里统一约定 tree[0] 是标签、tree[1]/tree[2] 是左右子树——这个约定我们一开始就写在模块 docstring 里了。

3. 关键算法

(1)生成算法 —— 边生成边校正

generate_tree(max_value, op_count) 递归构造:随机把 op_count-1 个运算符分给左右子树,再随机挑运算符,最后按局部约束校正:

  • -:left < right 就交换,保证结果非负;
  • ÷:right == 0 就改写成 1;left == right 会导致结果为 1(不是真分数),优先重取左值、否则整棵重试、最终兜底为 1/(r-1) ÷ 1;
  • + ×:无需校正。

为什么不直接"生成完再检查"? 初版我们试过 rejection sampling(生成一棵完整的树,检查不合法就丢弃重来),结果 3 个运算符的树里只要有一处不合法就全废,命中率很低。改成递归过程中就地校正后,一次成型的比例大幅提升,这也是后来 generate_tree 能占到 75% 却仍然只有 0.19 秒的原因。

(2)去重算法 —— 规范化元组(本项目的技术核心)

题目要求:任何两道题不能通过有限次交换 + 和 × 的左右子树变成同一道题。做法是给每棵树算一个"规范形式" canonical(tree),相同当且仅当两题等价。

def canonical(tree):
    if tree[0] == "num":
        return (0, tree[1])            # ← 标签 0
    op, left, right = tree[0], tree[1], tree[2]
    lc, rc = canonical(left), canonical(right)
    if op in ("+", "×"):
        if lc > rc:
            lc, rc = rc, lc
    return (1, op, lc, rc)             # ← 标签 1

这里最容易漏掉的是首尾那个标签。0 和 1 不是随便写的,它们保证了叶子节点的元组永远小于任何内部节点。少了这个标签,3+(2+1) 和 1+2+3 就会因为树形不同而被判为不重复,直接读错题。

实测对照(题目原文给的三个例子,我们全部验过):

题目 A 题目 B A ≡ B? 期望 结果
23 + 45 45 + 23 是 重复 ✅ 判为重复
6 × 8 8 × 6 是 重复 ✅ 判为重复
3+(2+1) 1+2+3 是 重复 ✅ 判为重复
1+2+3 3+2+1 否 不重复 ✅ 判为不重复

后两例的推导:1+2+3 = ((1+2)+3),排序时叶子 3 被换到前面得到 (0,3) + (1,'+',(0,1),(0,2));3+(2+1) 右侧是 (0,3)、左侧是内部节点,排序后同样是那个元组,所以判重。而 3+2+1 = ((3+2)+1) 排序后是 (0,1) + (1,'+',(0,2),(0,3)),内部节点里 2 和 3 的次序不同,故不判重。完全踩中题目"有限次交换"的定义。

(3)批改算法 —— 全部操作数强制 Fraction

批改要读的是字符串,所以先把 3/5、2'3/8、整数分别替换成 Fraction(...) 调用,再用受限环境的 eval 求值。这里的坑见 §5.4。

4. 需求覆盖对照表

# 需求 落在哪个模块 如何验证
1 -n 控制题目个数 main 用例 1、8
2 -r 控制数值范围,缺则报错 main 用例 7(退出码 1 + 打印帮助)
3 计算过程不产生负数 generator.generate_tree 单元测试 + 2 万次采样零违规
4 ÷ 结果为真分数 generator.generate_tree 单元测试 + 2 万次采样零违规
5 每道题运算符 ≤ 3 generator.generate_tree 单元测试 test_operator_count_max_3
6 交换律意义下不重复 generator.canonical 用例 4、5 + 一万道全场零重复
7 Exercises.txt / Answers.txt 格式 fraction_util + main 用例 1~3 目视 + 编码校对
8 支持一万道生成 全部 用例 8:0.19 s
9 -e / -a 批改并统计到 Grade.txt(附加分) checker 用例 9、10、11

5. 关键函数流程图

主流程:

开始 → 解析命令行参数
  ├─ 有 -e 和 -a ──→ 批改模式 → 读题目/答案 → 求值比对 → 写 Grade.txt
  ├─ 有 -n        ──→ 缺 -r ? ──是──→ 打印错误 + 帮助 → 退出码 1
  │                     │否
  │                     └─→ 生成模式 → generate_exercises
  │                            └→ generate_tree → canonical 查重 → 写文件
  └─ 参数不足 ──────────→ 打印帮助信息
结束

generate_tree 单棵表达式树的构造流程:

generate_tree(r, op_count)
  ├─ op_count == 0 → 生成一个 < r 的自然数或真分数
  └─ op_count  > 0 → 随机切分 左/右 各得多少运算符 → 递归 → 随机挑运算符
         ├─ '-'  → 左 < 右 则交换  → 返回 (树, 左-右)
         ├─ '÷'  → 右为 0 改写为 1;左 == 右 则重取/重试/兜底;左 > 右 则交换
         ├─ '+'  → 返回 (树, 左+右)
         └─ '×'  → 返回 (树, 左×右)

五、代码说明

5.1 分数格式化(fraction_util.py)

def format_fraction(f: Fraction) -> str:
    """将 Fraction 转换为题目要求的格式:
       整数:5
       真分数:3/5
       带分数:2'3/8
    """
    if f.denominator == 1:
        return str(f.numerator)
    if abs(f.numerator) > f.denominator:
        whole = f.numerator // f.denominator
        remainder = f.numerator % f.denominator
        return f"{whole}'{remainder}/{f.denominator}"
    return f"{f.numerator}/{f.denominator}"

思路:按分母是否为 1 区分整数;再按分子绝对值是否大于分母区分真分数和带分数。Fraction 自带约分,所以我们完全不需要自己写 gcd 和通分——整数÷整数那个 bug(见 §5.5)之所以能修复得这么干净,就是因为底层始终是 Fraction 而不是我们手搓的运算。整个程序从生成到批改全程 Fraction,没有一个浮点数,这是正确性的根基。

已知边界:该函数对负数走的是 Python 向下取整语义。由于生成器保证非负值,这条路径目前没有输入源;若将来扩展减法允许负数,需要单独处理并补测试。

5.2 表达式生成(generator.py)

def generate_tree(max_value: int, op_count: int, _depth: int = 0):
    """递归生成一个有 op_count 个运算符的表达式树,返回 (tree, value)"""
    if op_count == 0:
        v = generate_number(max_value)
        return ("num", v), v

    left_ops = random.randint(0, op_count - 1)
    right_ops = op_count - 1 - left_ops

    left_tree,  left_val  = generate_tree(max_value, left_ops,  _depth + 1)
    right_tree, right_val = generate_tree(max_value, right_ops, _depth + 1)

    op = random.choice(["+", "-", "×", "÷"])

    if op == "-":
        if left_val < right_val:                      # 保证 e1 >= e2
            left_tree, right_tree = right_tree, left_tree
            left_val,  right_val  = right_val,  left_val
        value = left_val - right_val

    elif op == "÷":
        if right_val == 0:                            # 除数为 0 → 改写为 1
            right_tree, right_val = ("num", Fraction(1, 1)), Fraction(1, 1)

        if left_val == right_val and left_tree[0] == "num":
            for _ in range(20):                       # 结果为 1 不是真分数 → 重取左值
                new_v = generate_number(max_value)
                if new_v != right_val:
                    left_tree, left_val = ("num", new_v), new_v
                    break

        if left_val == right_val:                     # 左子树非叶子,改不动 → 整棵重试
            if _depth < 50:
                return generate_tree(max_value, op_count, _depth + 1)
            # 兜底(实测约 0.1% 触发):退化为确定合法的最简除法
            den = max(2, max_value - 1)
            return ("÷", ("num", Fraction(1, den)), ("num", Fraction(1, 1))), Fraction(1, den)

        if left_val > right_val:                      # 保证结果是真分数
            left_tree, right_tree = right_tree, left_tree
            left_val,  right_val  = right_val,  left_val
        value = left_val / right_val

    elif op == "+":
        value = left_val + right_val
    else:
        value = left_val * right_val

    return (op, left_tree, right_tree), value

分层兜底是本函数的设计要点,三级依次降级:

级别 条件 动作 触发率
一级 left == right 且左是叶子 重取左值(最多 20 次) 常见
二级 left == right 且左非叶子 整棵递归重试(_depth<50) 少见
三级 _depth >= 50 退化为 1/(r-1) ÷ 1 ~0.1%

第三级特意写成 1/(r-1) ÷ 1 而不是当初那版"左值 +1"——后者在 -r 10 时会造出 19 这种远超范围的数,属于隐性的需求违反,-r 参数的意义是所有出现过的数都要落在范围内,不只是叶子。

5.3 去重(generator.py)

def canonical(tree):
    """把表达式树规范化为一个元组用于去重。
       对 + 和 × 应用交换律:左右子树按字典序排序。"""
    if tree[0] == "num":
        return (0, tree[1])          # 叶子标签 0

    op, left, right = tree[0], tree[1], tree[2]
    lc, rc = canonical(left), canonical(right)

    if op in ("+", "×"):
        if lc > rc:
            lc, rc = rc, lc          # 交换律:小的放前面

    return (1, op, lc, rc)           # 内部节点标签 1

为什么 (0, ...) 和 (1, ...) 不能省,第四节已经推导过。另外注意 - 和 ÷ 不能排序——5-3 和 3-5 不是一道题(后者还是负数),1÷2 和 2÷1 也不是一道题(后者不是真分数),所以 if op in ("+", "×") 这个判断同样不可省。

5.4 括号处理(generator.py)

题目最容易被忽略的一点是:同一个 Priority 下不能出现多余括号,否则出题会变得很丑。

def _to_string_with_parens(tree, parent_op, is_left):
    prec = {"+": 1, "-": 1, "×": 2, "÷": 2}
    if tree[0] == "num":
        return format_fraction(tree[1])
    op = tree[0]
    s = tree_to_string(tree)
    if prec[op] < prec[parent_op]:                                  # 优先级低 → 加括号
        return f"({s})"
    if prec[op] == prec[parent_op] and not is_left and parent_op in ("-", "÷"):
        return f"({s})"                                             # 右子树的同级运算 → 加括号
    return s

规则只有两条:子运算优先级更低必须加括号;优先级相同但是右侧操作数时也要加括号(因为 - 和 ÷ 不满足交换律,8 - (3 - 1) 少了括号就会变成 8 - 3 - 1 = 4,答案是错的)。左侧则一律不加——+ 和 × 可以展开,- / 的左结合也是对的。

5.5 批改求值(checker.py)—— 本项目踩过的最大的坑

先看我们最初的版本(有 bug,不要照抄):

# ❌ 初版:只把 a/b 形式的字符串转成 Fraction,剩下的整数原样留下
s = re.sub(r"(\d+)'(\d+)/(\d+)", replace_mixed, s)
s = re.sub(r"(\d+)/(\d+)",       r"Fraction(\1, \2)", s)
return eval(s, {"__builtins__": {}}, {"Fraction": Fraction})

这段看起来没问题,跑十道四十道题也全对。但一万道题里有 378 道被误判为错(3.78%)。

原因很隐蔽:题目里只要出现整数 ÷ 整数(比如 2 ÷ 3),替换成 ÷→/ 后就是纯 Python 的 2 / 3,得到浮点数 0.6666666666666666。拿它跟答案文件里的 2/3(Fraction(2,3))一比,永远不等。只有真分数参与的那部分表达式被正确转成了 Fraction,其余落到浮点——所以它是"半对半错",肉眼抽样几道题根本发现不了。

修复版:

def _placeholder(index: int) -> str:
    """占位符里不能出现数字,否则会被后面的整数正则误匹配,
       因此把序号 0-9 映射成字母 A-J。"""
    return "__" + "".join(chr(65 + int(ch)) for ch in str(index)) + "__"


def evaluate_expression_string(expr: str) -> Fraction:
    s = expr.replace("×", "*").replace("÷", "/")
    mapping, counter = {}, [0]

    def next_key():
        key = _placeholder(counter[0]); counter[0] += 1; return key

    def replace_mixed(m):          # ① 带分数 2'3/8 —— 必须最先处理,否则被真分数规则拆坏
        key = next_key()
        whole, num, den = int(m.group(1)), int(m.group(2)), int(m.group(3))
        mapping[key] = f"Fraction({whole} * {den} + {num}, {den})"
        return key

    def replace_fraction(m):       # ② 真分数 1/2
        key = next_key()
        mapping[key] = f"Fraction({int(m.group(1))}, {int(m.group(2))})"
        return key

    def replace_integer(m):        # ③ 剩下的整数,包括 "2 / 3" 里的 2 和 3
        key = next_key()
        mapping[key] = f"Fraction({int(m.group(1))}, 1)"
        return key

    s = re.sub(r"(\d+)'(\d+)/(\d+)", replace_mixed,    s)
    s = re.sub(r"(\d+)/(\d+)",       replace_fraction, s)
    s = re.sub(r"(\d+)",             replace_integer,  s)

    for key, value in mapping.items():                 # ④ 回填(三步全部扫完后再写)
        s = s.replace(key, value)

    return eval(s, {"__builtins__": {}}, {"Fraction": Fraction})

三条经验:

  1. 顺序不能乱:带分数必须先于真分数,否则 2'3/8 里的 3/8 会被提前抢走。
  2. 占位符不能含数字:我们第一版用 __MIXED_0__,结果第 ③ 步的整数正则把 0 又识别成一个操作数,表达式彻底乱掉。改成字母 A–J 映射才解决。
  3. 回填必须延后:三步扫描期间字符串里还留着裸 Fraction(...),如果边扫边回填,后面的正则会命中回填内容。

修复后一万道题闭环批改 Correct: 10000 / Wrong: 0,并补了回归用例 test_eval_integer_division_is_exact 把它钉死。


六、测试运行

1. 单元测试(test_main.py,19 个用例)

python -m unittest test_main -v

【待替换截图:新版的 "Ran 19 tests ... OK"】

编号 用例 测试内容 目的
1 test_format_integer Fraction(5,1) → "5" 整数输出格式
2 test_format_proper_fraction Fraction(3,5) → "3/5" 真分数输出格式
3 test_format_mixed_fraction Fraction(19,8) → "2'3/8" 带分数输出格式
4 test_parse_proper_fraction "3/5" → Fraction(3,5) 输入解析
5 test_parse_mixed_fraction "2'3/8" → Fraction(19,8) 带分数解析
6 test_parse_integer "5" → Fraction(5,1) 整数解析
7 test_add 1/2 + 1/3 = 5/6 加法求值
8 test_sub 5/6 - 1/3 = 1/2 减法求值
9 test_mul 1/2 × 2/3 = 1/3 乘法求值
10 test_div 1/3 ÷ 2/3 = 1/2 除法求值
11 test_operator_count_max_3 30 轮 × {1,2,3} 运算符 运算符个数 ≤ 3
12 test_no_negative_results 100 轮递归全树检查 减法子式 e1 >= e2
13 test_division_always_proper 100 轮递归全树检查 除法结果恒为真分数
14 test_no_duplicate_exercises 生成 20 道,canonical 集合去重 题目之间不重复
15 test_commutative_dedup 3+2 与 2+3 canonical 相同 交换律判定
16 test_eval_simple_fraction "1/2 + 1/3" → 5/6 批改模块求解
17 test_eval_mixed_fraction "2'3/8 + 1/8" → 5/2 带分数参与求值
18 test_eval_with_multiplication "1/2 × 2/3" → 1/3 乘号识别
19 test_eval_integer_division_is_exact "2 ÷ 3" → 2/3 等 3 组 整数÷整数回归

2. 黑盒 / 命令行用例(14 个)

直接跑 main.py,覆盖参数组合、边界与异常。下表为实际执行结果,非预期值:

# 输入 / 场景 预期结果 实际结果
1 -n 10 -r 10 生成 10 题 + 10 答案 退出码 0;题目 10 行、答案 10 行;首题 1. 8 × 4 - 1/4 =
2 -n 1 -r 10 正常生成 1 行 退出码 0;题目 1 行
3 -n 5 -r 5 所有数值 < 5 退出码 0;样例 2/3 - 1/2 ÷ (1/2 + 2) =、1/2 ÷ 2 =
4 -n 10(缺 -r) 报错 + 打印帮助 退出码 1;stderr 错误:必须指定 -r 参数(数值范围);stdout 含 usage
5 -r 10(只给 -r) 打印帮助,不生成文件 退出码 0;Exercises.txt 未生成
6 不带任何参数 打印帮助 退出码 0;stdout 首行 usage: main.py [-h] [-n N] [-r R] [-e E] [-a A]
7 边界 -n 5 -r 1 不崩溃 退出码 0;样例 1. 1 + 1 - 1 =、1 × 1 + 1 =
8 边界 -n 5 -r 2 不崩溃 退出码 0;样例 1. 1 - 1 =、1 × 1 × 1 =
9 -n 10000 -r 10 能生成 1 万道不重复题目 退出码 0;题目 10000 行;端到端(含进程启动与文件 IO)约 0.3~0.6 s,其中纯生成约 0.19 s
10 批改:答案全对 Correct: 10 Correct: 10 (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) / Wrong: 0 ()
11 批改:第 2/4/6 题故意答错 Correct: 7 / Wrong: 3 (2, 4, 6) Correct: 7 (1, 3, 5, 7, 8, 9, 10) / Wrong: 3 (2, 4, 6)
12 批改:题目/答案都不带题号前缀 仍能正确批改 Correct: 1 (1) / Wrong: 0 ()
13 批改:答案无法解析(写 abc) 判为错误而非崩溃 退出码 0;Correct: 0 () / Wrong: 1 (1)
14 批改:带分数答案 1'1/8 + 1'1/4 答 2'3/8 判对 Correct: 1 (1) / Wrong: 0 ()

用完一遍之后可以再跑一次回批:python main.py -e Exercises.txt -a Answers.txt,应当得到 Wrong: 0 ()。

3. 约束采样校验(2 万次)

另有一个 test_constraints.py,对生成的表达式树做全树递归校验(不是只看最终结果),断言六条不变量,各跑一万次:

编号 不变量 违规数
C1 运算符个数 ≤ 3 0
C2 所有减法子式 e1 >= e2 0
C3 所有除法子式 e1 < e2 且 e2 ≠ 0 0
C4 整题的值 ≥ 0 0
C5 一万道两两不重复 0
C6 所有出现的数值 < r 0

4. 为什么能确定程序是正确的?

我们不用"跑了几道题没什么问题"来论证,用的是下面四层:

第一层|构造性保证(最强的一层)
四条约束不是"生成后检查出来的",而是生成过程的数学不变量:- 通过交换保证左 ≥ 右,÷ 通过交换 + 三级兜底保证左 < 右,op_count 直接作为递归参数递减到 0。只要 generate_tree 的每条分支成立,输出就必然合法——不存在"运气好才合法"的情况。单元测试的作用是把每条分支钉死。

第二层|独立复算
生成器返回的 value 我们不信。测试用 calculator.evaluate_tree 重新独立求值,批改模块再用 evaluate_expression_string 从字符串第三次求值。三条路径(树上fold、字符串 eval、文件往返)对同一道题给出同一个 Fraction,才认为正确。

第三层|闭环一致性(最关键的一层)
生成一万道题 → 写文件 → 用 -e/-a 回批 → Correct: 10000 / Wrong: 0。这是最强的端到端证据:它同时证明了「生成合规」「格式往返无损」「答案自洽」「批改正确」四件事。也正是这一层揪出了 §5.5 那个 3.78% 的隐性 bug。

第四层|大规模随机 + 已知答案交叉验证
2 万次采样的约束校验覆盖随机分支;另挑若干含括号、带分数、连除的题目手工计算核对,与程序输出逐字符一致。

5. 功能演示

(1)生成 10 道题(python main.py -n 10 -r 10)

生成 10 道题

Exercises.txt

Answers.txt

(2)批改功能(python main.py -e Exercises.txt -a Answers.txt)

全对的情形:

批改全对

手动改错第 2、4、6 题的答案后,Grade.txt 输出:

Correct: 7 (1, 3, 5, 7, 8, 9, 10)
Wrong: 3 (2, 4, 6)

批改有错

(3)缺 -r 参数的错误提示(python main.py -n 10)

错误提示

(4)一万道规模测试
(python main.py -n 10000 -r 10
python main.py -e Exercises.txt -a Answers.txt)

image-20260919180151126


七、PSP 表格(实际时间)

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 300 350
· Analysis · 需求分析(包括学习新技术) 40 50
· Design Spec · 生成设计文档 20 25
· Design Review · 设计复审(和同事审核设计文档) 20 20
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 10 15
· Design · 具体设计 30 35
· Coding · 具体编码 100 110
· Code Review · 代码复审 20 25
· Test · 测试(自我测试,修改代码,提交修改) 60 70
Reporting 报告 60 70
· Test Report · 测试报告 20 25
· Size Measurement · 计算工作量 10 15
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 30 30
合计 390 445

八、项目小结

1. 结对感受

本次结对项目由我们两人共同完成。整个过程下来,有顺利也有踩坑,幸运的是成功解决并成为了实用的经验。

整体来看,这次合作是成功的。通过按模块划分职责、提前对齐接口、以及用测试作为共同标准,让协作效率明显提高,也避免了大部分返工。

从中我们学到了:一是按时长轮换的分工方式行不通,思路容易断;二是重要的判断最好当场留下记录,不然后面只能靠回忆;三是异常或失败最好在出口处就做好分类,否则后期排查会花很多不必要的精力。

下次如果再结对,我们会更早地确定接口和验收标准,在开始前提早做好完善的计划。

2. 双方互评

李采云对谭添睿:她负责题目生成、表达式树构建、运算求值以及去重算法的核心逻辑,在处理边界情况时习惯按层次逐级兜底,把边界当作设计的一部分来对待,这是项目后期能推得比较稳的主要原因。当然了,如果能在关键判断的当下就留下可复查的痕迹,而不只是临时口头确认,后续复盘时的依据会更扎实。

谭添睿对李采云:她负责分数格式化与解析、批改答案、结果统计,以及后期对整体流程的闭环验证,在代码写完初期先主动做了一轮完整的验证,用实际数据论证结果是否正确,这种处理方式对项目质量帮助很大。如果以后能把不同类型的失败在记录上再做一点区分,排查起来会更省力。

3. 经验教训

(1)索引写错导致大面积测试失败
一开始把表达式树的标签 tree[0] 误写成 tree[1],单元测试几乎全红。两个人一起排查很快就定位了。教训是:元组节点没有字段名,可读性很差,必须在一开始就在 docstring 里固化约定;后续每个函数入口都显式解包 op, left, right = tree[0], tree[1], tree[2],把这个坑一次性堵掉。

(2)不用浮点数,是最划算的一个决定
从设计之初就定了"全程 Fraction,一个数都不用 float"。这个决定后来有两笔收益:一是彻底没有精度误差,二是发现 bug 时可以直接排除整个浮点分支。代价只是 9% 的性能,而我们的性能需求是"一万道 0.2 秒内"。

(3)隐性 bug 只能靠大规模闭环发现
如前所述,抽样测试的心智模型是"如果它有 bug,我随便挑几道应该能发现"。但整数÷整数这种 bug 的命中率是 3.78%,抽 10 道题有一半以上概率完全测不到。从"抽样"切换到"全量闭环",我们对程序正确性的信心才真正建立起来。

(4)except Exception 掩盖问题的教训
调试初期有个 Version 在 checker 里把所有异常静默判错,一度让我们以为是生成器的 bug,绕了不少弯路。

4. 改进方向

(1)-e/-a 的健壮性
目前对题号编号、多余空格做了兼容(strip_number_prefix),但如果题目文件比答案文件长,多出来的题目会被静默忽略。应该显式报一行警告。

(2)-r 1 的语义
-r 1 时所有数值强制退化为 1,题目空间极小且会出现形如 1/2 ÷ 1 的退化表达式(严格说超出范围)。这是 -r 1 下题目采样本质困难导致的,建议要么在参数校验时拒绝 r < 2,要么明确定义为"退化为最小可用范围"。

(3)难度控制
可以增加按 -r 自动调整运算符数量、或增加一个 --level 参数控制是否出现带分数、连除等复杂形态。

(4)更强的等价判定
现在的去重只处理了 +、× 的交换律。若要完全贴合"有限次交换"的定义,还可以考虑结合律带来的等价(1+2+3 与 3+(2+1) 我们已经能识别,这条是踩遍了的);更进一步的话可以处理 a-b+c 与 a+c-b 这类移项等价,但目前题目未要求,做了反而可能把老师认为合法的题目误删。

(5)跨平台与 GUI
目前只在 Windows + Python 3.13 下验证。下一步可以加一份 GitHub Actions 的 CI,在 Linux/macOS 上自动跑测试;有余力再做图形界面版本,方便小学生直接使用。

posted @ 2026-09-21 16:47  x7y4  阅读(12)  评论(0)    收藏  举报