结对项目

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

本作业属于:软件工程(计科 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 需求拆解:四条硬约束

把题目原文翻译成可执行的约束:

  1. CLI-n 给题量、-r 给数值范围(必须给,否则报错并打印帮助);判分模式 -e 题目文件 -a 答案文件
  2. 合法表达式:自然数 / 真分数 / 带分数;+ − × ÷;每题运算符 ≤ 3。
  3. 不能出现负数:每个 e1 − e2 子式必须 e1 ≥ e2
  4. 除法必须是真分数:每个 e1 ÷ e2 必须 0 < e1 < e2(商落在 (0,1))。
  5. 去重:两道题不能通过有限次交换 +× 的左右操作数变成同一题。
  6. 输出:题目 → 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+4545+23 是重复的"。我们把表达式树递归规范化:

  • 遇到 +×,把两个子树的规范结果排序;
  • 遇到 -÷,保持左右顺序。

这样 23+4545+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() 出发,两种运行模式(生成模式 / 判分模式)下各函数之间的调用关系:
屏幕截图 2026-09-19 220745


三、效能分析

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 改进思路

  1. 去重键用 tuple 而不是字符串:一开始想把表达式转成字符串再 set,但字符串比较是 O(长度) 且要反复格式化;改成 tuple 规范形后,去重是纯哈希,1 万题去重 0 额外开销。
  2. Fraction 一次构造、复用求值结果_gen_tree 生成子树后立刻 evaluate 缓存到约束判断,避免后面再算一遍。
  3. 拒绝采样上限 200 次-r 很小时(比如 r=2)某些算子组合几乎必然违反约束,设上限防止死循环,直接报错提示"增大 -r"。

实测:1 万道题 零重复wc -lsort -u 一致),端到端 0.29 s,距离题目隐含的时间余量非常充裕。
截图:屏幕截图 2026-09-19 222125


四、关键代码说明

(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 -rpython 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/1910÷(...) = 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):

屏幕截图 2026-09-19 221957

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

image

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

屏幕截图 2026-09-19 222401

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

屏幕截图 2026-09-19 222211


六、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=2r=3 的小范围下反复压测,推动他给拒绝采样加上重试上限。后面的一万题压力测试、故意改错答案验证 Wrong 题号,也都是我和他一起设计和确认的。我的闪光点是把输入符号的兼容做得比较全,让判卷器很"皮实"。整个过程让我感到,找漏洞和写代码一样需要系统性——专门盯着约束和边界去构造反例,比随便跑两遍有效得多;建议下次我们在编码前就先一起把测试用例和异常输入列出来,用测试来驱动设计,配合会更顺畅。

彼此的闪光点与建议:

  • 张志远看谢嘉鸿:闪光点是他提问的角度很"刁钻",专挑边界和异常下手,0÷x、BOM、全角符号都是他先发现的,测试设计很系统;建议是下次把问题清单在我开工前就整理好,并试着自己动手改一小部分代码,理解会更深。
  • 谢嘉鸿看张志远:闪光点是架构清晰、代码规范,Fraction 精确运算和规范形去重基本一次到位、返工很少,发现问题也改得快;建议是别太相信"第一版就对",写完先自己按边界清单过一遍、多加点断言,函数也可以再拆小些,方便单独测试。
posted @ 2026-09-19 22:32  zzy314  阅读(21)  评论(0)    收藏  举报