第三周结对项目
结对项目:小学四则运算题目自动生成器
| 姓名 | 学号 |
|---|---|
| 艾力夏提·艾海买提 | 3124004121 |
| 依木拉木·阿不都苏力 | 3124004111 |
项目地址:https://github.com/doinb1234/3124004121_pair_project
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694 |
| 这个作业的目标 | 结对完成实现一个自动生成小学四则运算题目的命令行程序 |
一、PSP 表格(预估,实现程序之前)
在动手写代码之前,我们先对各个阶段的工作量做了估计:
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 20 |
| · Estimate | · 估计这个任务需要多少时间 | 20 |
| Development | 开发 | 440 |
| · Analysis | · 需求分析(包括学习新技术) | 60 |
| · Design Spec | · 生成设计文档 | 30 |
| · Design Review | · 设计复审(和同事审核设计文档) | 30 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 |
| · Design | · 具体设计 | 60 |
| · Coding | · 具体编码 | 180 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 60 |
| Reporting | 报告 | 70 |
| · Test Report | · 测试报告 | 30 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 530 |
二、效能分析
性能改进一共花了约 2 小时:cProfile 定位热点约 30 分钟,重构生成算法约 1 小时,基准对比与图表整理约 30 分钟。
2.1 改进思路
问题:第一版"生成-校验-重试"浪费严重。 第一版生成器的做法是:随机生成整棵表达式树(树形、叶子、运算符全部随机),再递归校验"无负数、除法结果为真分数"两条硬约束,不合格就整棵树丢弃重试。用 cProfile 分析"生成 10000 道题(-r 10)"发现:
- 一共尝试了 25933 棵候选树,只有 10000 棵被接受,约 61% 的生成工作被直接丢弃;
- 校验函数
is_valid递归调用了 124562 次,纯属事后补救的开销。
改进:自底向上选运算符,构造即合法。 既然约束是局部的(每个减法节点要求左值 ≥ 右值、每个除法节点要求 0 < 左值 < 右值),那就在构造每个节点时当场保证它合法:先生成左右子树并拿到它们的值 lv、rv,再从合法运算符集合里随机选一个——
+、×任何情况下都合法;lv ≥ rv时才把-放进候选(保证结果非负);0 < lv < rv时才把/放进候选(保证结果是真分数,且天然不会除零)。
这样返回的每棵树都满足全部约束,is_valid 校验函数被整个删除,也不再有任何"生成了又丢掉"的浪费。
效果(-n 10000 -r 10,墙钟取 3 次平均):
| 指标 | 优化前 V1 | 优化后 V2 | 变化 |
|---|---|---|---|
| 总耗时(墙钟) | 0.338 s | 0.237 s | 快约 30% |
| 函数调用总数 | 4 102 072 | 2 273 510 | 减少 45% |
| gen_tree 调用次数 | 129 711 | 65 302 | 减半 |
| is_valid 调用次数 | 124 562 | 0 | 整个删除 |
一万道题目仅需约 0.24 秒,远满足需求。
2.2 性能分析图
优化后各指标相对优化前的占比(V1 = 100% 基线,越低越好):
![优化前后指标对比]
2.3 消耗最大的函数
优化后 cProfile Top 10 函数累计耗时:
![cProfile Top 10]
消耗最大的函数是 gen_tree(累计 0.800s,约占 84%)。这是符合预期的:它是出题的核心递归函数,包含随机数生成、Fraction 构造与求值;题目生成本身就是这个程序的主要工作,其余部分(去重、写盘)只占零头。
三、设计实现过程
3.1 代码如何组织
项目只有 7 个源文件 + 测试 + 基准脚本,按职责分层:
| 文件 | 职责 | 关键函数/类 |
|---|---|---|
main.py |
命令行入口:参数解析、生成/批改两种模式分发、文件输出 | main / run_generate / run_grade |
gen.py |
题目生成器:随机树 + 自底向上选合法运算符 + canonical 去重 | ProblemGenerator(gen_leaf / gen_tree / generate) |
expr.py |
表达式树 ExprNode:构造时求值并缓存、最小括号输出、canonical 去重形式 |
ExprNode |
parse.py |
表达式解析:递归下降法,供批改功能还原题目 | parse_expression / parse_number |
fmt.py |
数字格式化:整数 / 真分数 / 带分数三种输出形态 | format_number |
grade.py |
批改:逐题判定对错并写 Grade.txt | grade |
tests.py |
单元测试(41 个用例) | 8 个测试类 |
全项目共 2 个类(ExprNode、ProblemGenerator,另有 1 个异常类 GenerationError),所有数值运算都基于 Python 标准库的 fractions.Fraction,全程精确分数运算,不使用浮点数,因此 1/6 + 1/8 = 7/24 这类结果零误差。
3.2 模块关系
依赖方向自上而下,expr.py 是整个程序的核心数据结构,其余模块都围绕它:
![模块结构]
3.3 关键流程图
(1)generate():生成 + canonical 去重 + 撞车熔断
![generate 主流程]
去重的关键是 canonical():+ 和 × 满足交换律,把左右子树的规范形式排序后再拼接,于是"交换子表达式后等价"的两道题得到完全相同的字符串。例如 3 + 5 与 5 + 3 的规范形式都是 (+3,5),3+(2+1) 与 1+2+3 都是 (+(+1,2),3);而 1+2+3 与 3+2+1 分别是 (+(+1,2),3) 和 (+(+2,3),1),判定为不重复,符合题目要求。- 和 ÷ 不可交换,保持原顺序。当参数太小、题目空间被耗尽时(如 -r 3 -n 10000),连续撞车达到熔断阈值就报错退出,而不是死循环。
(2)gen_tree():自底向上选运算符,构造即合法
![gen_tree 流程]
(3)递归下降解析器:三层函数对应三级优先级
![解析器结构]
批改功能需要把题目文本还原成表达式树。解析器采用递归下降法:parse_expression 处理 + -(最低优先级)、parse_term 处理 × ÷、parse_factor 处理括号和数字,同层运算符在 while 循环里左结合,天然满足"1+2+3 等价于 (1+2)+3"。
四、代码说明
4.1 fmt.format_number:分数输出三种形态
def format_number(value: Fraction) -> str:
n, d = value.numerator, value.denominator
if d == 1:
return str(n) # 整数:8/4 -> "2"
if n < d:
return f"{n}/{d}" # 真分数:3/5 -> "3/5"
whole, rem = divmod(n, d)
return f"{whole}'{rem}/{d}" # 带分数:19/8 -> "2'3/8"
思路:程序内部统一用 Fraction 存储,只在输出时才转换格式。Fraction 自动约分,所以分母为 1 等价于整数,分子小于分母等价于真分数,剩下的必然是带分数——三种情况互斥且完备,比逐一判断更简洁。
4.2 ExprNode:构造即求值 + canonical 去重形式
class ExprNode:
__slots__ = ("op", "value", "left", "right")
def __init__(self, op=None, left=None, right=None, value=None):
self.op = op
self.left = left
self.right = right
self.value = _apply(op, left.value, right.value) if op is not None else value
def canonical(self) -> str:
if self.is_leaf():
return str(self.value)
left = self.left.canonical()
right = self.right.canonical()
if self.op in ("+", "*") and left > right:
left, right = right, left # 交换律:排序后两棵等价子树规范形式一致
return f"({self.op}{left},{right})"
思路:每个节点在构造函数里就完成求值并把结果缓存进 value,之后任何地方取值都是 O(1),不会反复递归计算。canonical() 只对 +、× 节点排序左右子树(字符串排序即可,因为约分后相等的分数字符串必相等),递归自底向上,保证"交换等价"的题目规范形式完全相同。
4.3 gen_tree:自底向上选运算符(核心生成逻辑)
def gen_tree(self, ops: int) -> ExprNode:
if ops == 0:
return self.gen_leaf() # 叶子:自然数 1~r-1 或真分数 a/b
left_ops = random.randrange(ops)
left = self.gen_tree(left_ops)
right = self.gen_tree(ops - 1 - left_ops)
choices = ["+", "*"]
if left.value >= right.value:
choices.append("-") # 减法:保证结果非负
if 0 < left.value < right.value:
choices.append("/") # 除法:保证结果是真分数且不除零
return ExprNode(random.choice(choices), left, right)
思路:树形和叶子仍然是随机的,但运算符不是盲选——它是在"当前左右值允许的合法集合"里随机选。这样每一棵树构造出来就必然满足"无负数、除法结果为真分数"两条硬约束,从根源上消除了第一版的拒绝采样浪费(见效能分析)。
4.4 parse_expression:递归下降解析
def parse_expr(): # 加减层,优先级最低
node = parse_term()
while peek() in ("+", "-"):
node = ExprNode(advance(), node, parse_term())
return node
def parse_term(): # 乘除层
node = parse_factor()
while peek() in ("*", "/"):
node = ExprNode(advance(), node, parse_factor())
return node
def parse_factor(): # 括号或数字
if peek() == "(":
advance()
node = parse_expr()
if advance() != ")":
raise ValueError("缺少右括号")
return node
token = advance()
return ExprNode(value=parse_number(token))
思路:三层函数对应三级优先级,上层调用下层;括号由最底层处理、递归回最顶层,形成闭环。数字解析 parse_number 同时支持 3、3/5、2'3/8 三种写法。
4.5 grade:批改
for index, text in enumerate(exercises):
ok = False
if index < len(answers):
try:
expected = parse_expression(text).value
ok = parse_number(answers[index]) == expected
except (ValueError, ZeroDivisionError):
ok = False # 题目或答案格式非法 -> 判错
(correct if ok else wrong).append(index + 1)
思路:每道题用解析器还原后重新算出标准答案,与用户答案按 Fraction 精确比较——所以 2/4 与 1/2 判为相同。答案缺失或格式非法(如 abc)时对应题目判错而不是崩溃。
五、测试运行
5.1 测试用例
测试分两层:基础功能测试(格式化、解析、求值、括号、去重)+ 随机与边界测试(批量生成后逐树检查约束、闭环一致性、批改)。固定随机种子保证可复现。共 41 个用例全部通过:
| 编号 | 测试内容 | 测试输入 | 预期结果 | 结果 |
|---|---|---|---|---|
| T01 | 分数格式化 | 3/5、8/4、19/8 | "3/5"、"2"、"2'3/8" | 通过 |
| T02 | 数字解析 | "3"、"3/5"、"2'3/8" | 正确还原为 Fraction | 通过 |
| T03 | 基本计算 | 3+5、1/6+1/8 | 8、7/24 | 通过 |
| T04 | 运算符优先级 | 3+5×2、(3+5)×2 | 13、16 | 通过 |
| T05 | 左结合规则 | 8-3-1、8÷4÷2 | 4、1 | 通过 |
| T06 | 最小括号 | 1+2×3、5-(3-2)、8÷(4÷2) | 括号不多不少 | 通过 |
| T07 | 交换律去重 | 3+5 与 5+3;6×8 与 8×6 | 判定重复 | 通过 |
| T08 | 结合+交换去重 | 3+(2+1) 与 1+2+3;1+2+3 与 3+2+1 | 前者重复,后者不重复 | 通过 |
| T09 | 数值范围 | 生成 200 道题(r=10) | 所有叶子数值 < 10 | 通过 |
| T10 | 硬约束 | 随机生成 2000 道题 | 无负数、除法结果为真分数、运算符 ≤ 3 | 通过 |
| T11 | 题目去重 | 生成 500 道题 | canonical 结果无重复 | 通过 |
| T12 | 一万道题 | -n 10000 -r 10 | 生成成功且全部不重复(约 0.24s) | 通过 |
| T13 | 边界 r=1 | -r 1 | 正常报错提示 | 通过 |
| T14 | 题目空间耗尽 | -r 3 -n 10000 | 正常报错提示,不死循环 | 通过 |
| T15 | 生成-解析闭环 | 生成 100 道题写盘再解析重算 | 与 Answers.txt 完全一致 | 通过 |
| T16 | 批改全对 | 3 道题全对 | Correct: 3 (1, 2, 3) | 通过 |
| T17 | 批改部分错 | 5 道题 3 对 2 错 | Correct: 3 (1, 3, 5),Wrong: 2 (2, 4) | 通过 |
| T18 | 等价答案 | 答案 2/4 对 1/2 | 判定正确 | 通过 |
| T19 | 缺失/非法答案 | 缺一行答案、答案写 abc | 判错且不崩溃 | 通过 |
5.2 运行演示
生成 10 道题(python main.py -n 10 -r 10)后,Exercises.txt 内容:
4 × 3/4 ÷ 5 =
1/2 ÷ 9 =
4/5 ÷ 4 × 3 =
1/2 ÷ 8 + 2/3 =
2/3 × 3 =
7 - 5 =
8 - 1/6 =
1/4 + 3/4 =
2 + 1/2 =
2/7 × 1 =
Answers.txt 内容(我们故意把第 2、5 题答案改成 999 和 0 演示批改):
3/5
999
3/5
35/48
0
2
7'5/6
1
2'1/2
2/7
批改(python main.py -e Exercises.txt -a Answers.txt)后 Grade.txt:
Correct: 8 (1, 3, 4, 6, 7, 8, 9, 10)
Wrong: 2 (2, 5)
单元测试运行输出(节选):
test_add_commutative (CanonicalTests) ... ok
test_chain_different_order (CanonicalTests) ... ok
test_ten_thousand (GeneratorTests) ... ok
test_generate_write_parse_reevaluate (RoundTripTests) ... ok
test_all_correct (GradeTests) ... ok
test_partially_wrong (GradeTests) ... ok
----------------------------------------------------------------------
Ran 41 tests in 0.563s
OK
5.3 为什么能确定程序是正确的
- 精确运算:全部使用
Fraction,不存在浮点误差,1/6 + 1/8 = 7/24这类结果精确到分子分母。 - 约束逐树验证:随机生成 2000 道题,递归检查每一棵树的每一个节点——减法左值 ≥ 右值、除法结果是真分数、所有中间结果非负、运算符数 ≤ 3、叶子数值在范围内,全部通过。
- 闭环一致性:把程序生成的 100 道题写盘后,用另一条代码路径(解析器)重新解析并重算,结果与
Answers.txt完全一致,说明"生成 → 输出 → 解析 → 求值"整条链路自洽。 - 去重完备性:500 道和 10000 道题各自的 canonical 形式集合大小与题目数相同,无重复;交换律/结合律等价题目(T07、T08)判定符合题目示例。
- 边界与异常:r=1、题目空间耗尽、答案缺失/非法等边界情况都有明确行为,不死循环、不崩溃。
六、PSP 表格(实际)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 25 |
| Development | 开发 | 440 | 405 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 70 |
| · Design Spec | · 生成设计文档 | 30 | 40 |
| · Design Review | · 设计复审(和同事审核设计文档) | 30 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 | 15 |
| · Design | · 具体设计 | 60 | 65 |
| · Coding | · 具体编码 | 180 | 150 |
| · Code Review | · 代码复审 | 30 | 25 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 60 | 50 |
| Reporting | 报告 | 70 | 90 |
| · Test Report | · 测试报告 | 30 | 35 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 45 |
| 合计 | 530 | 520 |
七、项目小结
艾力夏提·艾海买提(3124004121)的心得:
这次结对项目让我完整走了一遍"分析—设计—实现—测试—优化"的流程,收获最大的是两个点。第一是数据结构的选择:把每道题目建模成表达式树之后,括号、优先级、去重这些原本分散的难题全都有了统一的落点——求值在构造时就缓存好,去重只需要对交换律节点排序子树。第二是效能分析带来的真实收益:第一版"生成-校验-重试"有 61% 的树生成后就被丢弃,我在 cProfile 里看到 is_valid 十几万次调用时才意识到"先造后验"是错误的方向,改成自底向上选运算符后代码反而更短了。教训是:性能问题往往不是写得太慢,而是设计里藏着浪费,早一点跑 profile 比盲目优化更有效。另外一开始我俩各自写文件读写和批改时没有约定好题目行结尾带不带空格、答案怎么处理非法行,合并时产生了一次小返工,提醒我们结对前先约定接口细节。
依木拉木·阿不都苏力(3124004111)的心得:
作为结对中的驾驶员和领航员,这次我主要负责需求逐条核对、测试用例设计和批改模块。最大的体会是需求里的每个字都值得抠:比如"除法结果应是真分数"这条,我们一开始实现成"结果小于等于 1",后来对照题意才改成严格 0 < e1/e2 < 1;又比如题目不重复的定义里"有限次交换 + 和 ×",光靠想很难覆盖 3+(2+1) 与 1+2+3 这种嵌套情况,最后是两个人一起在白板上摆表达式树才想清楚 canonical 的做法。测试上我坚持做了"生成—解析"闭环:用另一条独立代码路径重算答案比对,这比单独测每个函数更能发现链路问题,事实证明它确实抓出过一个括号输出 bug。建议是:结对时把"谁写谁看"的角色约定清楚,评审时不放过任何一个魔法数字。
结对感受与互评:
- 结对感受:两个人轮换"驾驶员/领航员"角色,编码时有人即时评审、卡壳时有人换思路,效率明显高于一个人闷头写;通过 Git 的分支与提交记录,我们能做到"谁写的代码出问题谁回头看",责任清晰。
- 依木拉木给艾力夏提的建议:生成器第一版性能问题在代码复审时就应该被问出来("这棵树校验失败的概率是多少?"),下次设计评审可以更狠一点;闪光点是表达式树和 canonical 的设计非常干净,几乎不需要额外代码就同时解决了括号和去重。
- 艾力夏提给依木拉木的建议:测试设计很全面,但有些用例的数据依赖手算结果,最好让预期值也由 Fraction 算出来而不是抄手算数字,减少人为笔误(本次就出现过一次测试数据本身写错的情况);闪光点是对边界情况(r=1、答案缺失、非法答案)的敏感度,让程序在老师验收时不会因为异常输入掉链子。

浙公网安备 33010602011771号