第三周结对项目

成员一 车俊贤 / 学号:3124004048
成员二 钟志浩 / 学号:3124004076
Github 项目地址 https://github.com/dredgeship/dredgeship/tree/main/3124004048/assignment2
开发环境 Windows 11 + Python 3.10.2 + VS Code
作业要求 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694
作业的目标 结对实现一个自动生成小学四则运算题目的命令行程序

1. PSP 表格

PSP 阶段 预估耗时 实际耗时 偏差说明
计划 Planning 30 25 需求较清晰,计划略快
估计 Estimate 20 15 对规模估计偏保守
需求分析 Analysis 40 50 真分数/带分数格式与判重规则反复讨论,比预期久
设计文档 Design Spec 30 25
设计复审 Design Review 20 15
代码规范 Coding Standard 15 10 直接采用 PEP8 + 类型注解
具体设计 Design 45 40
具体编码 Coding 180 210 判重规则与括号渲染返工一次
代码复审 Code Review 40 35
测试 Test 90 100 追加了边界取值(r=1、r=3)用例
测试报告 Test Report 40 45
计算工作量 Size Measurement 15 10
事后总结 Postmortem 40 50
合计 605 630 整体偏差约 4%

2. 效能分析

2.1 优化投入时间

性能分析与优化共花费约 45 分钟(定位热点 15 分钟、改造 20 分钟、复测 10 分钟),

2.2 改进思路

第一版实现是「每次需要操作数时即时随机生成」:

  1. 每个叶子都要调用 random_number():一次 rng.random() 判定类型 + 多次 randrange() + 互质拒绝采样(最多 12 次 gcd);
  2. 每次选运算符都调用 random.Random.choices(),内部要计算累积权重表;
  3. leaf() 中对已经是 Fraction 的值再包一层 Fraction(),触发多余的归一化(含 gcd 与新的对象分配)。

cProfile 的统计印证了猜测(python tools/profile_gen.py,生成 10000 道题、-r 10):

优化前:3914528 次函数调用,1.130 s
   ncalls  tottime  filename:lineno(function)
   174624    0.119  fractions.py:62(__new__)        #Fraction反复构造
   166128    0.092  random.py:292(randrange)        # 取数时的多次随机
    37616    0.079  random.py:506(choices)          # 运算符抽样
86560/11328  0.069  generator.py:73(_build)
    58620    0.042  fraction.py:96(random_number)

三处针对性改造:

  1. 数值候选集预取number_pool(r) 在生成器初始化时把该范围内所有合法数值(自然数 + 最简真分数 + 带分数)一次性穷举完毕,之后每次取数只需一次 rng.choice(),并且复用同一个 Fraction 对象;
  2. 运算符抽样袋:把权重 0.3/0.3/0.2/0.2 展开成含 10 个元素的列表,用 rng.choice() 代替 choices()
  3. leaf() 快速路径:传入值已是 Fraction 时不再重新归一化。

2.3 优化结果

指标 优化前 优化后
cProfile 内部计时(10000 道) 1.130 s 0.623 s
函数调用次数 3 914 528 2 116 232
端到端 -n 10000 -r 10(含启动与写文件) 1.39 s 1.11 s

优化后耗时最高的函数(docs/perf_top_functions.png,由 tools/profile_gen.py --png 生成):

函数 tottime 说明
generator.py:_build 54 ms 递归构造表达式树并即时校验约束,是生成主循环
random.py:_randbelow_with_getrandbits 53 ms 随机数底层实现,已被压缩到最少调用次数
expression.py:Expr.__init__ 43 ms 冻结数据类的构造开销
fractions.py:__hash__ / builtins.pow 34 ms 规范化键进入判重集合时计算 Fraction 哈希

性能分析图

perf_top_functions

结论:一万道题的生成稳定在 1 秒量级,满足「程序应能支持一万道题目的生成」的要求,
剩余开销主要在随机数与对象构造,进一步优化收益有限,故停止优化。


3. 设计实现过程

3.1 代码组织

按「数值 → 表达式 → 解析 → 生成 → 判题 → 命令行」六层组织,每层只依赖下层:

模块 主要类 / 函数 职责
arith/fraction.py format_numberparse_numberrandom_numbernumber_pool 自然数 / 真分数 / 带分数的格式化、解析、随机取值
arith/expression.py Exprleafbuildapplycanonical_key 表达式树:构造即求值、最小括号渲染、规范化判重键
arith/parser.py tokenizeparse_expressionevaluateExpressionError 词法分析 + 递归下降解析,供判题与自测使用
arith/generator.py ExerciseGenerator._build / _combine / generate 带约束随机出题与去重
arith/grader.py grade 重新求解题目、比对答案、输出 Grade.txt
arith/cli.py build_parserrun_generaterun_grademain 参数解析与两种模式编排
Myapp.py main 程序入口

依赖关系:

cli ──> generator ──> expression ──> fraction
 │                                    ▲
 ├───> grader ──> parser ─────────────┘
 └───> grader ──> fraction

3.2 关键设计决策

  1. 统一用 Fraction 表示数值:彻底避免浮点误差,1/6 + 1/8 精确等于 7/24
  2. 构造即求值build() 在创建节点时就把结果算出来,因此「不出现负数」「除法结果为真分数」可以在生成过程中自底向上即时判定,不必先生成再整体丢弃;
  3. 括号内最小化:左孩子优先级更低才加括号,右孩子优先级更低或相等都要加括号(a − (b − c) 不能写成 a − b − c);
  4. 判重键:对 +× 递归排序左右子树的规范化键,恰好对应需求中「有限次交换 + 和 × 左右表达式」这一等价关系;÷ 保持原序。因此 23 + 4545 + 233 + (2 + 1)1 + 2 + 3 被判为重复,而 1 + 2 + 3(即 (1+2)+3)与 3 + 2 + 1(即 (3+2)+1)不重复;
  5. 去重与容量generate() 用集合保存已用键,连续失败超过阈值即停止,并在 -r 过小(如 -r 1)时给出「最多只能生成 N 道不重复题目」的警告,而不是死循环。

3.3 生成流程

flowchart TD A[开始: -n count -r r] --> B[预取数值候选集 number_pool] B --> C{已生成数量 < count?} C -- 否 --> H[写 Exercises.txt / Answers.txt] C -- 是 --> D[随机运算符个数 ops = 1~3] D --> E[_build: 随机拆分左右子树 + 随机运算符] E --> F[_combine: 减法左>=右? 除法结果为真分数?] F -- 不满足 --> E F -- 满足 --> G{规范化键是否已存在?} G -- 是 --> C G -- 否 --> I[加入结果集] --> C H --> J[结束]

4. 代码说明

4.1 判重:规范化键(arith/expression.py

def canonical_key(expr: Expr) -> Tuple:
    """计算表达式的规范化键(判重依据)。"""
    if expr.is_leaf:
        return ("v", expr.value)
    if expr.op in COMMUTATIVE_OPERATORS:          # + 与 × 可交换
        left_key = canonical_key(expr.left)
        right_key = canonical_key(expr.right)
        # 对左右子树的键排序,等价于「有限次交换左右表达式」
        return (expr.op, (left_key, right_key) if left_key <= right_key else (right_key, left_key))
    return (expr.op, canonical_key(expr.left), canonical_key(expr.right))  # − 与 ÷ 不可交换

思路:把表达式树规约成一个可哈希的嵌套元组,同一等价类的题目一定得到同一个元组,
于是判重退化为集合查表,复杂度 O(题目数 × 树规模)。

4.2 约束:构造即校验(arith/generator.py

def _combine(self, op: str, left: Expr, right: Expr) -> Optional[Expr]:
    """在满足约束的前提下合并两个子表达式,不满足返回 None。"""
    if op == MINUS and left.value < right.value:
        return None                       # 需求 3:计算过程不能产生负数
    if op == DIVIDE:
        if left.value <= 0 or right.value <= 0:
            return None                   # 除数、被除数均需为正,且结果不为 0
        if left.value >= right.value:
            return None                   # 需求 4:除法结果必须是真分数(< 1)
    return build(op, left, right)

_build() 在节点内最多重试 retry 次,仍失败则把失败上报给父节点重新拆分,
避免在顶层反复丢弃整棵树。

4.3 渲染:最小必要括号(arith/expression.py

def _render(self, symbols, parent_precedence, is_right_child) -> str:
    if self.is_leaf:
        return format_number(self.value)
    precedence = PRECEDENCE[self.op]
    # 左孩子优先级更低才加括号;右孩子优先级更低或相等都要加括号(左结合语义)
    need_parenthesis = parent_precedence is not None and (
        precedence < parent_precedence
        or (precedence == parent_precedence and is_right_child)
    )
    text = "{0} {1} {2}".format(
        self.left._render(symbols, precedence, False),
        symbols[self.op],
        self.right._render(symbols, precedence, True),
    )
    return f"({text})" if need_parenthesis else text

4.4 解析:递归下降(arith/parser.py

def _parse_expression(self) -> Expr:          # expr := term (('+'|'−') term)*
    node = self._parse_term()
    while True:
        op = self._accept_operator(PLUS + MINUS)
        if op is None:
            return node
        node = build(op, node, self._parse_term())   # 左结合:不断向左合并

def _parse_term(self) -> Expr:                # term := factor (('×'|'÷') factor)*
    ...

用「运算符优先级分两层 + 循环左合并」同时解决优先级与左结合问题,
判题时直接复用该解析器重新求解题目,保证出题端与判题端口径完全一致。

4.5 判题(arith/grader.py

expected = _expected_answer(exercises[index])   # 重新解析题目求解
given = _given_answer(answers[index])           # 解析用户答案为 Fraction
if expected is not None and given is not None and expected == given:
    correct.append(number)

按值比较而非字符串比较,因此 2'3/819/8 视为同一答案;
无法解析的行一律记为错误,保证编号统计不缺失。


5. 测试运行

5.1 单元测试

python -m unittest discover -s tests    # 55 个用例全部通过

5.2 测试用例(节选 12 个)

编号 用例 输入 期望 覆盖点
1 test_mixed_number format_number(19/8) 2'3/8 带分数输出格式
2 test_fraction_arithmetic_is_exact 1/6 + 1/8 7/24 分数运算无精度损失
3 test_parse_full_width_separator 2’3/8 19/8 全角分隔符容错
4 test_parse_invalid 1/0'1/2 ValueError 非法输入处理
5 test_parenthesis_needed_on_right_same_precedence 9 − (5 − 2) 保留括号 左结合语义
6 test_addition_commutative 23 + 45 vs 45 + 23 判重键相同 需求 6
7 test_associative_plus_is_duplicate 3 + (2 + 1) vs 1 + 2 + 3 判重键相同 需求 6
8 test_different_order_is_not_duplicate 1 + 2 + 3 vs 3 + 2 + 1 判重键不同 需求 6 反例
9 test_left_associativity 10 − 3 − 2 / 10 − (3 − 2) 5 / 9 解析器结合性
10 test_no_negative_and_proper_division 随机 500 道题 0 处违规 需求 3、4、5
11 test_range_one_does_not_crash -r 1 正常退出且题目合法 边界取值
12 test_mixed_correct_and_wrong 5 题 1 错 Correct: 4 (1, 3, 4, 5) / Wrong: 1 (2) 需求 9
13 test_equivalent_answer_formats 答案写 19/8 判对 答案等价形式
14 test_missing_range_reports_error -n 10 退出码 2 + 帮助信息 需求 2

5.3 为什么可以确定程序是正确的

  1. 等价类覆盖:数值格式化 / 解析、括号渲染、判重键、解析器、生成器约束、判题、命令行共 55 个用例全部通过,覆盖正常、边界(-r 1-r 3)、异常(除零、非法字符、参数缺失)三类输入;
  2. 交叉验证test_rendered_expression_is_parseabletools/verify.py独立的解析器重新求解生成器输出的题目文本,若括号规则或求值有错,两者结果必然不一致——这是最有说服力的一致性检查;
  3. 大规模校验python tools/verify.py -n 10000 -r 10 对一万道题逐条核对「运算符 ≤ 3、无负数、除法为真分数、数值在范围内、题目不重复、答案一致」,结果为 0 处违规;
  4. 端到端回归test_generate_then_grade_round_trip 先生成 20 道题再判自己的答案,得到 Correct: 20
python tools/verify.py -n 10000 -r 10
# [通过] 运算符个数 <= 3 / 无负数 / 除法为真分数 / 数值在范围内:0 处违规
# [通过] 题目互不重复:重复 0 道
# [通过] 答案与重新求解结果一致:0 处不一致
# 结论:全部通过

6. 项目小结

6.1 成败得失

  • :模块化分层让「出题」与「判题」共用同一套数值与解析代码,需求 9 的实现几乎免费;
  • :把约束检查放在构造阶段(构造即求值),比「生成后过滤」快得多,也让代码更易测;
  • :初期对判重规则理解不到位,第一版把 1 + 2 + 33 + 2 + 1 判成重复,
    返工后才明确「只对 + 与 × 的左右子树排序,不做结合律展开」;
  • 教训:配对编程时先花 20 分钟把易歧义的需求(真分数定义、判重定义)写成测试用例,
    比事后返工便宜得多。

6.2 结对感受(车俊贤)

与钟志浩结对最大的收获是「领航员视角」:我在写生成器时容易陷入细节,
钟志浩持续追问边界条件(-r 1、除数为 0、答案写成 19/8),直接促成了十几个边界用例。
钟志浩的闪光点:对需求文字的咬文嚼字非常到位,判重规则的最终定义就是他推导出来的。
建议:复审时可以先把结论说在前面,我更容易跟上节奏。

6.3 结对感受(钟志浩)

车俊贤的实现速度快、代码整洁,模块划分和类型注解让后续测试非常好写。
车俊贤的闪光点:性能优化的思路很清晰,用 cProfile 定位热点后一下就把一万道题的生成压到 1 秒内。
建议:关键算法(如规范化键)可以多写几行注释或画一张图,方便我复审时快速理解。


posted @ 2026-09-20 21:52  zzhihao  阅读(12)  评论(0)    收藏  举报