结对作业
0. 成员与项目信息
| 项目 | 信息 |
|---|---|
| 我的姓名 | 蔡栩跃 |
| 我的学号 | 3124004235 |
| 结对同学姓名 | 刘薪宇 |
| 结对同学学号 | 3124004253 |
| GitHub 项目地址 | https://github.com/pqbwdqm2vq-hub/arithmetic-cli |
| 开发语言 | Python 3 |
| 主要交付物 | myapp.py、自动化测试、性能分析、题目 / 答案 / 判题文件 |
1. PSP2.1 记录表
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning・计划 | 明确任务范围、交付物和提交要求 | 20 | 15 |
| Development · Analysis | 需求分析,包括表达式、真分数、去重、判题规则和命令行参数学习 | 60 | 70 |
| Development · Design Spec | 生成设计文档,确定表达式树、分数运算和文件格式 | 40 | 35 |
| Development · Design Review | 复审设计,重点检查减法、除法和去重反例 | 25 | 20 |
| Development · Coding Standard | 制定命名、注释、文件编码、提交信息规范 | 20 | 15 |
| Development · Design | 具体设计类、函数、递归解析器和测试结构 | 70 | 60 |
| Development · Coding | 具体编码 | 180 | 210 |
| Development · Code Review | 代码复审,修正去重结构、括号输出和边界条件 | 40 | 50 |
| Development · Test | 自我测试、修改代码、提交修改 | 100 | 110 |
| Reporting · Test Report | 整理测试报告和运行截图 | 40 | 35 |
| Reporting · Size Measurement | 统计工作量、代码规模和性能数据 | 20 | 15 |
| Reporting · Postmortem & Process Improvement Plan | 事后总结并提出过程改进计划 | 35 | 40 |
| 合计 | 650 | 675 |
2. 需求分析
程序需要同时支持两种工作模式。
第一类是题目生成模式:
python myapp.py -n 10 -r 10
其中:
-n控制题目数量;-r控制题目中初始数值的范围,生成0到r-1的自然数以及分母小于r的真分数;- 题目和答案分别写入当前运行目录下的
Exercises.txt和Answers.txt。
第二类是自动判题模式:
python myapp.py -e Exercises.txt -a Answers.txt
程序读取题目和答案后,将正确和错误题号写入 Grade.txt。
本项目使用 Python 标准库 fractions.Fraction 表示有理数。这样做有两个好处:
-
1/3 + 1/6等分数运算不会出现浮点数误差; -
分数会自动约分,判题时可以直接比较数值。
题目约束被拆成以下检查点:
| 需求 | 实现方式 |
|---|---|
数值范围受 -r 控制 |
初始化自然数池和真分数池,叶子节点只从池中选取 |
| 减法不能产生负数 | 后序遍历表达式树,要求每个减法节点左值 ≥ 右值 |
| 除法结果必须是真分数 | 要求除数非零,且每个除法节点满足 0 < 左值 < 右值 |
| 运算符不超过 3 个 | 表达式树内部节点数随机取 1~3 |
| 题目不能重复 | 为表达式树生成规范形式,用集合判重 |
| 支持真分数和带分数 | Fraction 计算,输出时转换为 a/b 或 a'b/c |
| 支持判题 | 递归下降解析器解析题目,答案按有理数比较 |
| 支持一万题 | 集合判重,普通模式下实测约 0.21 秒生成一万题 |
3. 效能分析
3.1 分析方法
性能分析使用 Python 标准库 cProfile 和 pstats。为了避免偶然波动,性能脚本连续生成 20 轮,每轮 10000 道题,共 200000 道题。
复现命令:
python tools/profile\_run.py
普通一万题压测命令:
cd samples/load10000
python ../../myapp.py -n 10000 -r 10
最近一次普通压测结果:
已生成 10000 道题:Exercises.txt、Answers.txt
real 0.21s
user 0.18s
sys 0.01s
10000 Exercises.txt
10000 Answers.txt
3.2 优化思路
最初版本在每次生成叶子节点时临时创建分子、分母并构造 Fraction。cProfile 显示分数对象创建和约分占用了较多时间。随后做了如下优化:
-
预建操作数池:生成器初始化时一次性创建所有合法自然数和约分后的真分数。
-
随机选择复用对象:生成叶子节点时直接从池中选择,减少重复最大公约数计算。
-
只对合法表达式计算规范形式:先做减法和除法约束检查,通过后再进行去重键计算。
-
使用集合判重:规范形式放入
set,平均查找复杂度为 O (1)。 -
保持表达式树深度最多为 3:避免表达式规模无限增长,也让校验成本保持稳定。
优化前后,性能脚本均运行 20×10000 道题:
| 版本 | cProfile 总耗时 | 变化 |
|---|---|---|
| 优化前 | 19.376 秒 | 基线 |
| 优化后 | 10.563 秒 | 下降约 45.48% |
3.3 热点函数
| 排名 | 函数 | 自身耗时 (s) | 累计耗时 (s) | 调用次数 | 说明 |
|---|---|---|---|---|---|
| 1 | build |
0.8738 | 5.8910 | 494246 | 递归构造表达式树 |
| 2 | _valid_value |
0.7286 | 2.3553 | 494246 | 递归检查减法、除法等约束 |
| 3 | _random_leaf |
0.5556 | 2.7085 | 1483147 | 生成叶子操作数 |
| 4 | generate |
0.4857 | 10.4798 | 20 | 每轮生成任务的入口函数 |
| 5 | leaf |
0.4828 | 1.0054 | 1483147 | 创建叶子节点 |
| 6 | canonical_form |
0.3892 | 0.8751 | 278980 | 生成去重规范形式 |
| 7 | internal |
0.3439 | 0.6867 | 988901 | 创建内部表达式节点 |
| 8 | _random_tree |
0.2811 | 6.5664 | 494246 | 随机构造表达式树 |
从累计耗时看,generate 是总入口,耗时包含所有子函数;从函数自身耗时看,优化后最高的是递归函数 build。这符合预期,因为随机表达式树的创建、节点对象分配和递归调用是题目的主要工作量。
3.4 性能分析图

4. 设计实现过程
4.1 代码组织
程序主体集中在 myapp.py,测试位于 test_myapp.py,性能脚本位于 tools/。这样既便于命令行直接运行,也能保持作业项目结构清晰。
| 模块 / 函数 | 职责 |
|---|---|
Node |
表达式树节点。叶子节点保存 Fraction,内部节点保存运算符和左右子树 |
format_fraction |
将分数格式化为自然数、真分数或带分数 |
render_expression |
根据优先级输出表达式和必要括号 |
canonical_form |
生成去重用的规范形式 |
ExerciseGenerator |
构造操作数池、随机生成表达式树、检查约束、判重 |
ExpressionParser |
递归下降解析器,负责判题时解析题目 |
generate_files |
生成并写入 Exercises.txt、Answers.txt |
grade_files |
比对题目和答案,写入 Grade.txt |
main |
解析命令行参数,切换生成模式或判题模式 |
核心模块依赖关系
ExerciseGenerator(题目生成器)- 调用
Node构建表达式树,用Fraction做精确有理数计算 - 内部完成操作数池初始化、随机表达式树生成、约束校验、去重判断、题目批量输出
- 调用
ExpressionParser(表达式解析器,判题使用)- 调用
Fraction计算解析后的标准答案 - 完成词法拆分、括号处理、乘除加减优先级解析
- 调用
- 公共工具函数
format_fraction:把计算结果转换为题目要求的自然数/真分数/带分数格式render_expression:根据运算符优先级输出带必要括号的题目文本canonical_form:生成题目去重的规范形式,判断是否重复generate_files:把题目、答案写入对应文本文件grade_files:读取题目和答案文件,判题后输出 Grade.txtmain:命令行入口,自动切换题目生成或自动判题模式
4.2 生成与判题流程
题目生成流程
- 程序启动,读取命令行参数
-n和-r - 初始化自然数和真分数操作数池
- 随机生成包含 1~3 个运算符的表达式树
- 递归校验表达式合法性:
- 若出现减法负数、除法不符合真分数要求,丢弃当前题目,回到第 3 步重新生成
- 为合法表达式生成去重规范键
- 检查该题目是否已生成过:
- 若重复,回到第 3 步重新生成
- 把题目和对应答案加入结果列表,直到数量达到
-n指定值 - 所有题目写入
Exercises.txt,答案写入Answers.txt
自动判题流程
- 程序读取命令行参数
-e(题目文件)和-a(答案文件) - 逐行读取题目文件和答案文件
- 对每道题目:
- 拆分词法单元,识别数字、运算符、括号
- 递归下降解析表达式,用 Fraction 精确计算标准答案
- 读取用户答案,解析为有理数数值
- 比对两个数值,判断对错
- 汇总正确、错误的题目编号,写入
Grade.txt
4.3 表达式树设计
四则运算天然具有树形结构。例如:
(1 + 3 ÷ 9) ÷ 5
对应表达式树:
÷
/ \\
\+ 5
/ \\
1 ÷
/ \\
3 9
使用表达式树后,可以递归完成三件事:
-
计算每个子表达式的值;
-
检查每个减法和除法节点是否合法;
-
自底向上生成去重键。
4.4 去重设计
题目中的重复不是简单字符串重复。23 + 45 和 45 + 23 重复,因为加法左右子树可以交换;6 × 8 和 8 × 6 也重复。但 1+2+3 和 3+2+1 不重复,因为它们的树形结合结构不同。
因此,本项目没有把所有加法节点展平,而是在每个加法或乘法节点上只交换当前节点的左右子树:
def canonical\_form(node: Node):
if node.is\_leaf:
return "number", node.value
if node.op in (ADD, MUL):
children = \[
canonical\_form(node.left),
canonical\_form(node.right),
]
children.sort(key=repr)
return node.op, tuple(children)
return node.op, canonical\_form(node.left), canonical\_form(node.right)
这样可以正确处理:
-
23 + 45与45 + 23:重复; -
6 × 8与8 × 6:重复; -
3+(2+1)与(1+2)+3:重复; -
(1+2)+3与(3+2)+1:不重复。
5. 关键代码说明
5.1 预建合法操作数池
自然数和真分数都必须满足范围要求。初始化时统一构造,可以避免后续重复约分:
self.natural\_numbers = \[Fraction(value) for value in range(range\_value)]
fraction\_values: set\[Fraction] = set()
for denominator in range(2, range\_value):
for numerator in range(1, denominator):
fraction\_values.add(Fraction(numerator, denominator))
self.proper\_fractions = sorted(fraction\_values)
5.2 减法和除法约束
后序遍历表达式树。只要任意子表达式不合法,整道题就丢弃并重新生成:
if node.op == SUB and left\_value < right\_value:
return None
if node.op == DIV:
\# 商必须是正的真分数,因此除数非零且 0 < 被除数 < 除数。
if right\_value == 0 or left\_value <= 0 or left\_value >= right\_value:
return None
return left\_value / right\_value
这里对除法采用严格判断:0 < 被除数 < 除数,保证商是正的真分数,同时排除除零。
5.3 带分数输出
Fraction 内部统一使用假分数或普通分数表示,输出时再转换为题目要求的格式:
def format\_fraction(value: Fraction) -> str:
numerator = value.numerator
denominator = value.denominator
if denominator == 1:
return str(numerator)
if numerator > denominator:
whole, remainder = divmod(numerator, denominator)
return f"{whole}'{remainder}/{denominator}"
return f"{numerator}/{denominator}"
例如 19/8 输出为 2'3/8。
5.4 递归下降解析器
判题时需要重新计算题目答案。解析器分为三层:
-
factor:处理数字和括号; -
term:处理乘法、除法; -
expression:处理加法、减法。
核心代码如下:
def term(self) -> Fraction:
value = self.factor()
while self.peek() in (MUL, DIV):
op = self.take()
right = self.factor()
if op == DIV and right == 0:
raise ZeroDivisionError("除数不能为 0")
value = value \* right if op == MUL else value / right
return value
def expression(self) -> Fraction:
value = self.term()
while self.peek() in (ADD, SUB):
op = self.take()
right = self.term()
value = value + right if op == ADD else value - right
return value
5.5 判题输出
每道题都用 Fraction 比较。答案缺行、格式错误、解析失败都会被记为错误:
try:
expected = parse\_expression(question)
actual = parse\_answer(answer\_text)
is\_correct = actual == expected
except (ValueError, ZeroDivisionError, IndexError):
is\_correct = False
(correct if is\_correct else wrong).append(index)
6. 测试运行
6.1 自动化测试命令
python -m unittest discover -v
最近一次测试结果:
Ran 13 tests in 0.179s
OK
6.2 测试用例
| 编号 | 测试用例 | 目的 | 预期结果 |
|---|---|---|---|
| 1 | 5、7/24、19/8 格式化 |
验证自然数、真分数、带分数输出 | 分别输出 5、7/24、2'3/8 |
| 2 | 解析 2'3/8 |
验证带分数答案输入 | 得到 19/8 |
| 3 | 2 + 3 × 4 |
验证乘除优先于加减 | 结果为 14 |
| 4 | (2 + 3) × 4 |
验证括号能改变优先级 | 结果为 20 |
| 5 | 6 ÷ 2 − 1 |
验证 Unicode 运算符 ÷、− |
结果为 2 |
| 6 | 10 − (2 + 3) |
验证右侧同级减法括号 | 输出保留括号,结果为 5 |
| 7 | 1 ÷ (2 × 3) |
验证右侧同级除法括号 | 输出保留括号,结果为 1/6 |
| 8 | 23+45 与 45+23 |
验证加法交换律去重 | 规范形式相同 |
| 9 | 6×8 与 8×6 |
验证乘法交换律去重 | 规范形式相同 |
| 10 | 3+(2+1) 与 1+2+3 |
验证题目给出的重复反例 | 规范形式相同 |
| 11 | 1+2+3 与 3+2+1 |
验证题目给出的不重复反例 | 规范形式不同 |
| 12 | 批量生成 300 道题 | 检查数值范围、运算符数量、减法、除法和答案 | 全部合法且不重复 |
| 13 | 生成 10000 道题 | 压力测试和去重测试 | 10000 道题全部唯一 |
| 14 | 命令行生成文件 | 验证 -n、-r 和文件写入 |
两个文件各 10 行 |
| 15 | 命令行判题 | 验证 -e、-a 和 Grade.txt |
正确、错误题号符合预期 |
| 16 | 缺少 -r |
验证必填参数检查 | 输出帮助信息并退出 |
6.3 示例运行结果
命令:
python myapp.py -n 10 -r 10
示例题目:
1\. (8 + 2 × 7) × 6/7 =
2\. 3 + 8/9 =
3\. 1/9 ÷ (5 + 1/3) =
4\. (6 + 1) × 7 =
5\. 5 − (4 + 1/2) =
6\. 6 + 8 − 2 =
7\. 4/7 × 3/5 =
8\. 2/3 × 6/7 + 2 − 1/6 =
9\. 1/4 + (1 − 3/4) ÷ 4 =
10\. 5/8 × (6 + 8) =
对应答案:
1\. 18'6/7
2\. 3'8/9
3\. 1/48
4\. 49
5\. 1/2
6\. 12
7\. 12/35
8\. 2'17/42
9\. 5/16
10\. 8'3/4
使用生成的答案直接判题:
Correct: 10 (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
Wrong: 0 ()
6.4 为什么可以确定程序正确
我没有只依赖少量手工样例,而是从四个层面验证:
-
单元测试:对格式转换、解析器、括号、去重规则分别设置独立断言。
-
约束测试:随机生成 300 道题,递归检查每一个减法和除法节点,而不是只检查最终答案。
-
压力测试:生成 10000 道题,逐题计算答案并检查规范形式集合大小,确认没有重复。
-
文件级测试:通过命令行真实生成
Exercises.txt、Answers.txt和Grade.txt,验证用户实际使用路径。
7. 源代码管理
项目按功能增量提交,而不是一次性提交全部文件。建议的提交记录如下:
feat: 实现表达式树和四则运算题目生成
test: 补充约束、去重和命令行测试
perf: 增加 cProfile 性能分析与操作数池优化
docs: 补充运行说明、测试记录和结对项目博客
chore: 记录最终测试和一万题判题结果
每次提交都对应一个相对独立的功能点:
-
第一次提交保证题目可以生成;
-
第二次提交补齐正确性验证;
-
第三次提交体现性能分析和优化;
-
第四次提交完善文档和报告;
-
第五次提交保存最终自动化测试和一万题回判结果。
8. 项目小结
这次项目的难点不在四则运算本身,而在规则的严格表达。普通字符串拼接很难处理括号、结合性、交换律和分数运算,因此我们选择表达式树作为核心数据结构。树结构让每一个子表达式都可以被独立求值和检查,也让去重逻辑更加清晰。
开发过程中最需要注意的是题目给出的去重反例。如果简单地把所有加法节点展平并排序,就会把 1+2+3 和 3+2+1 错误地判为同一道题。后来我们把规则限定为 “在每个加法或乘法节点交换当前左右子树”,并通过单元测试固定这个行为,才解决了问题。
性能方面,最初临时创建分数对象的成本较高。通过 cProfile 定位热点后,我们将合法操作数提前放入对象池复用,20 轮一万题的 profile 总耗时从 19.376 秒降到 10.563 秒。这让我更明确地认识到性能优化应该先测量、再修改,而不是凭感觉调整代码。
8.1 结对感受
本次结对中,我主要负责表达式树、命令行入口、性能分析和主要编码;刘薪宇主要参与判题逻辑检查、测试用例设计、边界条件讨论和文档复核。实际开发时,两个人互相检查能更快发现思维盲区。例如减法非负和除法真分数不能只看最终结果,必须检查每个子表达式,这一点在交叉复审中得到了重点确认。
8.2 同伴闪光点与建议
刘薪宇的闪光点是测试思路比较细致,会主动构造括号、分数、错误答案和重复表达式等边界情况。特别是在讨论 3+(2+1)、1+2+3 和 3+2+1 的区别时,能够按照题目给出的交换规则逐步推导,而不是只凭数学上的加法结合律直接下结论。
建议是后续结对项目可以更早固定函数接口和文件格式,这样一个人编写生成逻辑时,另一个人可以并行编写判题和测试,减少后期合并调整。
8.3 我的改进方向
后续再遇到类似项目,我会先把 “数据如何表示、规则如何验证、错误如何测试” 三件事写成设计清单,再开始编码。同时应在开发初期就加入自动化测试,而不是等功能写完后再补测试。这样每次修改去重或解析逻辑时,都能立刻知道是否破坏了旧规则。



浙公网安备 33010602011771号