结对项目(陈明凯、邓文炜)
第二次结对项目
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 结对项目 |
| 这个作业的目标 | 双人合作完成一次结对项目 |
一、
小组成员:陈明凯 3124004503 邓文炜3124004505
作业 GitHub 地址:https://github.com/kecit33/arithmetic-generator
二、PSP表格
| 阶段 | 主要工作 | 预计用时/分钟 | 实际用时/分钟 |
|---|---|---|---|
| 需求分析 | 阅读作业要求,整理参数、文件格式和约束 | 20 | 20 |
| 总体设计 | 设计表达式树、分数表示和命令行模式 | 40 | 60 |
| 题目生成 | 实现随机数、运算符、括号和结果约束 | 60 | 70 |
| 等价题去重 | 实现交换律、结合律规范化键 | 30 | 30 |
| 文件输出 | 实现 Exercises、Answers 文件生成 | 30 | 40 |
| 批改功能 | 实现表达式解析、答案比较和 Grade 输出 | 20 | 20 |
| 兼容性修改 | 调整为 Python 3.6 可运行版本 | 10 | 10 |
| 性能分析 | 使用 VS Community Profiler 定位热点 | 40 | 30 |
| 性能优化 | 缓存表达式计算结果并重新测试 | 60 | 80 |
| 测试与文档 | 完成功能测试、README 和博客 | 45 | 50 |
| 合计 | 355 | 400 |
三. 效能分析
3.1 花费时间
在性能改进上花费约 30 分钟(含测量、剖析、两次小优化与验证)。
3.2 改进思路
- 先测量后优化:用
cProfile剖析生成 5000 题(-r 10)的过程,确认瓶颈在随机数生成与
分数构造(gen_tree0.155s、gen_number0.092s、random.randrange0.068s、Fraction
构造 0.041s,合计约占 80%),而不是算法结构,因此没有引入复杂的算法级优化。 - 校验与求值合一:初版"先 is_valid() 校验、再 evaluate() 求值"会把子表达式值重复计算多次;
改为valid_value()一趟自底向上同时完成约束校验与求值,非法子树立即剪枝。
A/B 实测(各 20000 题):两趟 0.3561s vs 单趟 0.3389s(r=10),提升约 4.8%。 - 运算符加权降重试:−、÷ 是带约束运算符(分别要求左≥右、左<右),随机生成失败率高;
给 +、× 权重 3、−、÷ 权重 2,降低无效重试比例。 - 集合判重 O(1):
canonical()生成唯一规范串后放入set,避免 O(n²) 两两比较;
1 万题判重总耗时仅约 0.03s。 - 重试上限防死循环:
max_attempts = n×300 + 1000,题目空间不足(如 -r 1 要 500 题)时
主动报错退出,而不是死循环。
3.3 性能分析图

左图:生成耗时随题目数量近似线性增长,1 万道题(-r 10)约 0.22 秒(约 4.5 万题/秒),
满足"支持一万道题";右图:消耗最大的函数是随机建树 gen_tree 及其内部调用的
gen_number、random.randrange、Fraction 构造——瓶颈在随机数生成本身,当前性能已远超需求。
四. 设计实现过程
4.1 代码组织
程序为单文件 Myapp.py(约 300 行,仅用 Python 标准库,无第三方依赖),按职责分 5 个模块:
| 模块 | 函数 | 职责 |
|---|---|---|
| 题目生成 | gen_number / gen_tree / valid_value | 随机生成表达式树;一趟完成约束校验与求值 |
| 判重 | canonical | 表达式树 → 唯一规范化字符串 |
| 表达式输出 | to_string / need_parens / num_str / answer_str | 树 → 题目字符串(括号规则);答案格式化 |
| 解析求值 | evaluate_expression / parse_number | 题目字符串 → 分数值(判题用,递归下降) |
| 入口与判题 | main / generate_problems / grade /read_lines | 参数解析、文件读写、对错统计 |
4.2 函数调用关系
main
├── generate_problems(count, r) # 生成主循环
│ ├── gen_tree(ops, r) # 随机生成表达式树
│ │ └── gen_number(r) # 随机自然数 / 真分数
│ ├── valid_value(node) # 约束校验 + 求值(一趟)
│ ├── canonical(node) # 规范化判重串
│ └── to_string(node) / answer_str(v) # 输出题目与答案
│ └── need_parens(...) # 括号规则
└── grade(ex_file, an_file) # 判题模式
├── read_lines(path) # 读取文件(兼容 utf-8/gbk)
├── evaluate_expression(text) # 独立解析题目并求值
│ └── parse_number(tok) # 整数 / a/b / a'b/c → Fraction
└── … # 写 Grade.txt
4.3 核心数据结构
表达式用二叉树表示,结点为元组:数字叶子 ('n', Fraction),运算符结点
('o', '×', 左子树, 右子树)。数值全部用标准库 fractions.Fraction 表示,精确分数运算、
无浮点误差,这是答案正确的基础(1/6 + 1/8 由 Fraction(1,6) + Fraction(1,8) 精确得到 7/24)。
4.4 判重算法设计(关键)
作业对"重复"的定义:两道题可通过有限次交换 + 和 × 的左右子树互相变换。例如
3+(2+1) 与 1+2+3 重复(三步交换:3+(2+1) → 交换根 → (2+1)+3 → 交换内层 →(1+2)+3);
而 1+2+3 与 3+2+1 不重复。
对应算法:canonical() 对+、×结点把左右子树的规范化串排序后拼接(交换不改变结果),
对 −、÷ 结点保持左右顺序(不可交换)。每个"可交换等价类"恰好映射到唯一字符串,
set 判重 O(1)。注意不做结合律展开(a+(b+c) 与 (a+b)+c 是两道不同的题),与作业说明
完全一致,并有 12 项单元测试覆盖(见 6.3 节)。
4.5 括号输出规则
生成树需还原为题目字符串,且重解析后必须得到同一棵树(否则判题会算错)。规则(need_parens):
-
×、÷ 优先级高于+、−,低优先级子树置于高优先级外层时需加括号,如 (1+2)×3;
-
−、÷ 的右子树含运算符时一律加括号,否则 a-(b-c) 会被重解析成 (a-b)-c;
-
同级左结合,(a+b)+c 直接写 a + b + c;a×(b÷c) 保留括号以示结构。
4.6 主流程
五. 代码说明
5.1 表达式生成与单趟"校验+求值"
def gen_tree(ops, r):
"""随机生成一棵恰好包含 ops 个运算符的表达式树(元组表示)。"""
if ops == 0:
return ('n', gen_number(r)) # 数字叶子
op = random.choice(OP_POOL) # 加权选择运算符
left_ops = random.randrange(ops) # 运算符分给左右子树
right_ops = ops - 1 - left_ops
return ('o', op, gen_tree(left_ops, r), gen_tree(right_ops, r))
def valid_value(node):
"""一趟自底向上完成『约束校验 + 求值』, 非法子树立即剪枝。
约束: ①减法左值 >= 右值(计算过程不产生负数);
②除法 0 < 左值 < 右值(结果必为真分数)。
"""
if node[0] == 'n':
return True, node[1]
_, op, left, right = node
ok1, v1 = valid_value(left)
if not ok1:
return False, Fraction(0)
ok2, v2 = valid_value(right)
if not ok2:
return False, Fraction(0)
if op == '-':
if v1 < v2: # 需求3: 无负数
return False, Fraction(0)
return True, v1 - v2
if op == '÷':
if not (v1 > 0 and v1 < v2): # 需求4: 除法结果真分数
return False, Fraction(0)
return True, v1 / v2
if op == '+':
return True, v1 + v2
return True, v1 * v2
valid_value 是正确性的第一道关卡:叶子非负、+/× 保持非负、− 保证左≥右,
因此整棵树的中间结果与最终答案都不会出现负数。
5.2 判重核心
def canonical(node):
"""对 + 和 × 的左右子树排序, 使『可交换等价类』映射到唯一字符串。"""
if node[0] == 'n':
v = node[1]
return 'N%d/%d' % (v.numerator, v.denominator)
_, op, left, right = node
if op in ('+', '×'):
return op + '(' + ','.join(sorted((canonical(left), canonical(right)))) + ')'
return op + '(' + canonical(left) + ',' + canonical(right) + ')' # - ÷ 保持顺序
例:23 + 45 与 45 + 23 均规范化为 +(N23/1,N45/1) → 判重复;
3+(2+1) 与 1+2+3 均规范化为 +(+(N1/1,N2/1),N3/1) → 判重复;
3+2+1 规范化为 +(+(N2/1,N3/1),N1/1) → 不重复。与作业说明逐条吻合。
5.3 生成主循环
def generate_problems(count, r):
seen, result = set(), []
max_attempts = count * 300 + 1000 # 重试上限, 防死循环
for _ in range(max_attempts):
if len(result) >= count:
break
node = gen_tree(random.choice((1, 2, 3)), r) # 需求5: 每题 1~3 个运算符
ok, value = valid_value(node)
if not ok:
continue # 不满足约束, 重新生成
key = canonical(node)
if key in seen:
continue # 与已有题目重复(需求6), 重新生成
seen.add(key)
result.append((to_string(node) + ' = ', answer_str(value)))
if len(result) < count:
raise RuntimeError('无法生成 %d 道互不重复的题目, 请增大 -r 参数(当前 -r %d)' % (count, r))
return result
5.4 判题
def grade(ex_file, an_file):
exercises = read_lines(ex_file) # 兼容 utf-8 / gbk 编码
answers = read_lines(an_file)
...
for i in range(min(len(exercises), len(answers))):
expr = exercises[i].rstrip('=').strip()
try:
got = evaluate_expression(expr) # 独立解析题目并求值
want = parse_number(answers[i]) # 支持 5 / 3/5 / 2'3/8
(correct if got == want else wrong).append(i + 1)
except Exception:
wrong.append(i + 1) # 解析失败/除零按错处理
# 写 Grade.txt: Correct: 5 (1, 3, 5, 7, 9)
# Wrong: 5 (2, 4, 6, 8, 10)
六. 测试运行
6.1 测试环境与自动化测试
-
环境:Windows 11 + Python 3.10.11,命令行运行;
-
三套可复跑测试:
tools/run_tests.py(23 项命令行用例)、tools/test_canonical.py
(12 项判重规则用例)、tools/verify.py(对任意题目文件做全量校验)。
6.2 命令行测试用例(23 项,全部通过)
| # | 用例 | 预期结果 | 实际 |
|---|---|---|---|
| 1 | -n 10 -r 10 | 生成 10 道题 | PASS |
| 2 | 生成后 Exercises/Answers 各 10 行 | 行数一致 | PASS |
| 3 | 题目以 = 结尾(等号前后空格) | 全部满足 | PASS |
| 4 | 运算符前后有空格 | 全部满足 | PASS |
| 5 | -n 10(缺 -r) | 报错并给帮助,退出码 1 | PASS |
| 6 | -r 10(缺 -n) | 默认生成 10 道 | PASS |
| 7 | -n 5 -r 1(r 可为 1 的边界) | 正常生成(全 0 算式) | PASS |
| 8 | -r 0 | 报错 | PASS |
| 9 | -n 0 | 报错 | PASS |
| 10 | 无参数 | 显示帮助 | PASS |
| 11 | -n(缺取值) | 报错 | PASS |
| 12 | 全对答案判题 | Correct: 10 (1,2,...,10) | PASS |
| 13 | Grade.txt 内容 | 两行,Wrong: 0 | PASS |
| 14 | 篡改第 2、5 题答案 | Correct: 8 (1,3,4,6,7,8,9,10) | PASS |
| 15 | Grade.txt 错题编号 | Wrong: 2 (2, 5) | PASS |
| 16 | 题目与答案行数不一致 | 警告并按较小值统计 | PASS |
| 17 | 等价分数判题(5 vs 10/2) | 判定为对 | PASS |
| 18 | 只给 -e | 报错 | PASS |
| 19 | 只给 -a | 报错 | PASS |
| 20 | -e/-a 与 -n 同时使用 | 报错 | PASS |
| 21 | 题目文件不存在 | 报错(无 Traceback) | PASS |
| 22 | -n 500 -r 1(题目空间不足) | 报错而非死循环 | PASS |
6.3 判重规则单元测试(12 项,全部通过,覆盖作业说明全部例子)
| 题目 A | 题目 B | 预期 | 实际 |
|---|---|---|---|
| 23 + 45 | 45 + 23 | 重复(交换 + 左右) | PASS |
| 6 × 8 | 8 × 6 | 重复(交换 × 左右) | PASS |
| 3 + (2 + 1) | 1 + 2 + 3 | 重复(作业原例) | PASS |
| 1 + 2 + 3 | 3 + 2 + 1 | 不重复(作业原例) | PASS |
| 8 - 3 | 3 - 8 | 不重复(减号不可交换) | PASS |
| 6 ÷ 2 | 2 ÷ 6 | 不重复(除号不可交换) | PASS |
| 3 + (2 + 1) | 3 + (1 + 2) | 重复(内层交换) | PASS |
| (1 + 2) + 3 | 3 + (2 + 1) | 重复(根交换后再内层交换) | PASS |
| 2 × 3 + 4 | 4 + 2 × 3 | 重复 | PASS |
| 2 + 3 × 4 | 2 + 4 × 3 | 重复 | PASS |
| 2 + 3 × 4 | 2 + 3 + 4 | 不重复(结构不同) | PASS |
| (2 - 3) + 4` | 2 - (3 + 4) | 不重复(括号位置不同) | PASS |
6.4 全量校验(1 万道题)
verify.py 将生成的每题字符串重新解析成表达式树,独立检查:运算符 1~3 个、减法无负数、
除法结果为真分数、数值(自然数/真分数/分母)均在 -r 范围内、答案与重新计算值一致(分数
等价比较)、题目两两不重复。对 -n 10000 -r 10 全量校验输出:
PASS 共 10000 道题: 运算符1~3个、无负数、除法结果为真分数、
数值在范围内、答案全部正确、题目两两不重复
6.5 运行效果示例
> Myapp.exe -n 10 -r 10
已生成 10 道题目: Exercises.txt / Answers.txt
Exercises.txt Answers.txt
3 × (2 + 5) = 21
3 ÷ 5 × 8 = 4'4/5
9 - 8/9 = 8'1/9
6 × 4 × (5 - 1/7) = 116'4/7
1 ÷ (8 + 1/2) = 2/17
2/3 × 9 × (9 + 1) = 60
8 + 0 + 2/7 = 8'2/7
3 - 2 = 1
4/5 + (4 + 7 + 1) = 12'4/5
3 ÷ 7 = 3/7
另:1/6 + 1/8的答案经 Fraction 精确计算为 7/24,与作业示例一致(见测试用例 12 的固定数据)。
6.6 为什么能确定程序是正确的
- 生成端三道关卡:
valid_value用精确分数保证"无负数、除法结果真分数";canonical + set
保证不重复;need_parens保证输出可无歧义还原。 - 独立校验路径交叉验证:
verify.py走与生成端完全不同的路径(字符串 → 重新解析 → 逐项
断言),1 万题全量通过,两套代码互相印证。 - 判重规则与作业说明逐条对齐:作业中"重复/不重复"的全部例子做成单元测试,全部通过。
- 判题端双向测试:全对、全错、混合、等价分数四种场景符合预期;判题时重新独立解析求值,
与生成端的答案计算路径不同,可发现生成端潜在错误。 - 参数边界全覆盖:-r 缺失/为0/为1、-n 缺失/为0、文件缺失、行数不一致、题目空间不足
等异常路径均有明确行为,无崩溃、无死循环。
七. 项目小结
7.1 成败得失
成功之处:
-
把需求抠到字面以下。判重规则是本题最大的坑:先对作业说明中的 4 个例子逐一推导
(3+(2+1) 如何两步交换变成 1+2+3,而 1+2+3 为什么变不成 3+2+1),得出
"按树结构、只交换 +/× 子树、不做结合律展开"的精确模型,判重代码仅 9 行且一次通过全部单测。 -
全程精确分数:Fraction 杜绝浮点误差,1/6 + 1/8 = 7/24 天然正确。
-
测试自动化:23 项命令行用例 + 12 项规则用例 + 1 万题全量校验一键可复跑,回归成本几乎为零。
-
性能充裕:1 万题约 0.22 秒,远高于需求门槛。
不足之处:
-
生成策略是"随机生成 + 丢弃重试",代码简洁但存在无效尝试;-r 很小而 -n 很大时重试上升,
我们以重试上限优雅报错兜底,但未实现"约束式生成"(更复杂,收益在当前规模下很小)。 -
判题对非常规输入(如答案含多余空格、中文括号)容错有限,目前按解析失败处理;
因作业假设"输入题目均符合规范",此取舍合理。
7.2 经验与教训
- 需求中"等价"类问题(判重、格式)先形式化定义再编码,否则返工成本极高;
- 括号输出与解析必须互为逆过程,才能保证"生成的题目判题一定正确";
- 测试要独立于实现(verify.py 走不同路径),才能发现生成端的潜在错误;
- 性能优化先 profile 再动手,避免在非瓶颈上浪费精力。
7.3 结对感受与互评
陈明凯:坚持用递归下降实现解析器并严格对齐输出端括号规则,保证了"生成字符串可
无歧义还原成同一棵树",这是判题正确的基础;他写的 verify.py 独立于生成端走了一条不同的
验证路径,帮我们抓出几处边界问题,是质量保障的关键。建议:下次更早共享性能剖析结果,避免
他在优化上重复造轮子。
邓文炜:把作业说明中"重复"的定义推敲得非常透彻,直接避免了"字符串排序+展开
结合律"的错误方案;他"先写单测再写实现"的习惯让判重模块一次通过。建议:可以在结对开始时
就约定统一的命名与注释风格,减少代码复审成本。
共同体会:结对编程最大收益是"设计先行、代码互审"——关键算法(判重、括号规则)先一起
推演清楚再动手,写代码时间反而只占一小部分;自动化测试让两人放心各自修改模块而不怕互相
破坏。最大的教训是:遇到"等价"类需求,一定要先构造形式化定义并写用例,否则返工成本极高。

浙公网安备 33010602011771号