结对项目:(郑乐冰,陈嫒琳)

姓名:郑乐冰 学号:3224004234姓名:陈嫒琳 学号:3224004228Github 项目地址:https://github.com/zhenglebing/zhenglebing/tree/main/结对项目


一、PSP 表格(预估)

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

二、需求分析

题目要求实现一个自动生成小学四则运算题目的命令行程序,核心需求可以拆成两部分:

(1)题目生成

  • 使用 -n 指定生成题目的数量;
  • 使用 -r 指定题目中数值的范围(自然数、真分数及其分母都要求 < r),该参数必须给定,否则报错并给出帮助信息;
  • 表达式中只能出现自然数、真分数、+ − × ÷ 和括号;
  • 计算过程中不能出现负数,即任意的 e1 − e2 都要求 e1 ≥ e2
  • 除法 e1 ÷ e2 的结果必须是真分数(0 < e1/e2 < 1);
  • 每道题运算符个数不超过 3 个;
  • 同一次运行生成的题目不能重复。题目把“重复”定义为:可以通过有限次交换 +× 左右表达式而互相转化;
  • 题目写入 Exercises.txt,答案写入 Answers.txt,真分数用 3/52'3/8 表示;
  • 需要支持一次性生成一万道题目。

(2)题目批改

  • 通过 Myapp.exe -e <exercisefile>.txt -a <answerfile>.txt 对题目与答案文件批改;

  • 统计正确、错误的题目编号并写入 Grade.txt,格式为:

    Correct: 5 (1, 3, 5, 7, 9)
    Wrong: 5 (2, 4, 6, 8, 10)

关键难点分析:

  1. 真分数的表示与计算。要求输出形如 2'3/8 的带分数,且运算结果要约分。我们使用 Python 标准库 fractions.Fraction 保存数值,把“数值本身”和“显示格式”分离,避免到处手写最大公约数逻辑。
  2. 判重规则。题目明确规定“只能交换 +× 的左右”,因此不能简单地“拍扁排序所有操作数”。例如 1+2+33+2+1 不算重复,但 1+2+33+(2+1) 算重复。我们在“无序二叉树”层面做规范化:对 +× 节点排序左右子树,对 ÷ 保持左右顺序。
  3. 一万道题目的性能。如果采用“随机生成完整表达式再判断合法性”的拒绝采样,会做大量无效工作。改造成“自底向上、每一步只从合法的运算符里随机挑一个”,可以保证产出的树天然合法。

三、设计实现过程

3.1 模块划分

项目使用面向对象的方式组织,主要分为四层:

main.py 命令行入口,解析参数、调度生成/批改
src/
fraction_utils.py Fraction 与 "3/5"、"2'3/8" 之间的格式化/解析
expression.py 表达式二叉树 Node、求值、字符串化、判重、解析器 Parser
generator.py 题目生成器 ExpressionGenerator / generate_unique_problems
grading.py 批改逻辑 grade / write_grade_file
tests/ 22 个单元测试
tools/ profile_report.py(性能分析)、benchmark.py(算法对比)

各模块的关系是单向依赖:

main.py ──► generator.py ──► expression.py ──► fraction_utils.py
│ ▲
└────────► grading.py ────────┘

  • expression.py 是核心,被生成器和批改器共同复用;
  • generator.py 只负责“造题”;
  • grading.py 只负责“判卷”;
  • main.py 负责 I/O 和参数校验。

3.2 关键数据结构:表达式树

用二叉树表示表达式:叶子保存 Fraction 数值,内部节点保存 (op, 左子树, 右子树)。这样:

  • 求值只需一次后序遍历;
  • 判重、加括号、统计运算符个数都可以递归处理;
  • 生成过程中可以随时拿到任意子表达式的值来检查约束。

image

3.3 题目生成流程

ExpressionGenerator._build 是生成的关键。它接收“还要放几个运算符”,自底向上递归构造:

开始

├─ operator_count <= 0 ──► 生成一个叶子(自然数或真分数),返回值

└─ 随机把 operator_count-1 个运算符分给左右子树

├─ 递归构造左子树,同时拿到左值
├─ 递归构造右子树,同时拿到右值

└─ _choose_operator(左值, 右值):
候选 = { +, × } (永远合法)
若 左值 >= 右值,加入 - (保证不出现负数)
若 0 < 左值/右值 < 1,加入 ÷ (保证除法结果是真分数)
随机返回一个候选

└─ 组合成新节点,同时把该节点的值返回给上层

为什么这样设计? 因为约束是“局部的”:一个节点是否合法只取决于左右子树的值。自底向上构造时,子树的合法性已经保证,父节点只需从合法运算符集合里挑一个,因此永远不会生成非法题目,也不需要“生成—检查—重试”的循环。_build 把子树的数值一并返回,父节点直接使用,避免了对子树重复调用 evaluate()

生成 n 道互不重复的题目由 generate_unique_problems 完成:对每个新题目调用 Node.canonical() 得到规范形式,用集合判重,重复就跳过。为了在数值范围过小、根本无法凑出足够多不重复题目时能够及时报错,代码统计“连续多少次没有产生新题目”,超过阈值后抛出友好错误。

3.4 判重:规范形式

def canonical(self):
if self.is_leaf:
return ("num", str(self.value))
left_key = self.left.canonical()
right_key = self.right.canonical()
if self.op in (ADD, MUL) and right_key < left_key:
left_key, right_key = right_key, left_key
return (self.op, left_key, right_key)

+× 排序左右子树,对 ÷ 不排序。这样:

表达式 规范形式是否相同 是否重复
23 + 4545 + 23 相同 重复
6 × 88 × 6 相同 重复
1 + 2 + 33 + (2 + 1) 相同 重复
1 + 2 + 33 + 2 + 1 不同 不重复
5 − 33 − 5 不同 不重复

3.5 批改流程

grading.grade 逐行读取题目与答案文件:用 Parser 解析题目表达式并求值,用 parse_fraction 解析答案行的数值,比较两者是否相等,分别记入正确/错误编号列表,最后按题目要求写出 Grade.txt。对缺失答案、非法格式的行按“错题”处理,且读取文件时使用 utf-8-sig,可以兼容 Windows 记事本保存的带 BOM 文件。

四、代码说明

4.1 真分数的格式化与解析

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

解析方向则把 2'3/8 还原为 Fraction(2*8+3, 8)Fraction 会自动约分,因此 1/6 + 1/8 直接得到 7/24,与题目示例一致。

4.2 求值与字符串化

to_string 采用“带优先级的递归打印”:

def _render(self):
if self.is_leaf:
return format_fraction(self.value), 3 # 叶子优先级最高
precedence = PRECEDENCE[self.op] # +− 为 1,×÷ 为 2
left_text, left_precedence = self.left._render()
right_text, right_precedence = self.right._render()
if left_precedence < precedence:
left_text = f"({left_text})"
if right_precedence < precedence or (
right_precedence == precedence and self.op in (SUB, DIV)):
right_text = f"({right_text})"
return f"{left_text} {self.op} {right_text}", precedence

只有当子表达式优先级更低、或右侧是同级 /÷ 时才补括号,从而保证打印出来的字符串重新解析后与原来的树语义一致。

4.3 生成器:合法性内嵌在构造里

def _choose_operator(self, left_value, right_value):
candidates = [ADD, MUL] # + 和 × 永远合法
if left_value >= right_value:
candidates.append(SUB) # 减法结果非负
if right_value > 0 and 0 < left_value < right_value:
candidates.append(DIV) # 除法结果是真分数
return self.rng.choice(candidates)

def _build(self, operator_count):
if operator_count <= 0:
value = self._random_value()
return Node.leaf(value), value # 同时返回节点和值
left_operators = self.rng.randint(0, operator_count - 1)
right_operators = operator_count - 1 - left_operators
left, left_value = self._build(left_operators)
right, right_value = self._build(right_operators)
op = self._choose_operator(left_value, right_value)
return Node(op=op, left=left, right=right), self._apply(op, left_value, right_value)

这段代码是需求“不出现负数、除法结果是真分数、运算符不超过 3 个”的直接落地,也是整份作业里我们认为最关键的代码。

4.4 入口与参数校验

main.pyargparse 定义 -n / -r / -e / -a。生成模式下缺少 -r-n 会调用 parser.error(...),打印错误与帮助信息并退出;批改模式下必须同时提供 -e-a,否则同样报错。

383f659d84016bcec636d150a14fb766

五、效能分析

5.1 分析方法

为了拿到可复现的数据,我们把生成逻辑封装进 tools/profile_report.py,用 cProfile 对“生成 10000 道 50 以内题目”采样,并生成性能柱状图。命令:

py tools/profile_report.py

脚本会打印耗时最高的函数,并生成不依赖第三方库的 docs/performance_analysis.svg(浏览器打开即可截图);如果安装了 matplotlib,还会额外输出 PNG。

5.2 性能分析图

性能柱状图:28533e7123020b9a6caeb7ef5f068e27

5.3 消耗最大的函数

对本机 Python 3.14 的采样结果(tottime,单位秒)大致如下:

排名 函数 累计耗时
1 fractions.py: __new__(Fraction 构造) 0.072
2 src/generator.py: _build 0.069
3 fractions.py: _richcmp(Fraction 比较) 0.057
4 random.py: _randbelow_with_getrandbits 0.049
5 random.py: randint 0.044
6 src/expression.py: canonical(判重) 0.040
7 src/generator.py: _choose_operator 0.036
8 src/generator.py: _random_value 0.035

结论:主要开销集中在标准库 fractions.Fraction 的构造与比较,以及随机数生成,业务逻辑本身(_buildcanonical)占比并不高。整体性能已经是“生成 10000 道约 0.2 秒”,远低于需求上限。

5.4 改进思路与对比

改进一:把“拒绝采样”改成“按合法运算符构造”。

最初的想法是“随机拼一棵表达式树,再判断是否合法,不合法就丢弃重来”。这种方式在运算符较多时会产生大量废题。改造为 _choose_operator 只在合法运算符集合里做选择后,生成过程不再产生废题。

tools/benchmark.py 对两种算法做了对比(n=10000,r=50,取 5 轮平均):

py tools/benchmark.py

朴素拒绝采样:0.1955 s
优化构造算法:0.1732 s
提升倍数:1.13x

在本机实测大约有 1.1~1.4 倍提升(不同数值范围下略有波动),由于绝对耗时已经很小,直观感受不明显,但这个改造让生成逻辑更稳健,尤其避免了小 r 场景下大量重试。

改进二:子树数值随构造过程上传。

_build 在构造节点时把子树的值一并返回,父节点选择运算符时直接使用,避免了对整棵子树反复 evaluate()。这也是性能分析图里 _build 虽调用次数多但 evaluate 并不靠前的原因。

改进三:兼容性与易用性。

  • 读取题目/答案文件使用 utf-8-sig,兼容 Windows 记事本的 BOM;
  • 解析器同时接受 × ÷(Unicode)与 * /(ASCII),避免输入格式差异导致批改失败。

运行 py tools/benchmark.py 耗时对比结果:c8e5ef033f96aa06d9db7b1921d0caf2

六、测试运行

6.1 单元测试

测试使用标准库 unittest,命令:

py -m unittest discover -s tests -v

共 22 个用例全部通过。

0cfd797e97df88cb49699f1d55ba6916

6.2 功能测试用例

# 输入 预期结果 说明
1 py main.py -n 10 报错并提示需要 -r 验证 -r 必填
2 py main.py -r 10 报错并提示需要 -n 验证 -n 必填
3 py main.py -n 10 -r 10 生成 10 题,Exercises.txt/Answers.txt 各 10 行 基本生成功能
4 检查所有题目 每个数 < 10,运算符不超过 3 个 范围与运算符数量约束
5 检查所有子表达式 不存在负数 计算过程约束
6 检查所有除法 结果都是真分数 除法约束
7 23 + 4545 + 23 视为重复,只保留一道 交换律判重
8 1 + 2 + 33 + 2 + 1 视为不同题目 结合顺序不同
9 1/6 + 1/8 答案为 7/24 真分数运算与约分
10 2'3/8 + 1/8 答案为 2'1/2 带分数格式
11 py main.py -e Exercises.txt -a 修改过的答案 Grade.txt 输出正确/错误编号 批改统计
12 py main.py -n 10000 -r 50 约 0.2 秒生成 10000 题 一万道题目性能
13 py main.py -e Exercises.txt 报错,要求同时提供 -a 批改参数校验

6.3 代表性运行结果

生成 10 道 10 以内的题目:

4/7 + 0 =
(5 + 3) × 5 × 6 =
2/3 × 0 + 2/7 =
2 × 3/7 + 1/4 =
1/6 × (9 - 2/3) =
2 × (9 + 2) =
1/2 ÷ 3 =
8 × 6 =
2 + 5 × 3/5 × 1/3 =
6 - 3/8 =

对应答案:

4/7
240
2/7
1'3/28
1'7/18
22
1/6
48
3
5'5/8

把第 2、5 题答案故意改错:

4/7
241
2/7
1'3/28
1'7/20
22
1/6
48
3
5'5/8

把第 2、5 题答案故意改错后批改:

Correct: 8 (1, 3, 4, 6, 7, 8, 9, 10)
Wrong: 2 (2, 5)

生成题目后的 Exercises.txtimage

对应Answers.txtimage

批改后的 Grade.txtimage

6.4 为什么能确定程序是正确的

  • 约束性测试tests/test_generator.py 对多组随机种子各生成 50 道题,递归检查每棵表达式树:所有子树值非负、除法结果严格在 (0, 1) 之间、运算符个数不超过 3、规范形式互不重复。
  • 判重规则测试tests/test_expression.py 用题目中给出的原始例子(23+451+2+3 等)逐一验证规范形式,确保与题意一致。
  • 批改正确性测试tests/test_grading.py 构造临时文件,覆盖全对、部分错、缺答案、带分数答案等情况。
  • 确定性:生成器支持随机种子,相同种子结果可复现,便于回归测试。
  • 边界处理-r 过大/过小、文件缺失、BOM、ASCII 运算符等异常路径都有处理。

七、PSP 表格(实际)

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

八、项目小结

8.1 结对分工

  • 郑乐冰:负责 expression.py(表达式树、求值、判重、解析器)与 grading.py
  • 陈嫒琳:负责 generator.pymain.py 以及测试与性能分析脚本;
  • 两人共同完成需求分析、设计评审、代码复审与博客撰写。

8.2 成功经验

  1. 先定接口再写代码。 我们一开始就把 NodeParserExpressionGenerator 的接口写清楚,两人可以并行开发、最后顺利拼装,几乎没有返工。
  2. 把约束放进构造过程。 “按合法运算符构造”这个设计让生成器天然满足所有约束,省去了大量边界判断和重试逻辑,也让代码更容易读懂。
  3. 测试先行。 题目里几个容易理解错的点(尤其判重规则)我们先用测试固定下来,后续重构时非常安心。
  4. 性能分析不能靠猜。cProfile 一看才发现大头在标准库的分数运算和随机数,而不是我们以为的业务逻辑。

8.3 遇到的困难

  • 判重规则的理解:一开始想当然地认为“数学上相等就是重复”,差点把 1+2+33+2+1 也判成重复。后来仔细读题、并用题目给的例子反推,才确定应该用“无序二叉树”规范化。
  • 真分数格式:带分数 2'3/8 的解析和输出需要单独处理,Fraction 并不直接支持这种字符串形式。
  • 批改时的格式差异:Windows 记事本会给文件加 BOM,导致第一行解析失败;改用 utf-8-sig 后解决。这提醒我们“批改器要尽量宽容”。

8.4 结对感受

本次结对开发采用模块拆分、接口先行的协作模式,我们主要通过线上沟通 + 代码评审的方式同步进度。前期两人一起讨论需求难点,对齐判重规则、表达式约束、分数格式化这些容易理解偏差的需求,提前约定好模块之间的接口,保证可以并行开发。

在代码复审阶段,互相阅读对方代码时发现了不少隐蔽问题:在评审生成器代码时,我发现搭档的逻辑在小范围数值场景下,除法筛选条件存在边界漏洞;而对方在阅读表达式树的canonical规范化代码时,指出我在递归处理嵌套加减表达式时,存在树节点排序逻辑考虑不全的隐患。我们一起讨论 bug 成因,共同修改并补充对应的单元测试,避免后续重构出错。

在开发遇到分歧时,我们不会直接争执,而是回到需求文档,编写小的测试用例验证逻辑是否符合题目要求。结对编程的优势在这里体现得很明显:单人开发容易陷入思维盲区,两个人互相检查、交叉测试,可以提前发现很多单靠自测很难发现的逻辑缺陷。整个项目过程中,两个人可以互相分担难点,遇到卡壳的地方一起讨论寻找思路,也加深了我们对表达式树、递归下降解析、性能调优等内容的理解。

8.5 对彼此的闪光点与建议

  • 对 郑乐冰:对核心算法思路清晰,精准理解题目复杂的判重规则,表达式树、解析器模块设计严谨;建议后续在代码中增加更多注释,方便他人快速读懂递归逻辑。

  • 对 陈嫒琳:擅长工程化实现,单元测试写得细致全面,主动搭建性能分析脚本,能主动思考算法性能优化,快速完成命令行入口模块;建议后续可以多深入底层数据结构,从树结构角度思考更多约束校验逻辑。

8.6 可改进之处

  • 目前 -r 过小时只能报错,未来可以在生成前估算“可生成的题目上限”,给用户更明确的提示;
  • 可以增加图形界面,把题目、答案、批改结果直接展示出来;
  • 可以引入更系统的性能基准测试,长期跟踪性能变化。

附:运行方式速查

    # 生成 10 道 10 以内的题目
    py main.py -n 10 -r 10
    
    # 生成 10000 道 50 以内的题目
    py main.py -n 10000 -r 50
    
    # 批改
    py main.py -e Exercises.txt -a Answers.txt
    
    # 单元测试
    py -m unittest discover -s tests -v
    
    # 性能分析
    py tools/profile_report.py

posted @ 2026-09-21 22:34  zhenglebing  阅读(1)  评论(0)    收藏  举报