结对项目(吴俊达、林炳辉)

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

一、项目基本信息

成员 姓名 学号
成员一 吴俊达 3124004443
成员二 林炳辉 3124004430

GitHub 项目地址:
https://github.com/LoVseele/LoVseele/tree/main/myapp

本项目使用 Python 实现小学四则运算题目的自动生成与自动批改,支持自然数、真分数、带分数与括号。程序一次可以生成一万道互不重复的题目,并把题目、标准答案、批改结果分别写入 Exercises.txtAnswers.txtGrade.txt。除命令行外还附了一个网页界面,用于调参出题、查看题目与批改结果。

前端页面使用 H5 + CSS + JavaScript 实现


二、PSP2.1 时间记录

单位:分钟。

PSP2.1 Personal Software Process Stages 预估耗时 实际耗时
Planning 计划 10 12
· Estimate · 估计这个任务需要多少时间 10 12
Development 开发 260 288
· Analysis · 需求分析(包括学习新技术) 30 35
· Design Spec · 生成设计文档 20 20
· Design Review · 设计复审(和同事审核设计文档) 10 10
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 10 8
· Design · 具体设计 30 30
· Coding · 具体编码 100 120
· Code Review · 代码复审 20 25
· Test · 测试(自我测试,修改代码,提交修改) 40 40
Reporting 报告 30 30
· Test Report · 测试报告 15 15
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 10 10
合计 300 330

三、需求分析与实现约定

3.1 功能需求

程序有两种命令行模式,另外提供一个网页界面。

题目生成模式:

python myapp.py -n 10 -r 10

其中:

  • -n 指定生成数量,省略时默认 10 道。
  • -r 指定数值范围,生成模式下必须提供,否则报错并打印帮助信息。
  • 题目中出现的自然数、真分数的分子与分母、带分数的整数部分都小于范围上界。
  • 每道题包含 1~3 个运算符。
  • 减法不能产生负数,除法结果必须是真分数。
  • 同一次生成的题目按规定去重。

生成结果保存到当前工作目录:

Exercises.txt
Answers.txt

答案批改模式:

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

程序按题号比较答案,输出 Grade.txt。批改模式不需要 -r

网页界面:

python server.py

浏览器打开 http://127.0.0.1:8000/ 即可出题与批改,网页与命令行共用同一套核心算法。

3.2 对需求中歧义的处理

除法结果的约定。 需求把带分数也列进了「真分数」的示例,因此「e1 ÷ e2 的结果应是真分数」存在解释空间。本项目采用较严格的约定:

0 < e1 ÷ e2 < 1

也就是被除数必须小于除数。带分数仍然可以作为操作数出现,也可以作为其他运算的结果。

数值范围的含义。 -r 只限制题目中出现的操作数,不限制中间结果与答案。例如 -r 108 + 9 合法,答案是 17。

难度的约定。 只保证「合法」并不等于题目适合小学生做,早期版本会生成 9 × 7 × 1'3/4 = 110'1/47/9 × 9/5 - 1/7 + 3/8 = 1'177/280 这类远超小学范围的题目。为此约定:每道题要么全是整数、要么全是分数;不使用 0 与 1 作无意义的操作数;答案不超过 、分母不超过 100;真分数的分母只取 2、3、4、5、6、8、9、10、12;分数题最多两步。这些规则只在 -r ≥ 3 时生效,-r 1 这类极端参数下自动关闭,否则一道题也生成不出来。


四、设计与实现过程

4.1 模块划分

模块 主要职责
myapp.py 解析命令行参数,选择生成或批改模式
server.py Web 后端:生成 / 批改 / 下载接口,托管前端页面
web/ 前端界面(原生 HTML / CSS / JS)
src/expression.py 表达式树、求值、规范形式、题面渲染
src/generator.py 操作数采样、表达式构造、约束校验、去重与难度控制
src/parser.py 把题目文本与答案文本解析回表达式树与有理数
src/grader.py 批改、统计对错题号、生成 Grade.txt 内容
src/fileio.py 三个结果文件的读写
tests/test_all.py 41 项自动化测试
tests/demo_cases.py 运行第六节的手工用例并输出表格
tests/benchmark.py 两版实现的性能基准与 cProfile 剖析

核心类只有两个:Number 表示一个非负数值,BinaryOp 表示 e1 op e2,二者都继承自抽象基类 Expr。求值、运算符计数、规范形式、文本渲染四个能力都定义在节点上;生成器负责构造节点,批改器通过解析器把文本还原成同样的节点,两条流程共用同一套分数处理与求值逻辑。

image

4.2 为什么使用表达式树

直接拼接字符串无法检查每一个中间结果,也难以为去重提供依据。

例如 2 × (1 + 3),它的结构是一个乘法节点,左子是数字 2,右子是加法表达式。有了树就可以递归完成:计算左右子表达式、检查当前运算是否合法、统计运算符个数、按优先级输出题目文本、生成去重标识。

4.3 题目生成流程

每道题先随机确定 1~3 个运算符(权重 1 步 40%、2 步 40%、3 步 20%),再把剩余的运算符数量随机分配给左右子树,保证生成的树恰好含有指定个数的运算符。

image

构造完成后做一次自底向上的校验:不合法就整棵树作废重来;题目已经在集合里也重来。若某个运算符个数连续 80 次构造失败,就自动降级到更少的运算符重试,保证小范围参数下也能出题;如果连续失败次数超过阈值(说明题目空间确实用完了,例如 -r 1 却要 10 万道题),就返回已生成的题目并把 exhausted 置位,由上层给出提示,而不是死循环。

实测平均每道题只尝试 1.06 次(-r 100);-r 10 时可选题目少、拒绝率更高,也只有 1.75 次。


五、关键代码与思路说明

5.1 使用 Fraction 精确计算

内部统一用标准库 Fraction 表示数值:

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

输出时按分母与整数部分分类处理,真分数写作 3/5,带分数写作 2'3/8

if value.denominator == 1:
    return str(value.numerator)

numerator, denominator = abs(value.numerator), value.denominator
if numerator < denominator:
    return f"{numerator}/{denominator}"

whole, remainder = divmod(numerator, denominator)
return f"{whole}'{remainder}/{denominator}"

例如 Fraction(19, 8) 输出为 2'3/8

5.2 校验与求值合并成一次遍历

如果先写一个函数判断合法性,再写一个函数求值,同一棵树就要走两遍。这里让校验函数直接返回值,用 None 表示不合法:

def validate_tree(expr, max_value=None):
    if isinstance(expr, Number):
        return expr.value

    left_value = validate_tree(expr.left, max_value)
    right_value = validate_tree(expr.right, max_value)

    if expr.op == "-":
        if left_value < right_value:
            return None                   # 减法不能产生负数
        value = left_value - right_value
    elif expr.op == "÷":
        if right_value == 0 or left_value <= 0 or left_value >= right_value:
            return None                   # 商必须是真分数
        value = left_value / right_value
    else:
        value = left_value + right_value if expr.op == "+" else left_value * right_value

    if max_value is not None and value > max_value:
        return None                       # 难度控制: 答案上限
    return value

这样递归一遍就同时完成了校验与求值,校验通过时的返回值就是答案。它也能拒绝 (3 - 5) + 8 这类题:虽然最终结果为正数,但内部减法已经违反要求。

5.3 按交换律生成去重标识

去重只允许交换加法、乘法的左右子树,不展开分组

def canonical(self):
    if self.op in ("+", "×"):
        first, second = self.left.canonical(), self.right.canonical()
        if second < first:
            first, second = second, first
        return f"({first}{self.op}{second})"
    return f"({self.left.canonical()}{self.op}{self.right.canonical()})"

所有题的标识存入集合,重复的不再计入。因此:

表达式对 判定
23 + 4545 + 23 重复
6 × 88 × 6 重复
3 + (2 + 1)1 + 2 + 3 重复
1 + 2 + 33 + 2 + 1 不重复

最后一行的依据是:1+2+3 等价于 (1+2)+3,根节点的子节点是 {1+2, 3};而 3+2+1 等价于 (3+2)+1,子节点是 {3+2, 1}。只允许交换子节点位置时二者无法互相转化,所以不重复。如果先把加法链展平再排序,这两道题会被误判为重复——这正是我们实现中出错的那一处。

5.4 按优先级渲染括号

为了输出可读的题面,只在必要的位置加括号:

def _needs_parentheses(self, parent_precedence, is_right_child):
    precedence = PRECEDENCE[self.op]
    if precedence < parent_precedence:                 # 如 (1 + 2) × 3
        return True
    return is_right_child and precedence == parent_precedence   # 如 1 - (2 - 3)

子表达式优先级低于父节点,或优先级相同且位于右侧时要加括号,其余情况省略(四则运算满足左结合)。这样输出的题面文本又能被自己的解析器读回原样,是批改功能能正常工作的前提,测试里专门用一条断言守着这个不变量。

5.5 按题号批改

批改器把提交的答案解析成 Fraction 后比较:

expected_value = expr.evaluate()
submitted_text = answer_lines[position].strip()

if submitted_text == "":
    note = "未作答"
else:
    try:
        submitted_value = parse_value(submitted_text)
    except ParseError:
        note = "答案格式不规范"
    else:
        is_correct = submitted_value == expected_value

缺失、空白或无法解析的答案都记为错误,但给出不同的说明;等值分数按相同数值处理,例如 2/41/2 判为同一个答案。

5.6 网页界面

需求允许用图形界面替代命令行,因此两边都做了。出题面板可以设置题目数量、数值范围与运算符个数上限,生成后展示统计、运算符分布、答案类型分布与题目列表,列表下方可以逐题填写答案并即时判定。

image

批改面板可以粘贴题目与答案文本一键批改,按需求规定的格式给出 Correct / Wrong 与题号列表,并列出错题明细。

image

后端只用标准库 http.server 写了一个小路由(/api/generate/api/grade/api/download/api/health)。下载接口的文件名走白名单,静态资源做路径规范化,避免目录穿越。


六、测试与运行结果

6.1 测试环境与命令

实测环境:Windows,Python 3.13,仅使用标准库,自动化测试用 unittest

python tests/test_all.py

41 项自动化测试全部通过,耗时约 1.7 秒,分 10 组:数值格式 3 项、文本解析 4 项、去重规则 5 项、生成约束 8 项、难度控制 7 项、边界参数 4 项、文件输出 1 项、批改 5 项、性能 1 项、命令行 3 项。

6.2 代表性测试用例

下表是 17 个手工用例,结果由 python tests/demo_cases.py 真实运行得到。

# 测试内容 预期结果 实际结果
1 生成 10 道 10 以内的题目 运算符个数 ≤ 3 最大 2 个,通过
2 把题面文本解析回表达式再求值 与答案文件一致 一致,通过
3 遍历全部减法子表达式 没有负数中间结果 通过
4 遍历全部除法子表达式 商都满足 0 < 商 < 1 通过
5 生成 10000 道题目 10000 道且互不重复 唯一题目数 10000,通过
6 23 + 4545 + 23 判为重复 通过
7 3 + (2 + 1)1 + 2 + 3 判为重复 通过
8 1 + 2 + 33 + 2 + 1 判为不重复 通过
9 3/519/8 的输出格式 3/52'3/8 通过
10 1/6 + 1/8 7/24 通过
11 用标准答案批改 全部判对 通过
12 第 2 题改成 999、第 3 题留空 对 8 题,错 2 题(2、3) 通过
13 标准答案 5/4,提交 1'1/4 判对 通过
14 提交 abc 记为格式不规范 通过
15 -r 1 生成 10 道题 不崩溃且互不重复 通过
16 缺少 -r 参数 退出码 2 并打印帮助 通过
17 生成 30 道题后批改自己的答案文件 Wrong: 0 () 通过

6.3 题目与答案展示

python myapp.py -n 10 -r 10 -s 2024

生成的 10 道题:

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

对应的标准答案:

2/17
5/28
2
1'13/18
2/5
15
13
1
1/12
1/3

题目里包含了整数、真分数与括号;真分数与带分数的书写格式分别是 1/121'13/18

6.4 一万道题生成

python myapp.py -n 10000 -r 100

两个文件均为 10000 行,耗时约 0.13 秒。自动化测试还逐题检查了数值范围、运算符个数、中间结果与去重标识,并验证题目输出成文本后能被重新解析出相同答案。

6.5 对错混合批改

把上面 10 道题的标准答案复制一份,手动把第 2 题改成 999、第 3 题留空,执行:

python myapp.py -e Exercises.txt -a Student.txt

得到:

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

这个用例还顺带暴露了一个真实 bug。 第一次运行时结果是 Correct: 1 (1)Wrong: 9 (...):原因是读取答案文件时把空行当作空白跳过,第 3 题的空行消失后,它后面的答案整体前移了一行,于是第 4 题的答案被拿去判第 3 题。修复方式是让读取答案文件时保留空行(空行代表该题未作答),并补了一条命令行级的回归测试:题目与答案各三行、中间一行留空,要求结果恰好是 Correct: 2 (1, 3)Wrong: 1 (2)

6.6 正确性依据与限制

正确性依据主要有四点:

  1. 用独立手段验证。 判断「有没有负数」时测试自己写了一套递归遍历,不复用被测代码的校验函数;判断「数值范围」时直接从渲染出来的题面文本里抽取数字比较。
  2. 守住「题面能被自己读回」这条不变量。 每批题目都会重新解析并求值,要求与答案文件完全一致,这条断言同时覆盖渲染、解析、求值三条链路。
  3. 把需求原文里的例子固化成断言,例如 1/6 + 1/8 = 7/242'3/8 的写法,以及上面三组去重案例。
  4. 跨进程验证。 命令行用例用 subprocess 真正启动程序,检查退出码、输出内容与 Grade.txt 的实际内容。

已知限制:生成与批改共用同一套求值与分数处理代码,所以「用程序自己的答案全部判对」只能说明流程自洽,不能单独证明算法正确,需要靠上面的独立检查和人工案例补充;测试也不可能覆盖所有输入,异常文件与极端参数的测试仍可继续扩充。


七、效能分析与优化

7.1 分析方法

cProfile 对「生成 10000 道题目」做剖析:

python -m cProfile -s tottime myapp.py -n 10000 -r 100

优化前的主要热点(完整结果见 docs/profile.txt):

函数 调用次数 tottime
random._randbelow_with_getrandbits 173,205 0.074 s
build_tree(旧采样实现) 107,633 0.072 s
fractions.Fraction.__new__ 89,808 0.067 s
random.randrange 109,682 0.062 s
validate_tree 103,901 0.059 s
isinstance 305,886 0.048 s

一共 3,675,619 次函数调用。结论很明确:瓶颈不在算法逻辑,而在随机数采样与分数对象构造上——每采一个操作数都要调用 1~3 次 randint(每次又要经过 randrange_randbelow_with_getrandbits 三层),再现场构造一个 Fraction 和一个 Number;而 10000 道题一共只需要约 6 万次采样,同样的动作被反复从零做起。

7.2 优化思路

针对热点做了三处不改变题目分布的优化:

  1. 预构造操作数池。 数值范围较小时,把合法操作数一次性构造成 Number 对象放进列表,采样时只做一次 pool[int(random() * len(pool))]Number 是只读节点,可以被不同题目共享。池的规模是 O(r²),所以只对 r ≤ 200 启用,更大范围退回逐个采样(实测 r=512 时建池要 120 ms,收益只有 50 ms 左右,不划算)。
  2. 缓存数值的文本形式。 Number 增加一个 _text 字段,第一次格式化后缓存。同一个操作数在「规范形式」和「题面渲染」中会被反复格式化,缓存后只算一次。
  3. 减少热路径上的函数调用。 用一次 random() 加权重判断代替 rng.choices()(后者每次多花约 4 µs,而 10000 道题会调用上万次),并把 rng.random 绑定到实例属性上。

同时还对比了两种去重策略:第一版用题面文本去重,第二版改成规范形式去重。文本比较看起来便宜,但会漏判交换律等价的题目。

7.3 测量结果

固定测试条件:数量 1000 / 2000 / 5000 / 10000,范围 100;每次计时循环跑多遍(每遍至少生成 20000 道题)再折算单次耗时,整个基准重复 3 轮取每格最小值;重复题数用固定随机种子单独统计一次。

题目个数 第一版:反复求值 + 文本去重 第二版:校验即求值 + 规范形式去重 第三版:再加操作数池与缓存
1000 0.0209 s(漏判重复 6 道) 0.0214 s 0.0122 s
2000 0.0452 s(漏判重复 26 道) 0.0431 s 0.0241 s
5000 0.1133 s(漏判重复 98 道) 0.1118 s 0.0606 s
10000 0.2319 s(漏判重复 271 道) 0.2356 s 0.1263 s

image

image

  • 第三版相对第二版提速 1.75~1.86 倍,函数调用总数从 3,675,619 次降到 2,078,572 次(减少 43.5%),剖析器记录的时间从 1.051 s 降到 0.578 s。
  • 耗时随规模近似线性增长,10000 道题约 0.13 秒完成,需求「支持一万道题目」有充足余量。
  • 第一版与第二版耗时几乎一样(0.2319 s 与 0.2356 s),说明改用规范形式去重几乎没有成本;但第一版一路都在漏判重复题,直接违反去重要求。也就是说「便宜」的文本去重并没有更快,却把正确性丢掉了。

image

优化后热点已经均匀化:build_tree(0.075 s)、validate_tree(0.057 s)是真正的业务逻辑,其余是解释器与标准库的固有开销,再往下优化就要动数据结构了,性价比不高。

需要说明的是:以上数据只适用于当前机器与测量条件;cProfile 本身有额外开销,因此剖析时间与正常运行时间不能直接比较。


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

项目按以下阶段做增量提交(git commit 的注释与之一致):

  1. 初始化项目,验证 Fraction 的分数与带分数格式。
  2. 实现表达式树与求值。
  3. 实现约束校验与规范形式去重。
  4. 实现命令行生成模式与文件输出。
  5. 实现文本解析(支持 2'3/8)与答案批改。
  6. 补充一万道题生成与自动化测试。
  7. 性能剖析与操作数池优化,加入性能基准脚本。
  8. 加入面向小学的难度控制规则与对应测试。
  9. 加入 server.pyweb/ 网页界面。
  10. 修复答案文件空行导致批改错位的问题,补回归测试。
  11. 精简注释、整理 README 与文档。

运行方式(本机验证通过):

python myapp.py -n 10 -r 10                       # 生成题目
python myapp.py -n 10000 -r 100                   # 一万道题
python myapp.py -e Exercises.txt -a Answers.txt   # 批改
python server.py                                  # 网页界面
python tests/test_all.py                          # 自动化测试
python tests/benchmark.py --profile               # 性能基准(会生成 docs/benchmark.json)

程序只使用 Python 标准库。__pycache__、IDE 配置,以及运行时生成的 Exercises.txtAnswers.txtGrade.txtoutput/ 都由 .gitignore 管理,不入库。


九、项目总结与结对感受

9.1 项目收获

需求里一句简单的描述也可能包含需要反复确认的规则。「不重复」和「结果是真分数」这两条我们都出现过理解偏差,而每次都靠需求原文给出的例子发现自己想错了——遇到语焉不详的需求,应该用原文给的例子反推定义,而不是凭直觉先写。

表达式树是这次设计里回报最高的决定:求值、校验、去重、渲染四件事都围绕同一种结构展开,后面加难度控制、加批改功能时都没有改动这一层。自动化测试则让改动变得放心,性能优化和难度调整之后,靠测试确认原有行为没有被破坏。第六节那个空行导致错位的 bug,也说明手工用例不能只覆盖"正常答案",边界数据要专门设计。

9.2 不足与改进

  • 时间记录不够及时,部分阶段是靠回顾估算的,今后应按任务记录起止时间。
  • 程序会保留一些必要的多余括号(例如 2 × (1/2 + (9 - 3))),可读性还有提升空间;这些括号是为了保持树结构不变,但确实不够美观。
  • 难度规则是从「像不像练习册」出发定的经验值,没有教育学依据,后续可以按年级细分。
  • 前端交互(表格作答、判定着色、结果渲染)目前只能靠手工点击验证,还没有前端测试。

9.3 实际分工

成员 主要承担的工作 参与的复审或测试
林炳辉 项目计划与时间估算、需求分析、设计文档、核心算法与命令行实现(表达式树、约束校验、去重、难度控制) 根据复审意见修改核心代码,说明关键实现思路,验证修改后的功能
吴俊达 文本解析与批改模块、Web 后端与前端界面、自动化测试与性能基准脚本、文档整理 检查实现与需求是否一致,验证正常输入、边界条件与异常输入,整理测试结果与改进建议

9.4 双方感受与互评

林炳辉的感受与对吴俊达的评价:

这次项目让我体会到,把功能写出来只是第一步,还要能把设计思路和模块之间的关系讲清楚。分数计算、表达式去重与文本解析之间有耦合关系,前期如果没有把数据表示和接口统一,后面加功能就会互相影响。

吴俊达总是重视需求与结果的对应关系,负责的测试与前端工作给我提供了另一种检查角度,避免仅凭「运行成功」就认为功能完成。而我写代码时容易只想着「正常输入能不能跑通」,要多多像他学习(笑

吴俊达的感受与对林炳辉的评价:

这次项目让我认识到,测试和复审同样需要理解程序设计。只有先弄清表达式树、规范形式和分数表示,才能判断结果是否满足要求。例如两道题答案相同不代表题目重复,测试必须按照需求规定的交换规则来设计。

林炳辉同学能够拆分需求并化为可实现的具体的功能需求的能力是他的闪光点。

posted @ 2026-09-19 23:12  LoVSeele  阅读(30)  评论(0)    收藏  举报