结对项目

小学四则运算题目生成程序:结对项目博客

项目名称:四则运算题目生成与批改程序
开发语言:Python 3.10 以上
开发方式:结对编程

成员 学号
周 微 3124004189
李建鑫 3124004170

Github项目地址:https://github.com/znnw/ShiZheYunSuangProject

项目入口:main.py

项目概述

本项目实现了一个命令行四则运算题目生成与批改程序,支持:

  • 使用 -n 指定生成题目数量。
  • 使用 -r 指定自然数、分子和分数分母的范围。
  • 生成题目和答案,并写入 Exercises.txtAnswers.txt
  • 每道题运算符数量不超过 3 个。
  • 减法过程不会产生负数。
  • 除法结果严格为真分数。
  • 支持整数、真分数和混数格式,例如 3/52’3/8
  • 支持一次生成 10000 道题。
  • 使用 -e-a 对题目文件与答案文件进行批改,并生成 Grade.txt

项目没有使用第三方依赖,核心计算使用 Python 标准库中的 fractions.Fraction,避免浮点数误差。

项目目录如下:

PythonProject2-SiZheYunSuang/
├── arithmetic_quiz/
│   ├── cli.py
│   ├── expression.py
│   ├── generator.py
│   ├── parser.py
│   ├── grader.py
│   ├── io.py
│   └── __main__.py
├── tests/
│   └── test_arithmetic_quiz.py
├── tools/
│   └── render_profile.py
├── main.py
├── README.md
|——.gitignore

一、实现前 PSP 计划表

在开始编码之前,我们先对需求进行了拆分,并对各模块开发时间进行预估。

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 45
· Estimate · 估计任务需要多少时间 20 25
· Analysis · 需求分析(包括学习新技术) 20 25
· Design Spec · 生成设计文档 40 45
· Design Review · 设计复审 60 70
· Coding Standard · 制定代码规范 80 95
· Design · 具体设计 70 80
· Coding · 具体编码 40 45
· Code Review · 代码复审 60 75
· Test · 测试、修改和提交 30 55
· Test Report · 测试报告 40 50
· Size Measurement · 计算工作量 0 0
· Postmortem & Process Improvement Plan · 事后总结与改进计划 0 0
合计 440 540

最初的估计比较乐观。实际开发中,表达式等价判断和性能测试花的时间明显高于预计。

二、效能分析

2.1 性能改进时间

性能专项分析、修改和复测共花费约 55 分钟,比计划多 25 分钟。

性能测试环境如下:

操作系统:Windows 10
Python:3.10.4
测试规模:10000 道题
题目范围:r = 10
分析工具:cProfile、pstats

2.2 性能改进思路

第一版的想法比较直接:随机生成表达式,然后直接比较题目字符串,或者把表达式字符串排序后去重。这种方法存在两个问题:

  1. 字符串不同不代表表达式不等价,例如 23 + 4545 + 23
  2. 字符串排序容易把不应该合并的题目错误合并,例如 1 + 2 + 33 + 2 + 1

最终采用了以下优化思路:

  1. 使用不可变语法树表示表达式,不在生成过程中反复解析字符串。
  2. +× 节点递归排序左右子树,生成规范化指纹。
  3. -÷ 节点保留左右顺序,避免错误合并结构不同的题目。
  4. 在合并两个子表达式时立即检查减法和除法约束,减少无效结果进入后续流程。
  5. 当减法出现 left < right 时交换左右子树,当除法出现 left > right 时也交换,从而减少纯拒绝采样。
  6. 使用 Fraction 精确计算,避免小数误差导致错误判题。
  7. 生成完成后只遍历一次集合进行去重,生成、渲染和写入分离。
  8. 批改时使用递归下降解析器,不使用 eval,兼顾效率和安全性。

2.3 性能分析图

image

图中柱形表示函数的累计耗时。父函数包含子函数耗时,因此多个函数之间会有重叠。

如果博客平台不支持本地 SVG 图片,可以使用下面的 Mermaid 版本展示同一组核心数据:

xychart-beta title "Generation cumulative time (seconds)" x-axis ["generate", "_random_expr", "_build_constr", "_random_atom", "make_binary", "render"] y-axis "Seconds" 0 --> 0.6 bar [0.503, 0.441, 0.163, 0.148, 0.123, 0.095]
xychart-beta title "Grading cumulative time (seconds)" x-axis ["grade_files", "parse_expr", "parse_exercise", "parse", "_parse_add", "_parse_mul"] y-axis "Seconds" 0 --> 0.7 bar [0.615, 0.522, 0.463, 0.399, 0.395, 0.348]

生成阶段的 profiler 数据:

2542907 function calls in 0.663 seconds

批改阶段的 profiler 数据:

2466470 function calls in 0.621 seconds

2.4 耗时最大的函数

生成阶段累计耗时最高的项目函数:

函数 累计耗时 说明
generate 0.503 秒 控制题目数量、去重和终止条件
_random_expression 0.441 秒 递归构造表达式
_build_constrained 0.163 秒 处理减法与除法的约束
_random_atom 0.148 秒 生成自然数或真分数
make_binary 0.123 秒 创建节点并计算节点值
render 0.095 秒 将语法树转换成题目文本

批改阶段累计耗时最高的项目函数:

函数 累计耗时 说明
grade_files 0.615 秒 批改流程总入口
parse_expression 0.522 秒 解析全部题目与答案
parse_exercise_line 0.463 秒 解析带编号的题目行
_ExpressionParser.parse 0.399 秒 启动递归下降解析
_parse_additive 0.395 秒 解析加减法层级
_parse_multiplicative 0.348 秒 解析乘除法层级

从结果看,生成阶段的主要成本在递归抽样和 Fraction 运算,批改阶段的主要成本在表达式解析。对于 10000 道题,总耗时仍保持在秒级,满足项目规模要求。

复现命令:

python -m cProfile -o generate.prof main.py -n 10000 -r 10
python -m cProfile -o grade.prof main.py -e Exercises.txt -a Answers.txt
python tools\render_profile.py generate.prof grade.prof -o performance_analysis.svg

三、设计与实现过程

3.1 模块划分

文件 职责
expression.py 定义表达式语法树、运算、约束检查和规范化指纹
generator.py 生成满足范围与运算约束的题目
parser.py 将题目行、答案行和表达式字符串解析成语法树
grader.py 比较标准答案并统计正确与错误题号
io.py 统一进行 UTF-8 文件读写和原子写入
cli.py 解析 -n-r-e-a 参数并调度各模块
main.py 命令行入口

当前项目共有 9 个类定义、35 个函数或方法。主要类如下:

作用
NumberNode 表示自然数或分数叶子节点
BinaryNode 表示一个二元运算节点
ExerciseGenerator 负责随机生成、约束处理和去重
NumberedLine 保存题目或答案的编号与正文
_ExpressionParser 递归下降解析表达式
GradingResult 保存正确与错误题号
ExpressionConstraintError 表达式违反数学约束时抛出
GenerationError 无法生成足够题目时抛出
ParseError 输入格式错误时抛出

3.2 模块关系图

flowchart LR CLI[cli.py] --> Generator[ExerciseGenerator] CLI --> Grader[grade_files] CLI --> IO[io.py] Generator --> Expr[expression.py] Grader --> Parser[parser.py] Parser --> Expr Expr --> NumberNode Expr --> BinaryNode Generator --> Exercises[Exercises.txt] Generator --> Answers[Answers.txt] Grader --> Grade[Grade.txt]

3.3 题目生成流程

flowchart TD A[开始生成] --> B[确定目标运算符个数 1 到 3] B --> C[随机拆分左右子表达式运算数] C --> D[递归生成左右子树] D --> E{选择运算符} E -->|减法| F[保证左边不小于右边] E -->|除法| G[保证除数非零且商为真分数] E -->|加法或乘法| H[直接合并] F --> I[创建 BinaryNode] G --> I H --> I I --> J[计算规范化指纹] J --> K{是否重复} K -->|是| B K -->|否| L[加入结果集合] L --> M{数量是否达到 n} M -->|否| B M -->|是| N[渲染并写入文件]

3.4 批改流程

flowchart TD A[读取题目文件和答案文件] --> B[按编号建立答案索引] B --> C[逐行解析题目] C --> D[解析标准答案] D --> E[使用 Fraction 精确比较] E --> F{答案是否一致} F -->|是| G[加入 Correct 列表] F -->|否| H[加入 Wrong 列表] G --> I[生成统计结果] H --> I I --> J[写入 Grade.txt]

3.5 关键设计说明

设计一:使用语法树而不是字符串

题目在程序内部不是字符串,而是 NumberNodeBinaryNode 组成的树。树的节点保存运算符、左右子树和计算值。

这样做有三个好处:

  1. 可以直接检查每个中间节点的值。
  2. 可以递归判断减法是否为负。
  3. 可以对 +× 的左右子树进行规范化排序。

设计二:只对合法的交换操作排序

题目要求说明,加法与乘法的左右操作数可以交换,但 1 + 2 + 33 + 2 + 1 不能视为重复。因此,程序没有直接把全部操作数排序,而是只交换语法树中有交换律的节点。

设计三:约束前移

如果先随机生成再不断丢弃无效表达式,范围较小时会产生大量无效尝试。当前实现会在合并左右子树时立刻处理减法和除法限制:

  • 减法:如果左边小于右边,交换两边。
  • 除法:如果被除数大于除数,交换两边。
  • 除法:如果任一侧为 0 或两侧相等,则放弃这次尝试。

这样比纯粹拒绝采样更容易生成合法题目。

设计四:不使用 eval

解析器由词法分析和递归下降解析组成,支持括号、四种运算符、真分数和混数。这样既避免安全风险,也方便精确控制语法优先级。

四、关键代码说明

4.1 运算约束检查

def make_binary(left: Node, operator: str, right: Node) -> BinaryNode:
    if operator not in OPERATORS:
        raise ExpressionConstraintError(f"不支持的运算符: {operator}")

    if operator == ADD:
        value = left.value + right.value
    elif operator == SUBTRACT:
        value = left.value - right.value
        if value < 0:
            raise ExpressionConstraintError("减法结果不能为负数")
    elif operator == MULTIPLY:
        value = left.value * right.value
    else:
        if right.value == 0:
            raise ExpressionConstraintError("除数不能为 0")
        value = left.value / right.value
        if not 0 < value < 1:
            raise ExpressionConstraintError("除法结果必须是真分数")

    return BinaryNode(left=left, operator=operator, right=right, value=value)

这个函数是所有表达式运算的统一入口。它同时完成三件事:

  1. 计算结果。
  2. 检查减法和除法约束。
  3. 创建包含缓存值的 BinaryNode

将约束放在节点创建阶段,可以保证后续无论怎样拼接子树,已有的非法节点都不会进入最终题目。

4.2 规范化指纹

def canonical_key(node: Node) -> tuple[object, ...]:
    """Return a structural key modulo swapping children of + and ×."""
    if isinstance(node, NumberNode):
        return ("number", node.value.numerator, node.value.denominator)

    left = canonical_key(node.left)
    right = canonical_key(node.right)
    if node.operator in (ADD, MULTIPLY):
        first, second = sorted((left, right))
        return (node.operator, first, second)
    return (node.operator, left, right)

这段代码是重复题判断的核心:

  • NumberNode 使用约分后的分子和分母作为指纹。
  • +× 节点将左右子树指纹排序后再组合。
  • -÷ 节点保留左右子树顺序。

例如:

23 + 45

和:

45 + 23

会得到相同的规范化指纹。

但:

1 + 2 + 3

和:

3 + 2 + 1

的树结构不同,因此不会被认为是同一道题。

4.3 生成器主循环

for attempt in range(attempt_limit):
    target_operators = 1 + attempt % 3
    try:
        expression = self._random_expression(target_operators)
    except GenerationError:
        continue

    fingerprint = canonical_key(expression)
    if fingerprint in fingerprints:
        continue

    fingerprints.add(fingerprint)
    expressions.append(expression)
    if len(expressions) == count:
        return expressions

主循环使用 attempt % 3 轮流生成 1、2、3 个运算符的题目,使题目结构更均衡。每次生成表达式后,程序立即计算规范化指纹,只有新的题目才会加入结果。

4.4 表达式递归下降解析

def _parse_additive(self) -> Node:
    node = self._parse_multiplicative()
    while not self.at_end and self.peek() in (ADD, SUBTRACT, "−"):
        operator = self._consume()
        right = self._parse_multiplicative()
        normalized = SUBTRACT if operator in (SUBTRACT, "−") else ADD
        node = BinaryNode(node, normalized, right, _calculate(node, normalized, right))
    return node


def _parse_multiplicative(self) -> Node:
    node = self._parse_factor()
    while not self.at_end and self.peek() in (MULTIPLY, DIVIDE, "*", "/"):
        operator = self._consume()
        right = self._parse_factor()
        normalized = DIVIDE if operator in (DIVIDE, "/") else MULTIPLY
        node = BinaryNode(node, normalized, right, _calculate(node, normalized, right))
    return node

解析器按优先级分层:

  1. _parse_additive 处理加法和减法。
  2. _parse_multiplicative 处理乘法和除法。
  3. _parse_factor 处理括号和数字。
  4. _parse_number 处理自然数、真分数和混数。

左结合由循环实现,括号由递归处理,因此 1 + 2 × 3(1 + 2) × 3 会得到不同结果。

4.5 分数格式转换

def format_fraction(value: Fraction) -> str:
    if value.denominator == 1:
        return str(value.numerator)

    sign = "-" if value < 0 else ""
    absolute = abs(value)
    whole, remainder = divmod(absolute.numerator, absolute.denominator)
    if whole:
        return f"{sign}{whole}’{remainder}/{absolute.denominator}"
    return f"{sign}{remainder}/{absolute.denominator}"

该函数统一负责输出格式:

  • 5/4 输出为 1’1/4
  • 3/7 仍然输出为 3/7
  • 整数不会附加分母。

4.6 文件原子写入

def atomic_write_lines(path: Path, lines: Iterable[str]) -> None:
    path = Path(path)
    temporary = path.with_name(f".{path.name}.tmp")
    try:
        with temporary.open("w", encoding="utf-8", newline="\n") as handle:
            handle.writelines(lines)
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()

程序先写临时文件,再使用 os.replace 替换目标文件。这样可以降低写入中途失败导致正式文件内容不完整的风险。

五、测试运行

5.1 自动化测试命令

python -m unittest discover -s tests -v

本项目的测试结果:

Ran 14 tests in 0.035s

OK

5.2 测试用例

编号 输入或操作 预期结果 实际结果
T01 Fraction(7, 24)Fraction(19, 8) 格式化 分别得到 7/242’3/8 通过
T02 解析 2’3/8 值为 19/8 通过
T03 解析 1 + 2 × 3 值为 7,乘法优先 通过
T04 解析 (1 + 2) × 3 值为 9,括号优先 通过
T05 比较 (1+2)+33+(2+1) 的指纹 判定为重复 通过
T06 比较 (1+2)+3(2+3)+1 的指纹 判定为不重复 通过
T07 尝试计算 1 - 2 抛出表达式约束异常 通过
T08 尝试计算 2 ÷ 1 因结果不是真分数而拒绝 通过
T09 计算 1 ÷ 2 结果为 1/2 通过
T10 使用固定种子生成 500 道题 数量为 500,指纹全部唯一 通过
T11 检查 500 道题的最大运算符数量 每道题不超过 3 个 通过
T12 检查叶子数值和分母 均小于 r=12 通过
T13 递归检查全部减法节点 每处 left >= right 通过
T14 递归检查全部除法节点 每处结果满足 0 < value < 1 通过
T15 批改 3 道人工题目,其中 2 对 1 错 Correct=(1,2)Wrong=(3,) 通过
T16 打乱题目和答案的编号顺序 按题号匹配,仍然全部正确 通过
T17 运行 python main.py -n 10000 -r 10 生成 10000 道题 通过
T18 将 10000 道题的答案文件重新批改 Correct: 10000Wrong: 0 通过

5.3 命令行生成测试

python main.py -n 10 -r 10

运行后生成:

Exercises.txt
Answers.txt

5.4 命令行批改测试

python main.py -e Exercises.txt -a Answers.txt

得到:

Correct: 10 (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
Wrong: 0 ()

5.5 10000 题稳定性测试

python main.py -n 10000 -r 10
python main.py -e Exercises.txt -a Answers.txt

结果:

题目数量:10000
规范化指纹数量:10000
最大运算符数量:3
批改结果:Correct: 10000
错误数量:0

5.6 为什么可以确认程序正确

我们主要从以下五个方面验证程序:

  1. 数学计算使用 Fraction,没有浮点数舍入误差。
  2. 每次创建二元节点时立即检查减法非负和除法真分数约束。
  3. 对生成结果再次递归遍历,独立验证节点约束,而不是只相信生成器。
  4. 重复题判定专门覆盖了题目给出的两个典型规则,并通过测试固定下来。
  5. 10000 道题全部生成后,再次使用批改器计算答案,结果全部一致,同时规范化指纹也不存在重复。

这些测试不能证明程序对任意恶意输入都绝对正确,但能够覆盖本作业要求中的主要功能、边界条件和规模要求。

六、实现后实际 PSP 表

PSP 阶段 计划时间 实际时间 差值 情况说明
需求分析 20 分钟 25 分钟 +5 分钟 对真分数与混数范围进行了重新确认
总体设计 40 分钟 45 分钟 +5 分钟 增加了解析器与生成器解耦设计
表达式模型 60 分钟 70 分钟 +10 分钟 重点调试规范化指纹
题目生成器 80 分钟 95 分钟 +15 分钟 提高除法与减法生成成功率
解析与批改 70 分钟 80 分钟 +10 分钟 增加混数和编号匹配支持
CLI 与文件读写 40 分钟 45 分钟 +5 分钟 增加原子写入和参数帮助
测试 60 分钟 75 分钟 +15 分钟 从 4 个测试扩充到 14 个
效能分析 30 分钟 55 分钟 +25 分钟 profiler 分析与图形化输出耗时较多
文档与博客 40 分钟 50 分钟 +10 分钟 补充架构图、流程图和测试表
合计 440 分钟 540 分钟 +100 分钟 实际时间约为计划的 1.23 倍

造成偏差的主要原因:

  1. 表达式等价判断比预期更难,不能直接将操作数全部排序。
  2. 为了控制重复率,需要同时考虑树结构、运算符交换律和节点约束。
  3. 性能分析不仅要找热点函数,还要保证优化后不破坏原有语义。
  4. 测试规模扩大后,需要补充更多边界测试。

七、项目小结

7.1 成功之处

  1. 使用语法树而不是字符串作为核心数据结构,程序结构清晰。
  2. 使用 Fraction 完成精确计算,避免分数和小数误差。
  3. 规范化指纹同时满足交换律和题目中特殊的不重复规则。
  4. 生成、解析、批改和文件 IO 分离,便于独立测试。
  5. 10000 道题能在秒级完成,性能和规模满足要求。
  6. 解析器不使用 eval,输入处理更安全。

7.2 不足之处

  1. 当前生成器仍然包含随机尝试,在极小范围下可能无法生成足够多的不重复题目。
  2. 词法分析使用正则表达式,对于正常题目足够,但复杂错误输入的错误提示还可以更细致。
  3. 性能图目前由项目脚本生成 SVG,还没有加入自动化的性能回归阈值。
  4. 程序暂时只提供命令行界面,没有提供图形界面。

7.3 经验与教训

这次项目让我们认识到,需求中的“重复题”不是简单的文本重复,而是表达式树在交换律下的一种等价关系。如果一开始直接从字符串入手,后面会反复修改,甚至难以保证测试通过。

另一个经验是,性能优化之前一定要先测量。我们最初主观认为随机数生成最慢,但 cProfile 显示生成阶段的主要累计时间集中在递归表达式构造和节点合并,批改阶段则集中在表达式解析。先看数据,再决定优化点,比凭感觉修改更可靠。

最后一个教训是,测试用例应该围绕需求逐条建立,而不是只测试“程序能运行”。当测试能够覆盖减法、除法、括号、重复题、编号错位和 10000 题规模时,修改程序会更有信心。

7.4 成员周微感受

这次结对中,我主要负责表达式模型、生成器和性能分析。结对编程最大的感受是,很多自己容易忽略的边界条件会被同伴及时指出。例如,只要看到运算树,我第一反应是递归排序全部操作数,但同伴提醒题目中的 1 + 2 + 33 + 2 + 1 不能判重,这让我们重新审视了交换律和结合结构之间的关系。

在性能分析阶段,同伴建议不要只记录总耗时,而是用 cProfile 找出累计耗时最高的函数。这个建议让我第一次比较系统地完成了从测量、定位到改进的过程。

7.5 成员李建鑫感受

我主要负责解析、批改、测试和文档。通过结对,我发现“能运行的代码”和“能说明为什么正确的代码”是两件事。周微在生成器中将约束放到节点创建阶段,使我在编写递归检查测试时可以明确知道每个节点应该满足什么条件。

我在结对中也提醒同伴不要只关注生成成功,还要测试输出格式和错误输入。两个人的关注点不同,反而让项目覆盖面更完整。

7.6 对彼此闪光点的分享

成员周微的闪光点:

  • 能够快速把字符串问题转化为语法树问题。
  • 遇到性能问题时愿意做实际测量,而不是凭感觉优化。
  • 能主动整理规范化指纹等关键逻辑。

成员李建鑫的闪光点:

  • 对数据结构和算法比较敏感, 测试意识较强,能够想到编号错位、混数解析、错误答案等边界情况。
  • 文档表达清晰,能够把复杂规则整理成模块表和流程图。
  • 在代码评审中敢于提出不同意见,减少了许多隐藏问题。

7.7 对彼此的建议

给周微的建议:

  • 在写核心算法前,可以先写更小的示例测试,再开始大规模编码。
  • 在完成性能优化后,及时记录前后数据,方便后续比较。

给李建鑫的建议:

  • 可以更早参与生成器的设计评审,以便从测试角度发现接口问题。
  • 在文档中可以增加更多失败案例,帮助后续维护者理解边界条件。

7.8 总结

本项目最终完成了题目生成、答案生成、表达式解析、重复题判断和答案批改等要求。结对编程让我们在算法设计、测试覆盖、性能分析和文档表达上互相补充。实际开发时间比计划多,但多出的时间主要投入在重复题规则验证、性能分析和测试扩充上,这些工作让程序从“能够运行”提升到了“能够解释、能够验证、能够继续维护”的状态。

posted @ 2026-09-20 21:26  zhouwww  阅读(13)  评论(0)    收藏  举报