第三周结对项目

结对项目:小学四则运算自动生成程序

邓子豪 3124004165
黄琪 3124004169
Github 项目地址:https://github.com/QH-gen/SEwork_3


一、PSP2.1 表格

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

注:预估列为两人合计;实际耗时为两人结对工作的总耗时。

二、需求分析

程序需要满足的核心需求:

  1. -n 控制题目数量,-r 控制数值范围(必须给定,否则报错并输出帮助信息);
  2. 计算过程不产生负数:e1 - e2 必须满足 e1 >= e2;
  3. 除法子表达式 e1 ÷ e2 的商必须是真分数(0 < e1/e2 < 1);
  4. 每道题目运算符不超过 3 个;
  5. 一次运行内题目不重复(判重标准:能通过有限次交换 +/× 左右表达式互相变换的题目视为重复);
  6. 题目写入 Exercises.txt,答案写入 Answers.txt,真分数用 3/5、带分数用 2'3/8 格式;
  7. 支持一次生成一万道题目;
  8. -e <exercisefile> -a <answerfile> 判分,结果输出到 Grade.txt。

Exercises.txt
image

Answers.txt
image

Grade.txt
image

三、效能分析与改进

改进思路

一开始我们的做法比较粗暴:随机生成一整棵表达式树,然后检查约束,不满足就整棵丢掉重来。写完一跑发现慢得离谱——除法要求商是真分数,但随机出来的除法大部分商都大于 1,丢掉重来的概率太高了,生成几千道题的时候就能明显感觉到卡顿。

后来花了大概两个小时想改进方案,最后决定改成边构造边算值:

  1. 每个节点构造的时候就把子表达式的值算出来(用 fractions.Fraction,不怕精度问题);
  2. 减法不用丢掉重来,左值比右值小的话直接交换左右子树就行,交换完就是一道合法的减法题;
  3. 除法两个方向都试一下,哪个方向商是真分数就用哪个,都不行再换运算符;
  4. 答案在生成时就算好了,后面不用再求值一遍;判重用的是元组形式的规范化键,哈希比较是 O(1) 的,比字符串快不少。

改完之后一万道题不到半秒就出来了,比之前快了非常多。

性能分析

profile_chart

运行 python profile_gen.py(cProfile,-n 10000 -r 50):

         1780536 function calls (1692969 primitive calls) in 0.487 seconds

   Ordered by: cumulative time
   List reduced from 52 to 15 due to restriction <15>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.019    0.019    0.487    0.487 generator.py:69(generate_questions)
51336/10286    0.057    0.000    0.387    0.000 generator.py:34(_generate_node)
    70978    0.015    0.000    0.109    0.000 random.py:332(randint)
    30811    0.023    0.000    0.107    0.000 generator.py:25(_random_operand)
    70978    0.036    0.000    0.094    0.000 random.py:291(randrange)
    61622    0.041    0.000    0.079    0.000 fractions.py:186(__new__)
    30811    0.010    0.000    0.068    0.000 expr.py:23(__init__)
   138550    0.021    0.000    0.047    0.000 {built-in method builtins.isinstance}
    26167    0.010    0.000    0.046    0.000 fractions.py:613(forward)
    71095    0.028    0.000    0.044    0.000 random.py:242(_randbelow_with_getrandbits)
    20525    0.031    0.000    0.042    0.000 random.py:454(choices)
51336/10286    0.019    0.000    0.041    0.000 expr.py:110(canonical_key)
    23041    0.013    0.000    0.029    0.000 fractions.py:933(_richcmp)
    17399    0.004    0.000    0.027    0.000 fractions.py:955(__lt__)
    61045    0.017    0.000    0.027    0.000 <frozen abc>:117(__instancecheck__)

共生成 10000 道题目(-n 10000 -r 50),总耗时 0.49 秒

image

image

消耗最大的是 _generate_node 函数(累计 0.387 秒,占了差不多 80%),主要是 Fraction 对象的构造和随机数生成在吃时间。这块没什么好优化的,精确计算就得用 Fraction,随机生成题目也避不开 random,所以 0.49 秒生成一万道题已经够用了。

四、设计实现过程

模块划分

main.py               命令行入口:参数解析(argparse)、生成/判分两种模式分发
arithmetic/numfmt.py  数值层:Fraction ↔ "3/5" / "2'3/8" / "5" 字符串
arithmetic/expr.py    表达式层:Num/Op 节点、evaluate、check_constraints、
                      to_string(带最小括号序列化)、canonical_key(判重键)
arithmetic/parser.py  解析层:题目字符串 → 表达式树(递归下降,左结合)
arithmetic/generator.py  生成层:自底向上构造满足约束的树 + 集合判重
arithmetic/grader.py  判分层:读文件 → 逐题求值比对 → 输出 Grade.txt

依赖关系:main → generator/grader → parser/expr → numfmt,单向依赖,
核心库 arithmetic 不依赖任何 I/O,方便单元测试。

关键流程

生成一道题目的流程:
image

generate_questions(n, r)
  └─ 循环直到收集 n 道题:
       1. 随机选运算符个数 k ∈ [1,3]
       2. _generate_node(k):递归构造子树
            - 叶子:随机自然数(0~r-1)或真分数(分母<r)
            - 减法:若左值 < 右值 → 交换左右子树(保证非负)
            - 除法:两个方向的商都不是真分数 → 重试本节点
       3. canonical_key(树):+ / × 节点递归规范化后按子键排序
       4. 键不在已见集合中 → 收录;在集合中 → 重新生成
       5. 连续 10000 次重复 → 题目空间不足,报错提示增大 -r

判分流程:

grade(exercise, answer)
  ├─ 读 Exercises.txt / Answers.txt,剥离 "N. " 前缀
  ├─ 对每题:parse_expression → evaluate → 与 parse_number(答案) 比值
  └─ 写 Grade.txt:Correct/Wrong 两行 + 数量 + 编号

判重的关键设计

image

题目重复的定义是「能通过有限次交换 +/× 左右表达式互相变换」。我们为每棵
表达式树计算规范化键:递归规范化两个子树后,对可交换运算符把两个子键按
大小排序,得到嵌套元组。两棵树的键相等 ⇔ 两道题目在上述意义下重复。

注意这里不做结合律展开,因此:

  • 3+(2+1) 与 1+2+3(即 (1+2)+3)键相同 → 判为重复 ✓
  • 1+2+3 与 3+2+1(即 (3+2)+1)键不同 → 判为不重复 ✓

与题目说明中的示例完全一致。

序列化的括号规则

输出字符串时,左操作数优先级低于父节点才加括号;右操作数优先级不高于
父节点就加括号(如 1 + (2 + 3)、5 - (2 - 1))。这保证字符串解析回去后
与原表达式树结合顺序完全一致,判分时不会歧义。

五、代码说明

1. 除法约束(真分数商)— generator.py

# 除法:商必须是真分数,即 0 < 被除数 < 除数;不满足则交换方向重试
if right_value != 0 and 0 < left_value / right_value < 1:
    return Op("÷", left, right), left_value / right_value
if left_value != 0 and 0 < right_value / left_value < 1:
    return Op("÷", right, left), right_value / left_value

构造除法节点时两个方向都尝试,任一方向商为真分数即采用;构造的同时返回
精确值,后续节点直接复用,避免重复求值。

2. 减法非负 — generator.py

if op == "-":
    # 保证 e1 >= e2:不够减就交换左右子树
    if left_value < right_value:
        left, right = right, left
        left_value, right_value = right_value, left_value
    return Op("-", left, right), left_value - right_value

3. 规范化判重键 — expr.py

def canonical_key(node):
    if isinstance(node, Num):
        return ("num", node.value)
    left = canonical_key(node.left)
    right = canonical_key(node.right)
    if node.op in _COMMUTATIVE and right < left:
        left, right = right, left
    return (node.op, left, right)

只对 +/× 排序子键,对 -/÷ 保持顺序;元组可直接比较、可哈希,
放入 set 即完成判重。

4. 带分数格式化 — numfmt.py

def format_number(value):
    value = Fraction(value)
    if value.denominator == 1:
        return str(value.numerator)          # 自然数: "5"
    if value < 1:
        return f"{value.numerator}/{value.denominator}"   # 真分数: "3/5"
    whole = value.numerator // value.denominator
    remainder = value - whole
    return f"{whole}'{remainder.numerator}/{remainder.denominator}"  # "2'3/8"

5. 最小括号序列化 — expr.py

def _format(node, parent_prec, is_right_operand):
    ...
    if prec < parent_prec or (prec == parent_prec and is_right_operand):
        return f"({text})"
    return text

右操作数同级也加括号,保证 (a + b) + c 与 a + (b + c) 输出不同、
且都能无损解析回原树。

六、测试运行

image

共 34 个单元测试(python -m unittest discover -s . -p "test_*.py" -v),
代表性用例:

# 用例 输入 预期 结果
1 整数格式化 Fraction(42) "42" 通过
2 真分数/带分数格式化 Fraction(3,5)、Fraction(19,8) "3/5"、"2'3/8" 通过
3 数字解析往返 "10'11/12" 等 format(parse(x)) == x 通过
4 非法数字拒绝 "2'8/8"、"3/0" ValueError 通过
5 除数为 0 1 ÷ 0 ZeroDivisionError 通过
6 减法非负约束 3 - 5 check_constraints 为 False 通过
7 除法真分数约束 5 ÷ 3、4 ÷ 2、0 ÷ 3 均为 False 通过
8 交换律判重 3+(2+1) vs (1+2)+3、6×8 vs 8×6 键相同 通过
9 结合顺序不判重 1+2+3 vs 3+2+1 键不同 通过
10 生成约束全量校验 n=200, r=10 全部满足约束、无重复、运算符≤3 通过
11 操作数范围校验 n=300, r=5 所有叶子值 ∈ [0, 5) 通过
12 r=1 边界 n=5, r=1 正常生成 通过
13 题目空间不足 n=5000, r=1 明确报错 通过
14 一万道题目(需求 9) n=10000, r=50 成功,耗时 0.14 秒 通过
15 缺 -r 报错 -n 10 退出码 2 并打印帮助 通过
16 生成文件格式 -n 5 -r 10 i. xxx = / i. 答案 通过
17 判分正确性 5 题含 1 错 Correct: 4 (1, 2, 4, 5) / Wrong: 1 (3) 通过
18 答案缺失按错计 缺第 2 题答案 Wrong 含 2 通过
19 解析结合性 1 + 2 + 3 解析为 (1+2)+3 通过
20 序列化往返 5 个表达式 parse(to_string(t)) ≡ t 通过

(结果列以本地运行为准,运行命令见 README)

七、项目小结

做得比较好的地方

  1. 生成策略选对了:一开始写的是生成整棵树再校验,不行就丢掉,跑起来很慢。后来改成边构造边算值、减法靠交换、除法双向尝试的策略,效果好了很多。这个改动花了不少时间讨论,但很值得。
  2. 用 Fraction 做精确计算:全程没碰浮点数,判重和判分都不怕精度问题,省了很多 debug 的麻烦。
  3. 判重比较简洁:用一个嵌套元组做规范化键,放进 set 就完事了。对结合律的处理(不展开)刚好符合题目给的例子,测试也验证过了。
  4. 模块划分比较清楚:核心库 arithmetic 完全不做 I/O,写单元测试的时候很方便,不用 mock 文件读写。34 个测试用例跑起来很放心。

做得不够好的地方

  1. 性能没有深入优化:cProfile 显示大部分时间花在 Fraction 运算上,如果想进一步优化可以考虑用整数 + GCD 手动化简来替代 Fraction,但这次时间紧就没做了。
  2. 除法约束理解可能偏严:题目说「结果应是真分数」,我们理解成 0 < 商 < 1,也就是排除了商等于 1 的情况(比如 3 ÷ 3)。如果允许商为 1,可生成的题目会更多。
  3. 文档写得有点晚:博文和 PSP 表格基本是代码写完之后才集中补的,如果开发过程中随手记一下,实际耗时的数据会更准。

结对感受

这次结对整体上挺顺利的。两个人一起写代码的好处是遇到问题能马上讨论,比如判重到底要不要考虑结合律,一个人可能纠结很久,两个人聊几分钟就能定下来。分工方面,邓子豪主要写核心生成逻辑和表达式树那部分,黄琪主要负责测试用例和判分模块,中间有交叉但大方向比较明确。

不太好的地方是有时候会卡在很小的问题上讨论太久,比如括号到底怎么加才算"最小括号",花了比预期更多的时间。下次遇到这种情况应该先定一个够用的方案,后面再迭代。

给对方的话

邓子豪给黄琪:
写测试很认真,各种边界情况都考虑到了,比如 r=1 的情况、答案缺失的情况这些我容易忽略的角落都测了。对需求的理解也很仔细,有几次我差点理解错题目的意思被你拉回来了。建议的话,核心算法部分可以多参与一些讨论,有些想法其实挺好的但你说得比较少。

黄琪给邓子豪:
代码写得挺规范的,注释也详细,我看你的代码基本不需要额外解释就能看懂。debug 的时候很有耐心,有个约束检查的 bug 你盯了很久才找出来。就是有时候太追求代码的完美了,一个简单的功能也要想很久怎么写得最优雅,其实先跑通再优化也行。

commit记录
image

posted on 2026-09-21 13:16  清淮gg  阅读(11)  评论(0)    收藏  举报

导航