AIGC标识 小学四则运算题目生成与自动批改程序

小学四则运算题目生成与自动批改程序

项目 内容
所属课程 计科24级78班 - 广东工业大学 - 班级博客
作业要求 本次作业要求
作业类型 软件工程第二次作业
完成方式 单人完成
GitHub 项目地址 https://github.com/yyxyxx/youyong/tree/main/PairProject

一、项目基本信息

成员 姓名 学号
成员一 叶浚轩 3124004447

本项目使用 Python 标准库实现小学四则运算题目的自动生成、标准答案计算和答案批改。程序支持自然数、真分数、带分数、括号以及 +、−、×、÷ 四则运算,一次可以生成 10000 道互不重复的题目。

程序提供两个命令行模式:

  • 生成模式:根据 -n 和 -r 生成 Exercises.txt 与 Answers.txt。
  • 批改模式:根据 -e 和 -a 读取题目与答案,生成 Grade.txt。

主要运行命令:

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

本项目只使用 Python 标准库,不依赖第三方包,也不读写参数规定之外的文件。


二、PSP2.1 时间记录

单位:分钟。

PSP2.1 Personal Software Process Stages 预估耗时 实际耗时
Planning 计划 30 20
· Estimate 估计这个任务需要多少时间 30 20
Development 开发 370 355
· Analysis 需求分析 45 50
· Design Spec 生成设计文档 45 40
· Design Review 设计复审 20 20
· Coding Standard 代码规范 15 15
· Design 具体设计 40 35
· Coding 具体编码 150 140
· Code Review 代码复审 40 35
· Test 测试与修改 70 55
Reporting 报告 85 75
· Test Report 测试报告 35 30
· Size Measurement 计算工作量 15 15
· Postmortem & Process Improvement Plan 事后总结和改进计划 35 30
合计 总计 485 450

实际开发过程中,需求分析和代码复审花费的时间比预估略多,主要原因是需要反复确认“交换律查重”“除法结果必须为真分数”等规则的准确含义。编码阶段通过先设计表达式树、再实现生成和批改,减少了后期返工。


三、需求分析与实现约定

3.1 生成模式

python Myapp.py -n 10 -r 10

参数说明:

  • -n 指定生成题目的数量,必须为正整数。
  • -r 指定题面中基础数值的范围,必须为正整数。
  • 自然数、真分数分子与分母、带分数整数部分都小于 r。
  • 每道题包含 1~3 个运算符。
  • 生成的题目和标准答案分别写入当前目录的 Exercises.txt 与 Answers.txt。

3.2 批改模式

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

程序按行读取题目和答案,使用精确分数进行比较,并输出:

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

批改模式不需要 -r 参数,结果写入当前目录的 Grade.txt。

3.3 必须满足的约束

  1. 任意减法的左值必须大于等于右值,因此所有中间结果都不能为负数。
  2. 任意除法的除数不能为 0。
  3. 除法结果必须严格满足 0 < e1 ÷ e2 < 1,也就是必须为真分数。
  4. 每道题的运算符数量不超过 3。
  5. 不能通过有限次交换 +、× 的左右操作数得到重复题目。
  6. 真分数输出为 3/5,带分数输出为 2’3/8。
  7. 程序需要支持一次生成 10000 道题。

3.4 对需求中歧义的处理

除法结果的范围。 需求写明 e1 ÷ e2 的结果应是真分数,因此本项目采用严格判断:除数不能为 0,并且 0 < e1 ÷ e2 < 1。带分数可以作为操作数或其他运算的结果,但不能作为除法的真分数结果。

数值范围的含义。 -r 只限制题目中直接出现的基础数值,不限制中间结果和最终答案。例如 -r 10 时,8 + 9 仍然合法,答案是 17。

重复题目的判断。 去重只允许交换加法和乘法的左右子树,不额外使用结合律或分配律。例如 3 + (2 + 1) 和 1 + 2 + 3 重复,但 1 + 2 + 3 和 3 + 2 + 1 不重复。


四、设计与实现过程

4.1 模块划分

模块 主要职责
Expr 不可变表达式树节点,保存精确值、运算符和左右子树
combine 合并两个子表达式,检查减法和除法约束
build_atom_pool 构造小于 r 的自然数、真分数和带分数
QuestionGenerator 随机生成题目并维护查重集合
canonical_key 按交换律生成表达式规范键
format_fraction 输出自然数、真分数或带分数
format_expression 根据优先级输出带必要括号的题面
_ExpressionParser 将题面文本安全解析为表达式树
parse_grade_inputs 读取并校验题目与答案文件
grade 按题号比较答案并统计对错
run 解析命令行参数并统一处理错误码

项目的核心数据结构为:

Expr
├── value:Fraction 精确数值
├── op:运算符
├── left:左子表达式
└── right:右子表达式

4.2 为什么使用表达式树

如果只拼接字符串,程序只能得到一段文本,很难判断某个中间减法是否为负数,也无法确定某个除法子表达式的商是否为真分数。表达式树保存了完整的计算结构,可以递归完成:

  • 计算每个子表达式的精确值;
  • 检查每个减法和除法节点;
  • 统计运算符数量;
  • 生成交换律规范键;
  • 根据优先级渲染题目;
  • 将题面重新解析后用于批改。

同一个表达式结构同时服务于生成、答案计算和批改,避免了多套逻辑产生不一致。

4.3 题目生成流程

flowchart TD A[读取 n 和 r] --> B[构造基础数值池] B --> C[随机选择 1~3 个运算符] C --> D[递归构造左右子树] D --> E{减法和除法约束是否满足} E -- 否 --> C E -- 是 --> F[计算规范键] F --> G{是否重复} G -- 是 --> C G -- 否 --> H[加入结果集合] H --> I{是否达到 n 道} I -- 否 --> C I -- 是 --> J[写入 Exercises 和 Answers]

约束在每次合并子表达式时立刻执行,而不是等整棵树生成完以后再统一检查。例如减法节点在合并时立即验证 left >= right,除法节点立即验证除数非零以及商为真分数。这样无效分支会尽早终止,减少无效递归。


五、关键代码与思路说明

5.1 使用 Fraction 精确计算

程序全程使用标准库 fractions.Fraction,不把分数转换成浮点数:

result = Fraction(1, 6) + Fraction(1, 8)  # Fraction(7, 24)

输出时根据分母和整数部分分类处理:

def format_fraction(value: Fraction) -> str:
    if value.denominator == 1:
        return str(value.numerator)
    if value.numerator < value.denominator:
        return f"{value.numerator}/{value.denominator}"
    whole, remainder = divmod(value.numerator, value.denominator)
    return f"{whole}’{remainder}/{value.denominator}"

因此 Fraction(19, 8) 会输出为 2’3/8,并与题目规定的书写格式保持一致。

5.2 校验与运算合并

combine 函数在计算节点值的同时检查所有必要约束:

def combine(op: str, left: Expr, right: Expr) -> Expr | None:
    if op == "−":
        if left.value < right.value:
            return None
        value = left.value - right.value
    elif op == "÷":
        if right.value == 0:
            return None
        value = left.value / right.value
        if not (0 < value < 1):
            return None
    # 其他运算符的计算
    return Expr(value=value, op=op, left=left, right=right)

如果某个分支不满足约束,就返回 None 让生成器重新构造,不会把错误表达式写入最终结果。

5.3 按交换律查重

去重函数保留表达式树的层级,只对 + 和 × 节点排序左右子树:

@lru_cache(maxsize=None)
def canonical_key(expression: Expr) -> str:
    if expression.is_atom:
        return f"N:{expression.value.numerator}/{expression.value.denominator}"

    left_key = canonical_key(expression.left)
    right_key = canonical_key(expression.right)
    if expression.op in {"+", "×"} and right_key < left_key:
        left_key, right_key = right_key, left_key
    return f"{expression.op}({left_key},{right_key})"

查重规则对应关系如下:

表达式对 判定
23 + 45 与 45 + 23 重复
6 × 8 与 8 × 6 重复
3 + (2 + 1) 与 1 + 2 + 3 重复
1 + 2 + 3 与 3 + 2 + 1 不重复

最后一项的原因是:1 + 2 + 3 按左结合解析为 (1 + 2) + 3,而 3 + 2 + 1 解析为 (3 + 2) + 1。仅允许交换根节点或子节点的左右位置时,两者无法互相转换。

5.4 按优先级渲染括号

题目输出时只在必要时添加括号:

  • 子表达式优先级低于父节点时添加括号;
  • 右侧子表达式与父节点优先级相同时添加括号;
  • 其他位置利用四则运算的左结合规则省略括号。

这样可以保证输出题面重新解析后得到与原表达式相同的树结构。例如 1 + (2 + 3) 不能被错误地输出为 1 + 2 + 3,否则左结合解析后结构会改变。

5.5 自动批改

批改器将题目重新解析为 Expr,再将答案解析为 Fraction:

for number, (expression, answer) in enumerate(zip(expressions, answers), start=1):
    if expression.value == answer:
        correct.append(number)
    else:
        wrong.append(number)

因为比较的是约分后的 Fraction,所以 2/4 和 1/2 会被视为同一个正确答案。


六、测试与运行结果

6.1 测试环境与命令

实测环境:Windows 64 位,Python 3.12.14,仅使用标准库,测试框架为 unittest。

python -m unittest discover -s tests -v

测试结果:

Ran 22 tests in 0.917s
OK

22 个自动化测试覆盖分数格式化、带分数解析、运算优先级、减法非负、除法真分数、括号输出、交换律查重、随机生成约束、命令行参数、文件输出和批改统计。

6.2 代表性测试用例

编号 测试内容 预期结果 实际结果
1 Fraction(3, 5) 格式化 输出 3/5 通过
2 Fraction(19, 8) 格式化 输出 2’3/8 通过
3 解析 2’3/8 得到 19/8 通过
4 1 + 2 × 3 答案为 7 通过
5 3 ÷ 4 − 1/4 答案为 1/2 通过
6 1/2 − 1/3 答案为 1/6,过程非负 通过
7 2 ÷ 1 拒绝,商不是真分数 通过
8 1 ÷ 0 报告除数不能为 0 通过
9 1 + (2 + 3) 输出时保留必要括号 通过
10 2 + 3 与 3 + 2 判定为重复 通过
11 3 + (2 + 1) 与 1 + 2 + 3 判定为重复 通过
12 1 + 2 + 3 与 3 + 2 + 1 判定为不重复 通过
13 生成 500 道随机题 无重复、无负过程、除法均为真分数 通过
14 -n 20 -r 10 两个文件各 20 行 通过
15 批改一题正确、一题错误 输出正确和错误题号 通过
16 题目与答案行数不一致 返回错误码 1 通过
17 缺少 -r 参数 返回参数错误码 2 并打印帮助 通过

6.3 题目与答案展示

运行:

python Myapp.py -n 10 -r 10

一次实际运行生成的题目如下:

2’5/9 × 5/6 =
1’2/7 + 5’2/7 + (7’2/7 − 5’4/5) =
2’2/5 ÷ 3’3/4 =
8’2/9 − 7’3/7 =
2’5/7 × (8’3/8 + 4’8/9) − 1’8/9 =
2/3 × 4’4/5 =
8’3/4 + (8’3/5 − 2’2/5) =
1/2 × 5’2/3 × 9’6/7 =
5’3/7 − 3/5 × 8’7/8 =
7’4/7 ÷ 9’1/9 =

对应答案如下:

2’7/54
8’2/35
16/25
50/63
34’19/168
3’1/5
14’19/20
27’13/14
29/280
477/574

题面中包含整数、真分数、带分数、括号以及四种运算符,输出格式可直接作为程序的批改输入。

6.4 一万道题生成

python Myapp.py -n 10000 -r 10

实测两个输出文件均有 10000 行。独立校验脚本重新读取生成结果,逐题完成以下检查:

  • 重新解析题面并计算答案;
  • 比较计算结果与 Answers.txt;
  • 统计 canonical_key 是否重复;
  • 检查运算符数量是否不超过 3;
  • 检查所有减法过程是否非负;
  • 检查所有除法是否满足真分数要求。

最终验证结果:

独立校验:10000 题全部满足范围、运算、查重和答案一致性要求。

6.5 正确性依据与限制

正确性依据主要包括:

  1. 使用精确分数计算,不存在浮点误差。
  2. 约束在表达式树合并时立即执行,错误分支不会进入结果集合。
  3. 查重只使用需求允许的交换规则,不额外引入结合律或分配律。
  4. 每批题目都可以重新解析并求值,题面、解析器、答案计算形成闭环验证。
  5. 自动化测试覆盖正常路径、边界路径和参数错误路径。

已知限制:极端小的 r 可能无法生成指定数量的不重复题目。程序会检测这种情况并报告错误,不会进入无限循环。当前版本重点满足课程功能要求,没有额外加入面向不同年级的难度模型。


七、效能分析与优化

7.1 分析方法

使用以下命令进行性能采样:

python -m cProfile -s cumulative Myapp.py -n 10000 -r 10

同时对 10、100、1000、10000 道题分别运行 5 次,记录“生成题目并写入文件”的平均耗时。

题目数量 平均总耗时(秒)
10 0.001929
100 0.006597
1000 0.049816
10000 0.598650

10000 题生成性能

10000 道题完整生成约 0.60 秒,峰值内存约 13.44 MB,满足一次生成一万道题的要求。

7.2 cProfile 热点

函数 累计耗时(秒) 说明
run 1.485 完整命令行流程
QuestionGenerator.generate 1.225 随机生成、查重和维护集合
_build 0.817 递归构造表达式树
combine 0.303 精确计算与约束检查
random.choice 0.252 随机选择基础数值和运算符
write_questions 0.238 拼接并写出两个结果文件

cProfile 本身会带来额外开销,因此表中的时间不能与正常运行时间直接比较,只用于判断热点函数。

7.3 优化思路

  1. 约束前移。 最初先生成整棵表达式再校验,不合法的减法和除法会继续递归。改进后将约束放入 combine,不合法分支立即终止。
  2. 规范键缓存。 使用 lru_cache 缓存 canonical_key,避免同一个子树在查重、格式化和测试过程中重复计算。
  3. 哈希集合查重。 每道题的规范键存入 set,平均查找复杂度为 O(1)。
  4. 批量拼接输出。 先构造完整字符串,再用一次文件写入完成输出,避免 10000 次小规模写盘。

7.4 批改性能

对 10000 道题及正确答案进行批改,核心答案比较约 0.0087 秒;包含文本读取、表达式解析和结果写入的总耗时约 0.73 秒。主要成本是重新解析题面,而不是分数比较。


八、源代码管理与运行说明

项目按照功能阶段进行提交,建议使用以下提交信息:

  1. feat: 实现四则运算题目生成与自动批改
  2. test: 补充分数、查重和命令行测试
  3. docs: 补充设计、性能与测试文档
  4. docs: 完成第二次软件工程作业博客

运行方式:

cd PairProject

# 生成 10 道范围小于 10 的题目
python Myapp.py -n 10 -r 10

# 生成 10000 道题目
python Myapp.py -n 10000 -r 10

# 批改题目
python Myapp.py -e Exercises.txt -a Answers.txt

# 执行自动化测试
python -m unittest discover -s tests -v

项目文件:

PairProject/
├── Myapp.py
├── main.py
├── README.md
├── PSP.md
├── requirements.txt
├── pyproject.toml
├── docs/
│   ├── DESIGN.md
│   ├── PERFORMANCE.md
│   ├── TEST_REPORT.md
│   ├── performance.png
│   └── performance.svg
└── tests/
    └── test_main.py

程序只使用 Python 标准库。__pycache__、测试缓存和运行时生成的 Exercises.txt、Answers.txt、Grade.txt 不提交到仓库。


九、项目总结与个人感受

9.1 项目收获

这次作业让我认识到,软件工程中的需求描述必须被逐句转换成可验证的规则。例如“除法结果应是真分数”不能只理解为除数不能为 0,还必须判断最终商是否严格小于 1;“题目不能重复”也不能简单地比较答案或字符串,而要按照题目允许的交换规则处理。

表达式树是本次设计中最关键的决定。生成、求值、校验、查重、括号渲染和批改都围绕同一种数据结构展开。后续增加测试或修改输出格式时,不需要重写核心算法,只需要在对应模块上扩展。

自动化测试也让我能够放心地调整实现。尤其是在修正子表达式运算符计数和批量批改路径时,单元测试可以快速确认原有功能没有被破坏。

9.2 不足与改进

  • PSP 中的部分实际耗时是完成阶段后回顾估算的,后续应按开始和结束时间及时记录。
  • 程序会保留一些语义上必要、但阅读体验一般的括号,后续可以优化为更接近人工书写习惯的渲染方式。
  • 当前没有实现图形界面,只完成题目允许的命令行版本。
  • 极端参数下的题目空间预测还可以进一步优化,例如在 r 很小时直接计算理论上限,而不是通过多次尝试后报错。
  • 可以继续增加更多真实输入文件的兼容性测试,以及针对解析器异常输入的回归测试。

9.3 实际分工

成员 主要承担的工作 参与的复审或测试
叶浚轩 独立完成需求分析、表达式树设计、题目生成、查重、分数格式化、文本解析、自动批改、命令行入口、测试和全部文档 完成代码审查、边界检查、10000 道题独立校验和性能采样

9.4 个人感受

本次作业采用单人完成的方式。最大的挑战不是实现四则运算,而是消除需求中的歧义,并把自然语言规则转换为稳定的数据结构与测试断言。通过先确定表达式树和精确分数模型,再围绕它实现生成、渲染和批改,最终避免了多套逻辑互相不一致的问题。

后续如果再完成类似任务,我会更早建立测试用例表,并在编码前把每条需求对应到至少一个可自动验证的测试,减少后期返工。

posted @ 2026-09-21 23:07  是轩轩呀  阅读(13)  评论(0)    收藏  举报