第三周结对项目
| 成员一 | 车俊贤 / 学号: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 改进思路
第一版实现是「每次需要操作数时即时随机生成」:
- 每个叶子都要调用
random_number():一次rng.random()判定类型 + 多次randrange()+ 互质拒绝采样(最多 12 次gcd); - 每次选运算符都调用
random.Random.choices(),内部要计算累积权重表; 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)
三处针对性改造:
- 数值候选集预取:
number_pool(r)在生成器初始化时把该范围内所有合法数值(自然数 + 最简真分数 + 带分数)一次性穷举完毕,之后每次取数只需一次rng.choice(),并且复用同一个Fraction对象; - 运算符抽样袋:把权重 0.3/0.3/0.2/0.2 展开成含 10 个元素的列表,用
rng.choice()代替choices(); 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 哈希 |
性能分析图

结论:一万道题的生成稳定在 1 秒量级,满足「程序应能支持一万道题目的生成」的要求,
剩余开销主要在随机数与对象构造,进一步优化收益有限,故停止优化。
3. 设计实现过程
3.1 代码组织
按「数值 → 表达式 → 解析 → 生成 → 判题 → 命令行」六层组织,每层只依赖下层:
| 模块 | 主要类 / 函数 | 职责 |
|---|---|---|
arith/fraction.py |
format_number、parse_number、random_number、number_pool |
自然数 / 真分数 / 带分数的格式化、解析、随机取值 |
arith/expression.py |
Expr、leaf、build、apply、canonical_key |
表达式树:构造即求值、最小括号渲染、规范化判重键 |
arith/parser.py |
tokenize、parse_expression、evaluate、ExpressionError |
词法分析 + 递归下降解析,供判题与自测使用 |
arith/generator.py |
ExerciseGenerator._build / _combine / generate |
带约束随机出题与去重 |
arith/grader.py |
grade |
重新求解题目、比对答案、输出 Grade.txt |
arith/cli.py |
build_parser、run_generate、run_grade、main |
参数解析与两种模式编排 |
Myapp.py |
main |
程序入口 |
依赖关系:
cli ──> generator ──> expression ──> fraction
│ ▲
├───> grader ──> parser ─────────────┘
└───> grader ──> fraction
3.2 关键设计决策
- 统一用
Fraction表示数值:彻底避免浮点误差,1/6 + 1/8精确等于7/24; - 构造即求值:
build()在创建节点时就把结果算出来,因此「不出现负数」「除法结果为真分数」可以在生成过程中自底向上即时判定,不必先生成再整体丢弃; - 括号内最小化:左孩子优先级更低才加括号,右孩子优先级更低或相等都要加括号(
a − (b − c)不能写成a − b − c); - 判重键:对
+与×递归排序左右子树的规范化键,恰好对应需求中「有限次交换 + 和 × 左右表达式」这一等价关系;−与÷保持原序。因此23 + 45与45 + 23、3 + (2 + 1)与1 + 2 + 3被判为重复,而1 + 2 + 3(即(1+2)+3)与3 + 2 + 1(即(3+2)+1)不重复; - 去重与容量:
generate()用集合保存已用键,连续失败超过阈值即停止,并在-r过小(如-r 1)时给出「最多只能生成 N 道不重复题目」的警告,而不是死循环。
3.3 生成流程
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/8 与 19/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 为什么可以确定程序是正确的
- 等价类覆盖:数值格式化 / 解析、括号渲染、判重键、解析器、生成器约束、判题、命令行共 55 个用例全部通过,覆盖正常、边界(
-r 1、-r 3)、异常(除零、非法字符、参数缺失)三类输入; - 交叉验证:
test_rendered_expression_is_parseable与tools/verify.py用独立的解析器重新求解生成器输出的题目文本,若括号规则或求值有错,两者结果必然不一致——这是最有说服力的一致性检查; - 大规模校验:
python tools/verify.py -n 10000 -r 10对一万道题逐条核对「运算符 ≤ 3、无负数、除法为真分数、数值在范围内、题目不重复、答案一致」,结果为 0 处违规; - 端到端回归:
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 + 3与3 + 2 + 1判成重复,
返工后才明确「只对 + 与 × 的左右子树排序,不做结合律展开」; - 教训:配对编程时先花 20 分钟把易歧义的需求(真分数定义、判重定义)写成测试用例,
比事后返工便宜得多。
6.2 结对感受(车俊贤)
与钟志浩结对最大的收获是「领航员视角」:我在写生成器时容易陷入细节,
钟志浩持续追问边界条件(-r 1、除数为 0、答案写成 19/8),直接促成了十几个边界用例。
钟志浩的闪光点:对需求文字的咬文嚼字非常到位,判重规则的最终定义就是他推导出来的。
建议:复审时可以先把结论说在前面,我更容易跟上节奏。
6.3 结对感受(钟志浩)
车俊贤的实现速度快、代码整洁,模块划分和类型注解让后续测试非常好写。
车俊贤的闪光点:性能优化的思路很清晰,用 cProfile 定位热点后一下就把一万道题的生成压到 1 秒内。
建议:关键算法(如规范化键)可以多写几行注释或画一张图,方便我复审时快速理解。
浙公网安备 33010602011771号