结对项目
结对项目:自动生成小学四则运算题目
本作业属于:软件工程(计科 24 级 78 班)
作业要求:https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703
作业目标:结对完成一个命令行四则运算题目生成与判分程序,实践"需求 → 设计 → 编码 → 测试 → 性能 → 交付"全流程。
| 项目 | 内容 |
|---|---|
| 队员 1 | 张志远 学号:3124004453 |
| 队员 2 | 谢嘉鸿 学号:3124004444 |
| GitHub 项目地址 | https://github.com/zzy685/ZZY685 |
| 实现语言 | Python 3 |
| 入口 | main.py |
一、PSP 表格(预估)
动手写代码之前填写,单位:分钟。
| PSP2.1 | Personal Software Process Stages | 预估耗时 |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 20 |
| Development | 开发 | |
| · Analysis | · 需求分析(含 Fraction、递归下降解析) | 60 |
| · Design Spec | · 生成设计文档(模块划分、表达式树、去重规范形) | 40 |
| · Design Review | · 设计复审(结对讨论约束是否可被违反) | 20 |
| · Coding Standard | · 代码规范(PEP8、函数单一职责) | 15 |
| · Design | · 具体设计(递归生成器、括号打印规则、判卷解析器) | 50 |
| · Coding | · 具体编码 | 120 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(含 1 万题压测、判分对错例) | 60 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 25 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem | · 事后总结与过程改进计划 | 20 |
| 合计 | 470 |
二、设计与实现过程
2.1 需求拆解:四条硬约束
把题目原文翻译成可执行的约束:
- CLI:
-n给题量、-r给数值范围(必须给,否则报错并打印帮助);判分模式-e 题目文件 -a 答案文件。 - 合法表达式:自然数 / 真分数 / 带分数;
+ − × ÷;每题运算符 ≤ 3。 - 不能出现负数:每个
e1 − e2子式必须e1 ≥ e2。 - 除法必须是真分数:每个
e1 ÷ e2必须0 < e1 < e2(商落在 (0,1))。 - 去重:两道题不能通过有限次交换
+、×的左右操作数变成同一题。 - 输出:题目 →
Exercises.txt,答案 →Answers.txt;判分 →Grade.txt。
2.2 模块划分
main.py
├── format_number / parse_number Fraction ↔ 题目字符串 (7 / 3/5 / 2'3/8)
├── Expr 表达式树 (叶子=数字, 内部=左 op 右)
├── evaluate 递归求值 (fractions.Fraction 精确运算)
├── canon 规范形 (去重键)
├── to_string 按优先级/结合性打印表达式 (带括号)
├── ProblemGenerator 递归随机建树 + 约束拒绝 + 规范形去重
├── _tokenize / _parse_expr 判卷模式的递归下降表达式解析器
├── grade 读题读答案, 产出 Grade.txt
└── main (argparse) 命令行分发
2.3 关键设计点
(1) 用 fractions.Fraction 做精确运算。
整道题全程不碰浮点数,加减乘除都在有理数域里做,从根上杜绝 0.1+0.2=0.3000000004 这类误差,答案 7/24 这种结果天然就是精确分数。
(2) 表达式树 + 递归拒绝采样。
随机生成运算符个数(1~3),递归地把运算符个数分配到左右子树。生成完一个子式立刻求值,若违反"减法不为负""除法为真分数"就抛 _Retry 重新生成。拒绝采样比"先拼再修"简单得多,且天然保证所有约束同时成立。
(3) 去重靠规范形而不是字符串比较。
题目说"23+45 和 45+23 是重复的"。我们把表达式树递归规范化:
- 遇到
+、×,把两个子树的规范结果排序; - 遇到
-、÷,保持左右顺序。
这样 23+45 与 45+23 生成同一个 tuple,直接丢进 set() 判重,O(1) 哈希。题目里"3+(2+1) 和 1+2+3 重复,但和 3+2+1 不重复"的例子也被这套规则正确区分(见测试用例 8、9)。
(4) 括号打印规则。
- 孩子优先级 低于 父亲 → 必须加括号(如
(1/2+1/3)×4); - 优先级相同且孩子在 右边 → 加括号(如
3+(2+1)、8-(3-1)); - 优先级相同且孩子在 左边 → 不加括号,左结合链拍平(如
1+2+3)。
这正好匹配题目给的例子。
(5) 判卷解析器。
判分模式要把别人写的 Exercises.txt 重新解析成数值。我们写了一个两层递归下降解析器(加减层 → 乘除层 → 括号/原子层),同时兼容 -/−、*/×、//÷、’/' 等输入变体,并用 utf-8-sig 读取以兼容 BOM。
2.4 函数调用关系流程图
下图展示了从入口 main() 出发,两种运行模式(生成模式 / 判分模式)下各函数之间的调用关系:

三、效能分析
3.1 测试环境与数据
- 规模:
-n 10000 -r 25(题目要求"支持一万道")。 - 不带 profiler 的端到端耗时:0.29 秒;带
cProfile插桩:0.63 秒。
3.2 cProfile 结果(消耗最大的函数)
| 排名 | 函数 | 累计时间 | 占比 | 说明 |
|---|---|---|---|---|
| 1 | _gen_tree(递归建树) |
0.439 s | ~70% | 递归分配运算符个数、拒绝采样 |
| 2 | _gen_leaf(随机数字) |
0.180 s | ~29% | 调 randint 造自然数/分数/带分数 |
| 3 | evaluate(求值) |
0.093 s | ~15%(重叠) | Fraction 精确求值,约束判断依赖它 |
消耗最大的函数是 ProblemGenerator._gen_tree——它是递归热点,每道题要递归展开、分配运算符个数并做拒绝采样。
3.3 改进思路
- 去重键用 tuple 而不是字符串:一开始想把表达式转成字符串再
set,但字符串比较是 O(长度) 且要反复格式化;改成 tuple 规范形后,去重是纯哈希,1 万题去重 0 额外开销。 - Fraction 一次构造、复用求值结果:
_gen_tree生成子树后立刻evaluate缓存到约束判断,避免后面再算一遍。 - 拒绝采样上限 200 次:
-r很小时(比如 r=2)某些算子组合几乎必然违反约束,设上限防止死循环,直接报错提示"增大 -r"。
实测:1 万道题 零重复(wc -l 与 sort -u 一致),端到端 0.29 s,距离题目隐含的时间余量非常充裕。
截图:
四、关键代码说明
(1) Fraction 与题目字符串互转:
def format_number(f: Fraction) -> str:
if f.denominator == 1:
return str(f.numerator) # 自然数
whole = f.numerator // f.denominator
rem = f.numerator % f.denominator
if whole == 0:
return f"{f.numerator}/{f.denominator}" # 真分数 3/5
return f"{whole}'{rem}/{f.denominator}" # 带分数 2'3/8
(2) 递归建树 + 约束拒绝:
def _gen_tree(self, ops: int) -> Expr:
if ops == 0:
return Expr.leaf(self._gen_leaf())
op = self.rand.choice(OPS)
left_ops = self.rand.randint(0, ops - 1)
right_ops = ops - 1 - left_ops
left = self._gen_tree(left_ops)
right = self._gen_tree(right_ops)
lv, rv = evaluate(left), evaluate(right)
if op == "-" and lv < rv:
raise _Retry() # 中间结果不能为负
if op == "÷" and (rv == 0 or lv <= 0 or lv >= rv):
raise _Retry() # 商必须是真分数
return Expr(left=left, op=op, right=right)
(3) 去重规范形:
def canon(node: Expr):
if node.is_leaf:
return ("n", node.value.numerator, node.value.denominator)
lc, rc = canon(node.left), canon(node.right)
if node.op in ("+", "×"): # 可交换, 排序
return (node.op, tuple(sorted((lc, rc))))
return (node.op, lc, rc) # 不可交换, 保序
(4) 命令行分发:
if args.e or args.a: # 判分模式
text = grade(args.e, args.a)
open("Grade.txt", "w", encoding="utf-8").write(text)
elif args.n and args.r: # 生成模式
for p, a in ProblemGenerator(args.r).generate(args.n):
... # 写 Exercises.txt / Answers.txt
else: # 缺参数 -> 报错+帮助
parser.print_help(sys.stderr); return 1
五、测试运行(≥10 个测试用例)
| # | 命令 / 输入 | 预期 | 实测 |
|---|---|---|---|
| 1 | python main.py -n 10 -r 10 |
生成 10 道题,写出 Exercises.txt / Answers.txt | ✅ |
| 2 | 缺 -r:python main.py -n 10 |
报错并打印帮助 | ✅ |
| 3 | -n 10000 -r 25 |
1 万题,零重复,<1s | ✅ 0.29s,10000/10000 唯一 |
| 4 | 答案自检:-e Exercises.txt -a Answers.txt |
Correct: 100% | ✅ Correct: 15 |
| 5 | 故意改错 3 个答案 | 对应题号进 Wrong | ✅ Wrong: 3 (2,4,6) |
| 6 | 减法约束:扫描所有 a - b 子式 |
a ≥ b |
✅ 生成器 raise _Retry 保证 |
| 7 | 除法约束:扫描所有 a ÷ b 子式 |
0 < a < b,商为真分数 |
✅ 例 3÷19=3/19、10÷(...) = 36/121 |
| 8 | 去重:23+45 vs 45+23 |
判为重复,只出一道 | ✅ canon 排序后 tuple 相同 |
| 9 | 非去重:1+2+3 vs 3+2+1 |
判为不同题 | ✅ canon 结构不同 |
| 10 | 真分数格式:1/6 + 1/8 |
答案 7/24 |
✅ Fraction 精确约分 |
| 11 | 带分数格式:2'3/8 |
输入输出均按 whole'num/den |
✅ format/parse 对称 |
| 12 | 括号:(2/3+18'15/19)×2-... |
结果精确,括号不丢 | ✅ 答案 17'74/95 |
| 13 | 极小范围 r=3 |
不死循环,仍能出题 | ✅ 5 道成功,上限 200 次重试保护 |
| 14 | BOM 文件输入 | 不把 BOM 当字符判错 | ✅ utf-8-sig 读取 |
为什么能确定程序是对的:
- 所有数值运算都走
Fraction,答案不会有浮点误差; - 约束在"生成时"就被拒绝采样钉死,而不是事后检查;
- 判分解析器和生成器用同一套 Fraction 语义,生成的题目自判 100% 正确;再人工构造错例,错例全部进入 Wrong、其余保持 Correct,双向验证了解析与比较的正确性。
5.1 测试运行截图
(1)生成题目与答案(对应用例 1、10~12):

(2)用正确答案判分,全部 Correct(对应用例 4):

(3)故意改错 3 个答案,错误题号被精确标出(对应用例 5):

(4)缺少必需的 -r 参数,报错并打印帮助(对应用例 2):

六、PSP 表格(实际耗时)
| PSP2.1 | 阶段 | 预估 | 实际 |
|---|---|---|---|
| Planning · Estimate | 估计任务 | 20 | 25 |
| Development · Analysis | 需求分析 | 60 | 70 |
| · Design Spec | 设计文档 | 40 | 45 |
| · Design Review | 设计复审 | 20 | 25 |
| · Coding Standard | 代码规范 | 15 | 10 |
| · Design | 具体设计 | 50 | 55 |
| · Coding | 具体编码 | 120 | 130 |
| · Code Review | 代码复审 | 30 | 35 |
| · Test | 测试 | 60 | 70 |
| Reporting · Test Report | 测试报告 | 25 | 25 |
| · Size Measurement | 计算工作量 | 10 | 10 |
| · Postmortem | 事后总结 | 20 | 25 |
| 合计 | 470 | 525 |
实际比预估多约 11%,主要花在:判卷解析器对 ×/÷/− 全角符号的兼容,以及 BOM 文件读取这两个一开始没考虑到的边角上。
七、项目小结与结对感受
做对了的:
- 一开始就把"精确运算"定为硬指标,选了
Fraction,后面没有为浮点误差返工; - 去重用规范形 tuple 而不是字符串,既快又严格,题目里那组"重复/不重复"的例子被一次性正确覆盖;
- 生成器用拒绝采样,约束加起来不复杂时(4 条)非常好维护。
踩过的坑:
- 除法"结果为真分数"一开始只写了
lv < rv,漏掉了lv == 0,会出现0 ÷ x = 0;后来收紧成0 < lv < rv。 - PowerShell 下写测试文件带 BOM,导致第一道题被误判,才意识到判卷读取要
utf-8-sig。
结对感受:
张志远(主要负责编码):
这次结对项目中,我主要承担整体架构设计和代码编写。从一开始确定用 fractions.Fraction 做全程精确运算,到设计 Expr 表达式树、递归拒绝采样的题目生成器、基于规范形 tuple 的去重、按优先级和结合性打印括号,再到判分模式的两层递归下降解析器,主体代码基本由我完成。一个人写代码很容易陷入"我觉得没问题"的盲区,比如除法约束我最初只写了 lv < rv,想当然地认为被除数不会是 0;谢嘉鸿在旁边一直追问"如果被除数是 0 会怎样""-r 很小的时候会不会卡死""别人用全角符号、带 BOM 的文件怎么办",正是这些问题逼着我把边界一条条补齐——把除法约束收紧成 0 < lv < rv、给拒绝采样加上 200 次重试上限、兼容 − × ÷ 和带分数的 ’、用 utf-8-sig 读取 BOM。我的闪光点是在拒绝采样里加了重试上限,避免了小范围下的死循环。这次让我体会最深的是:结对时有人持续提问和质疑,代码的健壮性会比单干高一个档次;下次我会在动手前主动把可能的边角情况列成清单,而不是等别人来问,也会把 PSP 估时拆得更细,让计划更贴近实际。
谢嘉鸿(主要负责提问、建议、测试与寻找漏洞):
我这次没有写太多代码,主要扮演"提问者"和"测试者"的角色——在张志远写代码的过程中,不断从需求和使用者的角度提问题、设计测试用例、专门去找漏洞。我会盯着题目原文一条条核对:除法要求结果是真分数,那 0 ÷ x 算不算?这一问就发现他漏掉了被除数为 0 的情况,会生成 0 ÷ 5 = 0 这种非法题;在 PowerShell 里造测试文件时,我发现第一道题总被误判,排查后是文件带了 BOM,于是建议读取改用 utf-8-sig;我还建议把 −、×、÷、带分数的 ’ 等各种输入符号都兼容掉,并在 r=2、r=3 的小范围下反复压测,推动他给拒绝采样加上重试上限。后面的一万题压力测试、故意改错答案验证 Wrong 题号,也都是我和他一起设计和确认的。我的闪光点是把输入符号的兼容做得比较全,让判卷器很"皮实"。整个过程让我感到,找漏洞和写代码一样需要系统性——专门盯着约束和边界去构造反例,比随便跑两遍有效得多;建议下次我们在编码前就先一起把测试用例和异常输入列出来,用测试来驱动设计,配合会更顺畅。
彼此的闪光点与建议:
- 张志远看谢嘉鸿:闪光点是他提问的角度很"刁钻",专挑边界和异常下手,
0÷x、BOM、全角符号都是他先发现的,测试设计很系统;建议是下次把问题清单在我开工前就整理好,并试着自己动手改一小部分代码,理解会更深。 - 谢嘉鸿看张志远:闪光点是架构清晰、代码规范,Fraction 精确运算和规范形去重基本一次到位、返工很少,发现问题也改得快;建议是别太相信"第一版就对",写完先自己按边界清单过一遍、多加点断言,函数也可以再拆小些,方便单独测试。

浙公网安备 33010602011771号