结对项目
结对项目——小学四则运算题目自动生成器
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 计科24级56班 |
| 这个作业要求在哪里 | 结对项目 |
| 这个作业的目标 | 结对完成一个自动生成小学四则运算题目的命令行程序,实践需求分析、设计、编码、测试与性能分析 |
| 姓名 | 学号 | GitHub 仓库 |
|---|---|---|
| 陈建志 | 3124007214 | https://github.com/DexJocelyn/PairWork |
| 陈信儒 | 3124004160 | https://github.com/xingwangxiang/PairWork |
一、PSP 表格
在开始编码前,我们对项目各阶段所需时间进行了预估;项目完成后,再根据实际结对开发过程补充实际耗时。记录如下:
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 40 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 40 |
| Development | 开发 | 630 | 745 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 90 |
| · Design Spec | · 生成设计文档 | 30 | 25 |
| · Design Review | · 设计复审(和队友审核设计) | 30 | 20 |
| · Coding Standard | · 制定代码规范 | 20 | 15 |
| · Design | · 具体设计 | 60 | 70 |
| · Coding | · 具体编码 | 300 | 360 |
| · Code Review | · 代码复审 | 40 | 45 |
| · Test | · 测试、修改代码并提交 | 90 | 120 |
| Reporting | 报告 | 75 | 95 |
| · Test Report | · 测试报告 | 30 | 40 |
| · Size Measurement | · 计算工作量 | 15 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结并提出改进计划 | 30 | 45 |
| 合计 | 735 | 880 |
从结果来看,实际总耗时比预估多 145 分钟。差异主要集中在编码和测试两个阶段:编码比预估多 60 分钟,主要花在除法真分数约束与交换律去重的边界情况处理上;测试比预估多 30 分钟,是因为要构造足够的边界用例来验证「交换律等价」这类容易出错的逻辑。需求分析也比预估多 30 分钟——「任何两道题不能通过有限次交换 + 和 × 变换为同一道题」这句话(例如 1+2+3 与 3+(2+1) 等价、但与 3+2+1 不等价)需要仔细推敲才能转化为可实现的标准。这说明我在开始项目前低估了理解题意和约束边界所需的时间,今后会在需求分析阶段投入更多精力,并在编码前先厘清关键约束的数学定义。
二、项目需求分析
本项目需要实现一个自动生成小学四则运算题目的命令行程序。题目中的数字包括自然数和真分数(如 3/5),运算结果可能出现带分数(如 2'3/8)。核心需求如下:
-n参数控制生成题目的个数;-r参数控制题目中数值(自然数、真分数及分母)的范围,必填,缺失时报错并给出帮助信息;- 计算过程中不能产生负数,即
e1 − e2需满足e1 ≥ e2; - 若存在
e1 ÷ e2子表达式,其结果必须是真分数,即0 < e1 < e2; - 每道题中出现的运算符个数不超过 3 个;
- 一次运行生成的题目不能重复,即任何两道题不能通过有限次交换
+和×左右表达式变换为同一道题; - 题目存入当前目录的
Exercises.txt; - 计算所有题目的答案并存入
Answers.txt(如1/6 + 1/8 = 7/24); - 支持一万道题目的生成;
- 支持
-e <题目文件> -a <答案文件>判分,结果写入Grade.txt。
运行示例:
python main.py -n 10 -r 10 # 生成 10 道题,数值范围 10 以内
python main.py -e Exercises.txt -a Answers.txt # 判分
除基本功能外,程序还需要处理参数缺失、-r 范围过小导致无法生成足够题目、题目文件与答案文件数量不一致等异常情况。
三、计算模块接口的设计与实现
3.1 项目结构
项目采用模块化结构,核心逻辑与入口、测试相互分离:
PairWork/
├── arithmetic.py # 核心模块:分数格式化、表达式树、生成器、解析器、判分
├── main.py # 命令行入口
├── tests.py # 22 个测试用例
├── README.md # 运行说明
└── .gitignore
程序运行部分只使用 Python 标准库 fractions、argparse 和 random,评测环境无需安装任何第三方运行依赖。
3.2 模块接口
| 类 / 函数 | 功能 |
|---|---|
Expression |
表达式树节点,op 为运算符,叶子节点存 Fraction 值,构造时即求值 |
Generator |
题目生成器,gen_expr 递归生成表达式,generate 负责去重与批量生成 |
frac_to_str(f) |
分数转字符串(整数 / 3/5 / 2'3/8) |
canonical(node) |
计算交换律去重的规范键 |
parse_expression(s) |
将题目字符串解析为表达式树(判分用) |
parse_answer(s) |
将答案字符串解析为 Fraction |
write_problems(list) |
将题目与答案写入 Exercises.txt / Answers.txt |
judge(ex, ans) |
比对题目与答案,输出 Grade.txt |
main() |
解析命令行参数,分发生成 / 判分 |
模块之间的调用关系如下:
main()
├── Generator.generate() # 生成模式
│ ├── gen_expr() # 递归生成表达式
│ │ └── random_number() # 生成自然数 / 真分数叶子
│ ├── canonical() # 计算去重键
│ └── write_problems() # 写文件
│ └── to_str() / frac_to_str()
└── judge() # 判分模式
├── parse_expression()
├── parse_answer()
└── (写入 Grade.txt)
生成器的主流程如下:
3.3 核心算法
1. 分数表示
程序统一使用 fractions.Fraction 表示所有数字,保证四则运算全程精确、无浮点误差。输出时还原成作业要求的三种格式:
def frac_to_str(f: Fraction) -> str:
if f.denominator == 1:
return str(f.numerator) # 整数
if f.numerator < f.denominator:
return f"{f.numerator}/{f.denominator}" # 真分数 3/5
whole, rest = divmod(f.numerator, f.denominator)
if rest == 0:
return str(whole)
return f"{whole}'{rest}/{f.denominator}" # 带分数 2'3/8
2. 表达式树
每道题表示为一棵二叉树。叶子节点存数字,内部节点存运算符,构造节点时立即用 Fraction 求出该子树的值,供后续约束判断使用:
class Expression:
__slots__ = ('op', 'left', 'right', 'value')
def __init__(self, op=None, left=None, right=None, value=None):
self.op = op
self.left = left
self.right = right
if op is None:
self.value = value
else:
self.value = _apply(op, left.value, right.value)
3. 递归生成(含约束)
gen_expr(k) 递归生成含 k 个运算符的表达式。减法和除法的约束在生成时直接保证,而非生成后再整树拒绝:
def gen_expr(self, k: int) -> Expression:
if k == 0:
return Expression(value=self.random_number())
ops = ['+', '-', '*', '/'] if self.r >= 3 else ['+', '-', '*']
op = random.choice(ops)
k1 = random.randint(0, k - 1)
k2 = k - 1 - k1
if op == '-':
left, right = self.gen_expr(k1), self.gen_expr(k2)
if left.value < right.value:
left, right = right, left # 交换保证非负
return Expression('-', left, right)
if op == '/':
for _ in range(300):
left, right = self.gen_expr(k1), self.gen_expr(k2)
if 0 < left.value < right.value: # 保证结果为真分数
return Expression('/', left, right)
n = random.randint(2, self.r - 1) # 兜底:构造 m/n
m = random.randint(1, n - 1)
return Expression('/', Expression(value=Fraction(m, 1)),
Expression(value=Fraction(n, 1)))
return Expression(op, self.gen_expr(k1), self.gen_expr(k2))
4. 交换律去重
需求 6 是本题最易出错的点。关键洞察是:+ 和 × 满足交换律、− 和 ÷ 不满足;而结合律不算作等价(1+2+3 与 3+2+1 是两道不同的题)。因此去重的方法是计算「规范键」——把 +、× 的左右子树规范键排序,−、÷ 保持原序:
def canonical(self) -> str:
if self.is_leaf():
return f"{self.value.numerator}/{self.value.denominator}"
l = self.left.canonical()
r = self.right.canonical()
if self.op in ('+', '*') and l > r:
l, r = r, l
return f"({l}{self.op}{r})"
以题目原文的例子验证:1+2+3 按左结合解析为 (1+2)+3,与 3+(2+1) 规范化后同为 ((1+2)+3),判定重复;而 3+2+1 解析为 (3+2)+1,规范化为 ((2+3)+1),与前者不同,判定不重复。
四、计算模块接口部分的性能改进
4.1 性能分析方法
我们先用 Python 自带的 cProfile 生成性能数据,再用 SnakeViz 可视化各函数的调用关系与耗时。分析对象为生成一万道题(-n 10000 -r 100)的场景。
python -m cProfile -o 效能分析.prof main.py -n 10000 -r 100
snakeviz 效能分析.prof

4.2 热点定位
cProfile 结果显示,生成一万道题总耗时约 0.321s,其中最耗时的函数是递归生成器 gen_expr(约 0.225s,占总量约 70%),其次是 random_number(随机数字生成,约 0.0881s)。这些时间主要花在随机数生成和 Fraction 构造上,属于算法本身的开销,并非异常热点。
4.3 优化方法
真正值得优化的地方在去重。最初我们考虑用列表保存已生成题目的规范键,每次生成后线性查找是否重复,这会导致 O(n²) 的时间复杂度。优化方案是改用 set(哈希集合),使查重从 O(n) 降为 O(1):
# 优化前:list 线性查找
seen = []
if key not in seen: # O(n)
seen.append(key)
# 优化后:set 哈希查找
seen = set()
if key not in seen: # O(1)
seen.add(key)
此外还有两处设计层面的优化:一是「约束先行」——减法直接交换保证非负、除法有界重试加兜底,避免生成完整表达式后再整树拒绝;二是 Expression 使用 __slots__ 声明字段,一万道题约 3~4 万个节点时内存占用显著下降。
4.4 优化结果
| 指标 | 优化前(list 去重) | 优化后(set 去重) | 变化 |
|---|---|---|---|
| 一万道题去重耗时 | 0.2250s | 0.0007s | 加速约 302 倍 |
| 一万道题总生成耗时 | — | 0.321s | 满足附加分要求 |
从结果可以看出,仅把去重容器从列表换成集合,去重环节就加速了约 302 倍。这让我们认识到,性能优化不能只靠主观猜测,而应先用分析工具定位热点,再有针对性地修改数据结构,并用真实数据验证效果。
五、计算模块部分的单元测试
5.1 测试方法
由于程序只依赖标准库,我们使用 Python 内置的 assert 编写测试脚本 tests.py,共 22 个测试用例,覆盖正常、边界和异常情况。运行命令:
python tests.py

主要测试内容如下:
| 测试对象 | 测试内容 |
|---|---|
frac_to_str() |
真分数 3/5、带分数 2'3/8、整数、零、约分 |
parse_expression() |
1/6+1/8=7/24、混合数、括号优先级、除法 |
canonical() |
23+45/45+23、6×8/8×6、1+2+3/3+(2+1)、1+2+3/3+2+1 等 5 组等价/不等价 |
| 生成约束 | 随机 2000 题全量校验:无非负、除法为真分数、运算符 ≤3、无重复 |
| 一万道题 | 10000 题无重复 |
judge() |
构造错误答案,验证 Correct/Wrong 编号正确 |
部分核心测试代码如下:
# 交换律去重(需求 6 的边界情况)
a = parse_expression("1 + 2 + 3 =").canonical()
b = parse_expression("3 + (2 + 1) =").canonical()
c = parse_expression("3 + 2 + 1 =").canonical()
check("1+2+3 与 3+(2+1) 重复", a == b)
check("1+2+3 与 3+2+1 不重复", a != c)
# 生成约束:随机 2000 题全量校验
def verify_constraints(expr):
if expr.is_leaf():
return expr.value >= 0
if expr.op == '-' and expr.left.value < expr.right.value:
return False
if expr.op == '/' and not (0 < expr.left.value < expr.right.value):
return False
return verify_constraints(expr.left) and verify_constraints(expr.right)
5.2 为什么能确定程序正确
- 约束用随机大样本全量校验:随机生成 2000 道题,逐题递归检查每个子表达式是否非负、除法是否为真分数,而非只测几个手写例子,能覆盖更多组合情况;
- 去重以规范键的数学含义为准:规范键严格对应「交换律等价」,并用题目原文给出的 4 组例子逐一验证,确保与题意一致;
- 判分用已知错误答案反向验证:故意改错部分答案,确认程序既能识别对的、也能揪出错的,而非只测「全对」的平凡情况。
六、异常处理说明
6.1 缺少 -r 参数
-r 为必填参数。缺失时打印帮助信息并以非零状态退出:
错误:-r 参数必须给定。
6.2 非法参数值
-r 必须为正整数、-n 必须为正整数,否则程序报错退出,不会继续执行。
6.3 范围过小导致题目数不足
当 -r 过小(如 -r 2)时,可行的不重复题目数量有限,可能无法生成要求的 n 道题。程序检测到后会抛出带说明的异常,提示用户增大 -r,而不是陷入死循环:
-r 2 下最多只能生成 3 道不重复题目,少于要求的 100 道,请增大 -r。
6.4 除法除零
除法子式要求 0 < 左值 < 右值,因此右值恒大于 0,从根源上杜绝了除零的可能。
6.5 判分文件数量不一致
判分时若题目数与答案数不一致,程序抛出异常并提示数量,而不是静默地对错位比对。
七、运行结果
7.1 生成题目
使用 -n 10 -r 10 生成 10 道题

7.2 题目与答案文件
生成的 Exercises.txt(题目)与 Answers.txt(答案)如下:


可以看到答案中出现了带分数(如 8'2/3、3'2/3、6'2/7),符合需求对真分数 3/5 与带分数 2'3/8 的格式要求。
7.3 判分
将答案文件复制一份并故意改错 3 处,用 -e -a 判分:


程序正确识别出 3 道错题,Grade.txt 输出 Correct: 7 (1, 3, 4, 6, 7, 9, 10) / Wrong: 3 (2, 5, 8)。
7.4 一万道题
使用 -n 10000 -r 100 生成一万道题:

整个程序无需任何第三方运行依赖,可直接通过 Python 命令行执行。
八、Git 版本管理
本项目使用 Git 记录结对开发过程,主要提交阶段包括:
feat:实现四则运算题目生成器核心功能(分数表示、表达式树、约束生成、交换律去重、判分);docs:添加 README 运行说明、效能分析报告与性能图;refactor:精简代码注释,效能分析改用 snakeviz。
将功能实现、文档和重构分别提交,使版本历史更加清晰,也便于在出现问题时定位改动范围。
九、总结与改进计划
通过本次结对项目,我们完成了从需求分析、算法设计、代码实现到单元测试和性能分析的完整流程。过去写程序时我们更关注「能不能跑通」;这次作业让我们认识到,一个较完整的软件项目还需要考虑约束边界、异常输入、去重等价关系、性能数据和版本管理。
在算法方面,我们用表达式树 + 规范键解决交换律去重,用「约束先行」保证减法非负、除法为真分数,用 Fraction 保证分数运算精确。这套方案结构清晰、无第三方依赖,一万道题能在 0.2 秒左右生成完成。
结对过程中,我们采用「同一台电脑、轮流编码、轮流复审」的方式:一人编写核心算法时,另一人在旁即时指出边界问题,这种即时复审比事后 review 更早地发现了去重和除法约束中的错误。
陈建志: 这次结对让我体会最深的是「把题意想清楚再动手」。拿到需求后,我们俩在「任何两道题不能通过有限次交换 + 和 × 变换为同一道题」这句话上反复确认了很久——1+2+3 和 3+(2+1) 算重复、3+2+1 不算,光是把这条翻译成代码里的「规范键」就改了三版。我主要负责表达式树、约束生成和交换律去重这几块核心算法,陈信儒负责命令行参数解析、文件读写、判分模块和测试用例。印象最深的是除法真分数约束:一开始我们打算生成完再整树拒绝,是陈信儒提出「直接在生成除法节点时保证结果是真分数」,省掉了大量无效生成。他构造的 1/6+1/8=7/24 这类带括号、混合数的用例,帮我揪出了两个约分和带分数格式的 bug。一个人写代码容易钻牛角尖,有队友在旁边即时复审,很多低级错误当场就被拦下来了。
陈信儒: 这次结对我主要负责参数解析、文件读写、判分模块,以及测试用例的设计和代码复审。和陈建志配合最大的感受是分工要清楚、边界要先定:他编码快、对递归和 Fraction 很熟,我则更擅长抠题意的边界和构造测试。我们一起把需求 6 拆成几个等价/不等价的例子逐一验证,这一步花的时间最多,但也是最值的一步——后来测试全过,很大程度上归功于前期把这些边界定义清楚了。过程中我也学到不少:用 Fraction 做精确分数运算、用规范键做去重,这些是我以前写作业不会主动去想的方法。
彼此的闪光点与建议:
- 陈信儒 → 陈建志:递归生成表达式的写法清晰、约束都落在生成点上,注释恰到好处。建议是写代码时更早同步思路,有几处我复审时才明白设计意图,边写边讲会更顺。
- 陈建志 → 陈信儒:测试用例设计得很全,尤其是构造「对错各半」的答卷验证判分器,这个思路很关键。建议是再大胆一点,直接动手改核心代码而不只是提建议,配合会更高效。
后续可以从以下方面继续改进:
- 题目生成时进一步平衡运算符的分布,避免连续除法导致题目形态过于单一;
- 为
-r较小时的可生成题目总数建立一个精确上界,提前给出更友好的提示; - 增加更多边界测试,例如
-r 1、-r 3、答案文件含空行等场景; - 在继续优化前建立稳定的性能基线,多次运行取平均值,减少单次测量误差。
本次作业中,PSP 表格帮助我们比较预估与实际耗时,tests.py 帮助我们验证约束正确性,cProfile 与 SnakeViz 则让我们能够根据真实调用耗时定位热点。通过这些工具,我们对「可运行、可测试、可维护、可分析」的软件开发过程有了更具体的认识。

浙公网安备 33010602011771号