结对项目 —— 小学四则运算题目生成器
姓名:谭铭昊 学号:3124004068
姓名:周子骞 学号:3124004077
GitHub 项目地址:https://github.com/RicharTan-zlz/pair-programming-homework
| 这个作业属于哪个班级 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694 |
| 这个作业的目标 | 结对完成题目为自动生成小学四则运算题目的图像界面程序。 |
一、PSP 表格
1.1 预估耗时
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | |
| · Analysis | · 需求分析(包括学习新技术) | 40 |
| · Design Spec | · 生成设计文档 | 30 |
| · Design Review | · 设计复审 | 20 |
| · Coding Standard | · 代码规范 | 15 |
| · Design | · 具体设计 | 40 |
| · Coding | · 具体编码 | 180 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 30 |
| · Size Measurement | · 计算工作量 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,提出过程改进计划 | 30 |
| 合计 | 550 |
1.2 实际耗时
| PSP2.1 | Personal Software Process Stages | 实际耗时(分钟) |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 25 |
| Development | 开发 | |
| · Analysis | · 需求分析 | 60 |
| · Design Spec | · 生成设计文档 | 40 |
| · Design Review | · 设计复审 | 25 |
| · Coding Standard | · 代码规范 | 20 |
| · Design | · 具体设计 | 50 |
| · Coding | · 具体编码 | 240 |
| · Code Review | · 代码复审 | 40 |
| · Test | · 测试 | 120 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 35 |
| · Size Measurement | · 计算工作量 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结 | 30 |
| 合计 | 700 |
分析:实际耗时 700 分钟 > 预估 550 分钟。主要超支在编码(+60)和测试(+30),原因是表达式树去重算法和批改模式中"分数/除法混淆"bug 的排查耗费较多时间。后续应预留更多 buffer。
二、设计实现过程
2.1 模块结构
程序分为 5 层:
┌──────────────────────────────────┐
│ main() 命令行入口(argparse) │
├──────────────────────────────────┤
│ 生成模式 / 批改模式 两种分支 │
├──────────────────────────────────┤
│ 表达式树(tree_*) │
├──────────────────────────────────┤
│ 分数工具(format/parse/eval) │
├──────────────────────────────────┤
│ 随机数生成(gen_number) │
└──────────────────────────────────┘
核心函数:
| 函数 | 功能 |
|---|---|
format_fraction(f) |
Fraction → 字符串(3/5, 2'3/8) |
parse_fraction(s) |
字符串 → Fraction |
eval_fraction_expr(expr) |
精确计算表达式(批改模式用) |
gen_number(r) |
生成 [0, r) 内的自然数或真分数 |
gen_tree(r, max_ops) |
递归生成表达式树 |
tree_value(node) |
递归求值 |
tree_to_string(node) |
表达式树 → 字符串(按需加括号) |
tree_normalize(node) |
表达式树规范化(去重用) |
generate(n, r) |
生成 n 道不重复题目 |
grade(ex_path, ans_path) |
批改模式 |
2.2 关键算法:表达式树
用二叉树表示表达式:
- 数字节点:
('num', Fraction) - 运算节点:
('op', op_char, left, right)
生成流程图:
[开始生成]
│
▼
┌──────────────┐
│ 随机选运算符 │
└───────┬──────┘
│
┌─────┴─────┐
▼ ▼
递归左子 递归右子
│ │
└─────┬─────┘
▼
┌──────────────┐
│ 求值 + 验证 │
│ · 减法≥0 │
│ · 除法<1 │
└───────┬──────┘
│
▼
加入题库
求值天然支持数学优先级:递归计算时先算子节点再算父节点,等价于"先乘除后加减"。
打印时按需加括号:
- 左子优先级 < 父优先级 → 加括号:
(1 + 2) × 3 - 右子优先级 < 父优先级,或同级且父为
-/÷→ 加括号:1 - (2 - 3)
2.3 关键算法:去重
作业要求:23 + 45 和 45 + 23 视为重复,3 + (2 + 1) 和 1 + 2 + 3 视为重复。
实现:tree_normalize 递归处理:
- 加法:左右子树排序后拼接
- 乘法:左右子树排序后拼接
- 减法、除法:不排序(不满足交换律)
示例:
# 1 + 2 与 2 + 1 归一化后相同
normalize(1+2) == normalize(2+1) # True
# 2 - 1 与 1 - 2 不同
normalize(2-1) != normalize(1-2) # True
2.4 关键算法:批改模式
难点:1/2 ÷ 3/4 中,/ 既表示分数又表示除法,必须区分。
解决方案:
- 把
÷替换为特殊符号// - 把
a/b形式的分数替换为Fraction(a, b) - 把剩余的纯数字替换为
Fraction(n) - 最后把
//替换回/(Python 中 Fraction 的/就是真除法)
效果:
eval("1/2 ÷ 3/4") # 结果 Fraction(2, 3)
eval("1/2 ÷ 4 ÷ 5") # 结果 Fraction(1, 40)
2.5 核心代码
def gen_tree(r: int, max_ops: int, depth: int = 0):
"""递归生成表达式树,返回 (node, None) 或 (None, None)"""
if max_ops == 0 or (depth > 0 and random.random() < 0.35):
return gen_number_node(r), None
op = random.choice(['+', '-', '×', '÷'])
left_ops = random.randint(0, max_ops - 1)
right_ops = max_ops - 1 - left_ops
left_node, _ = gen_tree(r, left_ops, depth + 1)
right_node, _ = gen_tree(r, right_ops, depth + 1)
lv = tree_value(left_node)
rv = tree_value(right_node)
if op == '-':
if lv < rv: # 交换左右,保证不产生负数
left_node, right_node = right_node, left_node
lv, rv = rv, lv
elif op == '÷':
if rv == 0 or lv / rv >= 1: # 保证除法的结果是真分数
return None, None
node = ('op', op, left_node, right_node)
return node, None
三、性能分析
3.1 性能分析图
使用 cProfile + snakeviz 分析 10000 道题生成:

3.2 数据分析
测试条件:10000 道题目生成,总耗时 1.046 秒。
| 函数 | 耗时占比 | 说明 |
|---|---|---|
generate() |
90% | 主生成流程 |
gen_tree() |
50% | 表达式树递归生成(瓶颈) |
gen_number() |
20% | 随机数生成 |
tree_value() |
11% | 表达式求值 |
format_fraction() |
10% | 分数格式化 |
tree_to_string() |
9% | 表达式转字符串 |
3.3 瓶颈分析
gen_tree调用 106766 次,但只有 23502 次成功(成功率 22%)- 78% 的失败来自约束检查(负数、除法 ≥ 1、无意义题目)
3.4 优化思路
理论上可优化:
- 用"预生成候选数 + 约束筛选"替代递归重试
- 缓存随机数,减少
random.randint调用
实际上不需要:1 秒生成 10000 题,远低于 5 秒要求,优化收益不大。
四、单元测试展示
4.1 测试策略
- 白盒:覆盖所有分支(空输入、减法负数、除法 ≥ 1、运算符超限)
- 黑盒:等价类划分(正常、边界、异常)
- 共设计 30 个测试用例
4.2 测试模块分布
| 模块 | 用例数 | 覆盖内容 |
|---|---|---|
| TestFractionUtils | 7 | 分数格式化 / 解析 |
| TestEvalExpression | 6 | 表达式精确求值 |
| TestExpressionTree | 7 | 树求值 / 打印 / 去重 |
| TestGeneration | 6 | 生成器约束验证 |
| TestIO | 4 | 文件读写 / 批改模式 |
4.3 测试代码片段
class TestEvalExpression(unittest.TestCase):
"""表达式精确求值测试"""
def test_priority_mul_before_add(self):
"""测试9: 先乘除后加减 - 1 + 2 × 3 = 7"""
self.assertEqual(eval_fraction_expr("1 + 2 × 3"), Fraction(7))
def test_priority_div_before_sub(self):
"""测试10: 1/3 - 1/6 ÷ 3 = 5/18"""
self.assertEqual(eval_fraction_expr("1/3 - 1/6 ÷ 3"), Fraction(5, 18))
def test_fraction_division(self):
"""测试13: 1/2 ÷ 3/4 = 2/3"""
self.assertEqual(eval_fraction_expr("1/2 ÷ 3/4"), Fraction(2, 3))
class TestIO(unittest.TestCase):
"""文件 IO 测试"""
def test_grade_correct(self):
"""测试29: 批改模式 - 全对"""
with tempfile.TemporaryDirectory() as d:
ex = os.path.join(d, "ex.txt")
ans = os.path.join(d, "ans.txt")
with open(ex, 'w', encoding='utf-8') as f:
f.write("1. 1 + 2 =\n2. 3 × 4 =\n")
with open(ans, 'w', encoding='utf-8') as f:
f.write("1. 3\n2. 12\n")
old_cwd = os.getcwd()
os.chdir(d)
try:
grade(ex, ans)
with open("Grade.txt", encoding='utf-8') as f:
content = f.read()
self.assertIn("Correct: 2", content)
self.assertIn("Wrong: 0", content)
finally:
os.chdir(old_cwd)
4.4 测试结果

Ran 30 tests in 0.037s
OK
4.5 正确性验证
100 题批改测试:

10000 题压力测试:
- 生成时间:约 1 秒
- 批改结果:Wrong: 0,100% 正确
4.6 测试正确性说明
为什么能确定程序正确:
- 白盒测试:覆盖所有分支(空输入、减法负数、除法 ≥ 1、运算符超限)
- 边界测试:
-r 2、-n 1、极值分数 - 端到端测试:生成 → 批改 → 对比原答案
- 交叉验证:批改模式独立计算答案,与生成模式的答案对比,10000 题全部一致
五、代码质量
5.1 pylint 评分

5.2 代码规范
- 所有函数均有 docstring
- 变量命名使用 snake_case
- 常量使用 UPPER_CASE
- 无未使用变量 / 导入
- 无过长函数(均 < 50 行)
六、异常处理说明
虽然本项目未引入自定义异常类,但通过流程约束保证鲁棒性:
| 场景 | 处理方式 |
|---|---|
| 命令行参数缺失 | argparse 报错 + 使用说明 |
-r 参数 ≤ 0 |
print("错误: -r 必须为正整数") + exit(1) |
| 文件不存在 | open 抛 FileNotFoundError |
| 表达式求值失败 | try/except 跳过该题 |
| 无法生成足够题目 | 打印警告并生成实际数量 |
七、Git 提交记录
共 5 次有意义的 commit,符合"每完成一个功能就提交"的要求:
| # | commit | 内容 |
|---|---|---|
| 1 | fba957b |
Initial commit |
| 2 | c7da74e |
feat: 实现四则运算表达式树生成器核心 |
| 3 | e6e9752 |
test: add 30 unit tests for core modules |
| 4 | e628c61 |
chore: add requirements and gitignore |
| 5 | 115af8f |
docs: add README |
八、运行方式
# 无需第三方依赖,仅需 Python 3.8+
python main.py -n 10 -r 10
# 批改模式
python main.py -e Exercises.txt -a Answers.txt
# 运行单元测试
python -m unittest test_main.py
九、项目小结
9.1 结对分工
- 谭铭昊:主笔编码,负责表达式树生成、批改模式、单元测试
- 周子骞:协助设计、代码 review、测试数据准备
9.2 遇到的困难
最大难题:批改模式的"分数/除法混淆"
批改时,1/2 ÷ 3/4 这个表达式中,/ 既表示分数又表示除法。最初用简单的 re.sub 替换,导致:
1/2 ÷ 4 ÷ 5被算成1/2 ÷ (4/5)= 5/8(错)- 正确答案应为
1/2 ÷ 4 ÷ 5= 1/40
解决:用 // 临时符号区分"除法运算符",先替换分数,再还原 //。
这个 bug 花了 2 小时排查,最后通过对比生成模式答案 vs 批改模式答案定位。
9.3 经验教训
- 测试先行:如果一开始就写"1/2 ÷ 4 ÷ 5"的测试用例,可以提早发现 bug
- 表达式解析很坑:优先考虑用 AST 或者手工词法分析,而不是正则
- 性能分析能定位真实瓶颈:cProfile 让我们知道
gen_tree占 50%,而不是想象中的分数运算
9.4 结对感受
闪光点:
- 两人同时 review 表达式树的去重逻辑,互相 catch 到了对方没考虑到的边界(
1+2+3vs3+2+1) - 遇到 bug 时一起分析,比单打独斗快很多
建议:
- 下次应该先写出所有测试用例,再写代码
- 应该提早 commit,不要等所有功能做完才提交
十、总结
本次结对项目完整实现了作业的 9 项需求(1-9),从生成、约束、去重、IO 到批改模式全部通过 10000 题测试。
核心成果:
- 10000 题生成 + 批改,100% 正确,耗时 1 秒
- 单元测试 30/30 通过
- pylint 10.00/10
- 5 次有意义的 Git commit
未实现:附加分需求 10(查重功能),因时间有限未做。
完
浙公网安备 33010602011771号