第三周结对项目
结对项目:小学四则运算自动生成程序
邓子豪 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 |
注:预估列为两人合计;实际耗时为两人结对工作的总耗时。
二、需求分析
程序需要满足的核心需求:
-n控制题目数量,-r控制数值范围(必须给定,否则报错并输出帮助信息);- 计算过程不产生负数:
e1 - e2必须满足e1 >= e2; - 除法子表达式
e1 ÷ e2的商必须是真分数(0 < e1/e2 < 1); - 每道题目运算符不超过 3 个;
- 一次运行内题目不重复(判重标准:能通过有限次交换
+/×左右表达式互相变换的题目视为重复); - 题目写入
Exercises.txt,答案写入Answers.txt,真分数用3/5、带分数用2'3/8格式; - 支持一次生成一万道题目;
-e <exercisefile> -a <answerfile>判分,结果输出到Grade.txt。
Exercises.txt

Answers.txt

Grade.txt

三、效能分析与改进
改进思路
一开始我们的做法比较粗暴:随机生成一整棵表达式树,然后检查约束,不满足就整棵丢掉重来。写完一跑发现慢得离谱——除法要求商是真分数,但随机出来的除法大部分商都大于 1,丢掉重来的概率太高了,生成几千道题的时候就能明显感觉到卡顿。
后来花了大概两个小时想改进方案,最后决定改成边构造边算值:
- 每个节点构造的时候就把子表达式的值算出来(用
fractions.Fraction,不怕精度问题); - 减法不用丢掉重来,左值比右值小的话直接交换左右子树就行,交换完就是一道合法的减法题;
- 除法两个方向都试一下,哪个方向商是真分数就用哪个,都不行再换运算符;
- 答案在生成时就算好了,后面不用再求值一遍;判重用的是元组形式的规范化键,哈希比较是 O(1) 的,比字符串快不少。
改完之后一万道题不到半秒就出来了,比之前快了非常多。
性能分析

运行 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 秒


消耗最大的是 _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,方便单元测试。
关键流程
生成一道题目的流程:

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 两行 + 数量 + 编号
判重的关键设计

题目重复的定义是「能通过有限次交换 +/× 左右表达式互相变换」。我们为每棵
表达式树计算规范化键:递归规范化两个子树后,对可交换运算符把两个子键按
大小排序,得到嵌套元组。两棵树的键相等 ⇔ 两道题目在上述意义下重复。
注意这里不做结合律展开,因此:
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) 输出不同、
且都能无损解析回原树。
六、测试运行

共 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)
七、项目小结
做得比较好的地方
- 生成策略选对了:一开始写的是生成整棵树再校验,不行就丢掉,跑起来很慢。后来改成边构造边算值、减法靠交换、除法双向尝试的策略,效果好了很多。这个改动花了不少时间讨论,但很值得。
- 用 Fraction 做精确计算:全程没碰浮点数,判重和判分都不怕精度问题,省了很多 debug 的麻烦。
- 判重比较简洁:用一个嵌套元组做规范化键,放进 set 就完事了。对结合律的处理(不展开)刚好符合题目给的例子,测试也验证过了。
- 模块划分比较清楚:核心库
arithmetic完全不做 I/O,写单元测试的时候很方便,不用 mock 文件读写。34 个测试用例跑起来很放心。
做得不够好的地方
- 性能没有深入优化:cProfile 显示大部分时间花在 Fraction 运算上,如果想进一步优化可以考虑用整数 + GCD 手动化简来替代 Fraction,但这次时间紧就没做了。
- 除法约束理解可能偏严:题目说「结果应是真分数」,我们理解成
0 < 商 < 1,也就是排除了商等于 1 的情况(比如3 ÷ 3)。如果允许商为 1,可生成的题目会更多。 - 文档写得有点晚:博文和 PSP 表格基本是代码写完之后才集中补的,如果开发过程中随手记一下,实际耗时的数据会更准。
结对感受
这次结对整体上挺顺利的。两个人一起写代码的好处是遇到问题能马上讨论,比如判重到底要不要考虑结合律,一个人可能纠结很久,两个人聊几分钟就能定下来。分工方面,邓子豪主要写核心生成逻辑和表达式树那部分,黄琪主要负责测试用例和判分模块,中间有交叉但大方向比较明确。
不太好的地方是有时候会卡在很小的问题上讨论太久,比如括号到底怎么加才算"最小括号",花了比预期更多的时间。下次遇到这种情况应该先定一个够用的方案,后面再迭代。
给对方的话
邓子豪给黄琪:
写测试很认真,各种边界情况都考虑到了,比如 r=1 的情况、答案缺失的情况这些我容易忽略的角落都测了。对需求的理解也很仔细,有几次我差点理解错题目的意思被你拉回来了。建议的话,核心算法部分可以多参与一些讨论,有些想法其实挺好的但你说得比较少。
黄琪给邓子豪:
代码写得挺规范的,注释也详细,我看你的代码基本不需要额外解释就能看懂。debug 的时候很有耐心,有个约束检查的 bug 你盯了很久才找出来。就是有时候太追求代码的完美了,一个简单的功能也要想很久怎么写得最优雅,其实先跑通再优化也行。
commit记录

浙公网安备 33010602011771号