结对项目:(郑乐冰,陈嫒琳)
姓名:郑乐冰 学号: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/5、2'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)
关键难点分析:
- 真分数的表示与计算。要求输出形如
2'3/8的带分数,且运算结果要约分。我们使用 Python 标准库fractions.Fraction保存数值,把“数值本身”和“显示格式”分离,避免到处手写最大公约数逻辑。 - 判重规则。题目明确规定“只能交换
+和×的左右”,因此不能简单地“拍扁排序所有操作数”。例如1+2+3与3+2+1不算重复,但1+2+3与3+(2+1)算重复。我们在“无序二叉树”层面做规范化:对+、×节点排序左右子树,对−、÷保持左右顺序。 - 一万道题目的性能。如果采用“随机生成完整表达式再判断合法性”的拒绝采样,会做大量无效工作。改造成“自底向上、每一步只从合法的运算符里随机挑一个”,可以保证产出的树天然合法。
三、设计实现过程
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, 左子树, 右子树)。这样:
- 求值只需一次后序遍历;
- 判重、加括号、统计运算符个数都可以递归处理;
- 生成过程中可以随时拿到任意子表达式的值来检查约束。
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 + 45、45 + 23 |
相同 | 重复 |
6 × 8、8 × 6 |
相同 | 重复 |
1 + 2 + 3、3 + (2 + 1) |
相同 | 重复 |
1 + 2 + 3、3 + 2 + 1 |
不同 | 不重复 |
5 − 3、3 − 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.py 用 argparse 定义 -n / -r / -e / -a。生成模式下缺少 -r 或 -n 会调用 parser.error(...),打印错误与帮助信息并退出;批改模式下必须同时提供 -e 和 -a,否则同样报错。
五、效能分析
5.1 分析方法
为了拿到可复现的数据,我们把生成逻辑封装进 tools/profile_report.py,用 cProfile 对“生成 10000 道 50 以内题目”采样,并生成性能柱状图。命令:
py tools/profile_report.py
脚本会打印耗时最高的函数,并生成不依赖第三方库的 docs/performance_analysis.svg(浏览器打开即可截图);如果安装了 matplotlib,还会额外输出 PNG。
5.2 性能分析图
性能柱状图:
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 的构造与比较,以及随机数生成,业务逻辑本身(_build、canonical)占比并不高。整体性能已经是“生成 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 耗时对比结果:
六、测试运行
6.1 单元测试
测试使用标准库 unittest,命令:
py -m unittest discover -s tests -v
共 22 个用例全部通过。
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 + 45 与 45 + 23 |
视为重复,只保留一道 | 交换律判重 |
| 8 | 1 + 2 + 3 与 3 + 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
对应Answers.txt
批改后的 Grade.txt
6.4 为什么能确定程序是正确的
- 约束性测试:
tests/test_generator.py对多组随机种子各生成 50 道题,递归检查每棵表达式树:所有子树值非负、除法结果严格在(0, 1)之间、运算符个数不超过 3、规范形式互不重复。 - 判重规则测试:
tests/test_expression.py用题目中给出的原始例子(23+45、1+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.py、main.py以及测试与性能分析脚本; - 两人共同完成需求分析、设计评审、代码复审与博客撰写。
8.2 成功经验
- 先定接口再写代码。 我们一开始就把
Node、Parser、ExpressionGenerator的接口写清楚,两人可以并行开发、最后顺利拼装,几乎没有返工。 - 把约束放进构造过程。 “按合法运算符构造”这个设计让生成器天然满足所有约束,省去了大量边界判断和重试逻辑,也让代码更容易读懂。
- 测试先行。 题目里几个容易理解错的点(尤其判重规则)我们先用测试固定下来,后续重构时非常安心。
- 性能分析不能靠猜。 用
cProfile一看才发现大头在标准库的分数运算和随机数,而不是我们以为的业务逻辑。
8.3 遇到的困难
- 判重规则的理解:一开始想当然地认为“数学上相等就是重复”,差点把
1+2+3和3+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




浙公网安备 33010602011771号