结对项目(陈明凯、邓文炜)

第二次结对项目

这个作业属于哪个课程 软件工程
这个作业要求在哪里 结对项目
这个作业的目标 双人合作完成一次结对项目

一、

小组成员:陈明凯 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 改进思路

  1. 先测量后优化:用 cProfile 剖析生成 5000 题(-r 10)的过程,确认瓶颈在随机数生成与
    分数构造
    gen_tree 0.155s、gen_number 0.092s、random.randrange 0.068s、Fraction
    构造 0.041s,合计约占 80%),而不是算法结构,因此没有引入复杂的算法级优化
  2. 校验与求值合一:初版"先 is_valid() 校验、再 evaluate() 求值"会把子表达式值重复计算多次;
    改为 valid_value() 一趟自底向上同时完成约束校验与求值,非法子树立即剪枝。
    A/B 实测(各 20000 题):两趟 0.3561s vs 单趟 0.3389s(r=10),提升约 4.8%。
  3. 运算符加权降重试:−、÷ 是带约束运算符(分别要求左≥右、左<右),随机生成失败率高;
    给 +、× 权重 3、−、÷ 权重 2,降低无效重试比例。
  4. 集合判重 O(1)canonical() 生成唯一规范串后放入 set,避免 O(n²) 两两比较;
    1 万题判重总耗时仅约 0.03s。
  5. 重试上限防死循环max_attempts = n×300 + 1000,题目空间不足(如 -r 1 要 500 题)时
    主动报错退出,而不是死循环。

3.3 性能分析图

image

左图:生成耗时随题目数量近似线性增长,1 万道题(-r 10)约 0.22 秒(约 4.5 万题/秒),
满足"支持一万道题";右图:消耗最大的函数是随机建树 gen_tree 及其内部调用的
gen_numberrandom.randrangeFraction 构造——瓶颈在随机数生成本身,当前性能已远超需求。

四. 设计实现过程

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 主流程

flowchart TD A([启动 Myapp]) --> B[解析命令行参数<br/>-n / -r / -e / -a] B --> C{是判题模式?<br/>存在 -e 或 -a} C -- 否 --> D{-r 参数缺失?} D -- 是 --> E[报错并打印帮助信息<br/>退出码 1] D -- 否 --> F{n ≥ 1 且 r ≥ 1?} F -- 否 --> G[参数不合法报错<br/>退出码 1] F -- 是 --> H[生成模式主循环<br/>seen=空集合, 直到凑满 n 道] H --> I[gen_tree 随机生成表达式树<br/>运算符个数取 1~3] I --> J[valid_value 一趟校验 + 求值] J --> K{满足约束?<br/>减法左≥右 / 除法结果真分数} K -- 否 --> I K -- 是 --> L[canonical 规范化判重串] L --> M{与已有题目重复?<br/>即存在于 seen 集合} M -- 是 --> I M -- 否 --> N[seen 加入该题<br/>暂存题目串与答案串] N --> O{已生成 n 道?} O -- 否 --> I O -- 是 --> P[写入 Exercises.txt<br/>写入 Answers.txt<br/>提示生成完成] C -- 是 --> Q{-e 与 -a 是否同时给出?} Q -- 否 --> R[-e 或 -a 缺失报错<br/>退出码 1] Q -- 是 --> S[逐题读取题目与答案<br/>行数取较小值] S --> T[evaluate_expression 独立解析<br/>题目并求值] T --> U[parse_number 解析给定答案] U --> V{计算值 == 答案值?} V -- 是 --> W[记入 Correct, 编号为行号+1] V -- 否 --> X[记入 Wrong, 编号为行号+1] W --> Y{还有题目?} X --> Y Y -- 是 --> S Y -- 否 --> Z[统计对错数量<br/>写入 Grade.txt] Z --> AA([结束]) P --> AA E --> AA G --> AA R --> AA

五. 代码说明

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 为什么能确定程序是正确的

  1. 生成端三道关卡valid_value 用精确分数保证"无负数、除法结果真分数";canonical + set
    保证不重复;need_parens 保证输出可无歧义还原。
  2. 独立校验路径交叉验证verify.py 走与生成端完全不同的路径(字符串 → 重新解析 → 逐项
    断言),1 万题全量通过,两套代码互相印证。
  3. 判重规则与作业说明逐条对齐:作业中"重复/不重复"的全部例子做成单元测试,全部通过。
  4. 判题端双向测试:全对、全错、混合、等价分数四种场景符合预期;判题时重新独立解析求值,
    与生成端的答案计算路径不同,可发现生成端潜在错误。
  5. 参数边界全覆盖:-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 经验与教训

  1. 需求中"等价"类问题(判重、格式)先形式化定义再编码,否则返工成本极高;
  2. 括号输出与解析必须互为逆过程,才能保证"生成的题目判题一定正确";
  3. 测试要独立于实现(verify.py 走不同路径),才能发现生成端的潜在错误;
  4. 性能优化先 profile 再动手,避免在非瓶颈上浪费精力。

7.3 结对感受与互评

陈明凯:坚持用递归下降实现解析器并严格对齐输出端括号规则,保证了"生成字符串可
无歧义还原成同一棵树",这是判题正确的基础;他写的 verify.py 独立于生成端走了一条不同的
验证路径,帮我们抓出几处边界问题,是质量保障的关键。建议:下次更早共享性能剖析结果,避免
他在优化上重复造轮子。

邓文炜:把作业说明中"重复"的定义推敲得非常透彻,直接避免了"字符串排序+展开
结合律"的错误方案;他"先写单测再写实现"的习惯让判重模块一次通过。建议:可以在结对开始时
就约定统一的命名与注释风格,减少代码复审成本。

共同体会:结对编程最大收益是"设计先行、代码互审"——关键算法(判重、括号规则)先一起
推演清楚再动手,写代码时间反而只占一小部分;自动化测试让两人放心各自修改模块而不怕互相
破坏。最大的教训是:遇到"等价"类需求,一定要先构造形式化定义并写用例,否则返工成本极高。

posted @ 2026-09-22 20:51  kecit  阅读(4)  评论(0)    收藏  举报