结对作业

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 控制题目中初始数值的范围,生成 0r-1 的自然数以及分母小于 r 的真分数;
  • 题目和答案分别写入当前运行目录下的 Exercises.txtAnswers.txt

第二类是自动判题模式:

python myapp.py -e Exercises.txt -a Answers.txt

程序读取题目和答案后,将正确和错误题号写入 Grade.txt

本项目使用 Python 标准库 fractions.Fraction 表示有理数。这样做有两个好处:

  1. 1/3 + 1/6 等分数运算不会出现浮点数误差;

  2. 分数会自动约分,判题时可以直接比较数值。

题目约束被拆成以下检查点:

需求 实现方式
数值范围受 -r 控制 初始化自然数池和真分数池,叶子节点只从池中选取
减法不能产生负数 后序遍历表达式树,要求每个减法节点左值 ≥ 右值
除法结果必须是真分数 要求除数非零,且每个除法节点满足 0 < 左值 < 右值
运算符不超过 3 个 表达式树内部节点数随机取 1~3
题目不能重复 为表达式树生成规范形式,用集合判重
支持真分数和带分数 Fraction 计算,输出时转换为 a/ba'b/c
支持判题 递归下降解析器解析题目,答案按有理数比较
支持一万题 集合判重,普通模式下实测约 0.21 秒生成一万题

3. 效能分析

3.1 分析方法

性能分析使用 Python 标准库 cProfilepstats。为了避免偶然波动,性能脚本连续生成 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 显示分数对象创建和约分占用了较多时间。随后做了如下优化:

  1. 预建操作数池:生成器初始化时一次性创建所有合法自然数和约分后的真分数。

  2. 随机选择复用对象:生成叶子节点时直接从池中选择,减少重复最大公约数计算。

  3. 只对合法表达式计算规范形式:先做减法和除法约束检查,通过后再进行去重键计算。

  4. 使用集合判重:规范形式放入 set,平均查找复杂度为 O (1)。

  5. 保持表达式树深度最多为 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 性能分析图

image

4. 设计实现过程

4.1 代码组织

程序主体集中在 myapp.py,测试位于 test_myapp.py,性能脚本位于 tools/。这样既便于命令行直接运行,也能保持作业项目结构清晰。

模块 / 函数 职责
Node 表达式树节点。叶子节点保存 Fraction,内部节点保存运算符和左右子树
format_fraction 将分数格式化为自然数、真分数或带分数
render_expression 根据优先级输出表达式和必要括号
canonical_form 生成去重用的规范形式
ExerciseGenerator 构造操作数池、随机生成表达式树、检查约束、判重
ExpressionParser 递归下降解析器,负责判题时解析题目
generate_files 生成并写入 Exercises.txtAnswers.txt
grade_files 比对题目和答案,写入 Grade.txt
main 解析命令行参数,切换生成模式或判题模式

核心模块依赖关系

  • ExerciseGenerator(题目生成器)
    • 调用 Node 构建表达式树,用 Fraction 做精确有理数计算
    • 内部完成操作数池初始化、随机表达式树生成、约束校验、去重判断、题目批量输出
  • ExpressionParser(表达式解析器,判题使用)
    • 调用 Fraction 计算解析后的标准答案
    • 完成词法拆分、括号处理、乘除加减优先级解析
  • 公共工具函数
    • format_fraction:把计算结果转换为题目要求的自然数/真分数/带分数格式
    • render_expression:根据运算符优先级输出带必要括号的题目文本
    • canonical_form:生成题目去重的规范形式,判断是否重复
    • generate_files:把题目、答案写入对应文本文件
    • grade_files:读取题目和答案文件,判题后输出 Grade.txt
    • main:命令行入口,自动切换题目生成或自动判题模式

4.2 生成与判题流程

题目生成流程

  1. 程序启动,读取命令行参数 -n-r
  2. 初始化自然数和真分数操作数池
  3. 随机生成包含 1~3 个运算符的表达式树
  4. 递归校验表达式合法性:
    • 若出现减法负数、除法不符合真分数要求,丢弃当前题目,回到第 3 步重新生成
  5. 为合法表达式生成去重规范键
  6. 检查该题目是否已生成过:
    • 若重复,回到第 3 步重新生成
  7. 把题目和对应答案加入结果列表,直到数量达到 -n 指定值
  8. 所有题目写入 Exercises.txt,答案写入 Answers.txt

自动判题流程

  1. 程序读取命令行参数 -e(题目文件)和 -a(答案文件)
  2. 逐行读取题目文件和答案文件
  3. 对每道题目:
    • 拆分词法单元,识别数字、运算符、括号
    • 递归下降解析表达式,用 Fraction 精确计算标准答案
    • 读取用户答案,解析为有理数数值
    • 比对两个数值,判断对错
  4. 汇总正确、错误的题目编号,写入 Grade.txt

4.3 表达式树设计

四则运算天然具有树形结构。例如:

(1 + 3 ÷ 9) ÷ 5

对应表达式树:

     ÷
   / \\
     \+   5
   / \\
 1   ÷
    / \\
    3   9

使用表达式树后,可以递归完成三件事:

  1. 计算每个子表达式的值;

  2. 检查每个减法和除法节点是否合法;

  3. 自底向上生成去重键。

4.4 去重设计

题目中的重复不是简单字符串重复。23 + 4545 + 23 重复,因为加法左右子树可以交换;6 × 88 × 6 也重复。但 1+2+33+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 + 4545 + 23:重复;

  • 6 × 88 × 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 57/2419/8 格式化 验证自然数、真分数、带分数输出 分别输出 57/242'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+4545+23 验证加法交换律去重 规范形式相同
9 6×88×6 验证乘法交换律去重 规范形式相同
10 3+(2+1)1+2+3 验证题目给出的重复反例 规范形式相同
11 1+2+33+2+1 验证题目给出的不重复反例 规范形式不同
12 批量生成 300 道题 检查数值范围、运算符数量、减法、除法和答案 全部合法且不重复
13 生成 10000 道题 压力测试和去重测试 10000 道题全部唯一
14 命令行生成文件 验证 -n-r 和文件写入 两个文件各 10 行
15 命令行判题 验证 -e-aGrade.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 为什么可以确定程序正确

我没有只依赖少量手工样例,而是从四个层面验证:

  1. 单元测试:对格式转换、解析器、括号、去重规则分别设置独立断言。

  2. 约束测试:随机生成 300 道题,递归检查每一个减法和除法节点,而不是只检查最终答案。

  3. 压力测试:生成 10000 道题,逐题计算答案并检查规范形式集合大小,确认没有重复。

  4. 文件级测试:通过命令行真实生成 Exercises.txtAnswers.txtGrade.txt,验证用户实际使用路径。

7. 源代码管理

项目按功能增量提交,而不是一次性提交全部文件。建议的提交记录如下:

feat: 实现表达式树和四则运算题目生成

test: 补充约束、去重和命令行测试

perf: 增加 cProfile 性能分析与操作数池优化

docs: 补充运行说明、测试记录和结对项目博客

chore: 记录最终测试和一万题判题结果

每次提交都对应一个相对独立的功能点:

  • 第一次提交保证题目可以生成;

  • 第二次提交补齐正确性验证;

  • 第三次提交体现性能分析和优化;

  • 第四次提交完善文档和报告;

  • 第五次提交保存最终自动化测试和一万题回判结果。

8. 项目小结

这次项目的难点不在四则运算本身,而在规则的严格表达。普通字符串拼接很难处理括号、结合性、交换律和分数运算,因此我们选择表达式树作为核心数据结构。树结构让每一个子表达式都可以被独立求值和检查,也让去重逻辑更加清晰。

开发过程中最需要注意的是题目给出的去重反例。如果简单地把所有加法节点展平并排序,就会把 1+2+33+2+1 错误地判为同一道题。后来我们把规则限定为 “在每个加法或乘法节点交换当前左右子树”,并通过单元测试固定这个行为,才解决了问题。

性能方面,最初临时创建分数对象的成本较高。通过 cProfile 定位热点后,我们将合法操作数提前放入对象池复用,20 轮一万题的 profile 总耗时从 19.376 秒降到 10.563 秒。这让我更明确地认识到性能优化应该先测量、再修改,而不是凭感觉调整代码。

8.1 结对感受

本次结对中,我主要负责表达式树、命令行入口、性能分析和主要编码;刘薪宇主要参与判题逻辑检查、测试用例设计、边界条件讨论和文档复核。实际开发时,两个人互相检查能更快发现思维盲区。例如减法非负和除法真分数不能只看最终结果,必须检查每个子表达式,这一点在交叉复审中得到了重点确认。

8.2 同伴闪光点与建议

刘薪宇的闪光点是测试思路比较细致,会主动构造括号、分数、错误答案和重复表达式等边界情况。特别是在讨论 3+(2+1)1+2+33+2+1 的区别时,能够按照题目给出的交换规则逐步推导,而不是只凭数学上的加法结合律直接下结论。
建议是后续结对项目可以更早固定函数接口和文件格式,这样一个人编写生成逻辑时,另一个人可以并行编写判题和测试,减少后期合并调整。

8.3 我的改进方向

后续再遇到类似项目,我会先把 “数据如何表示、规则如何验证、错误如何测试” 三件事写成设计清单,再开始编码。同时应在开发初期就加入自动化测试,而不是等功能写完后再补测试。这样每次修改去重或解析逻辑时,都能立刻知道是否破坏了旧规则。

截屏2026-09-22 11.14.20
截屏2026-09-22 11.21.51
截屏2026-09-22 11.09.23

posted @ 2026-09-22 11:31  ybaiai  阅读(14)  评论(0)    收藏  举报