结对作业:基于 Python 的小学四则运算题目生成与自动批改程序

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

一、项目基本信息

成员 姓名 学号
成员一 陈泽翔 3124004161
成员二 吕灿斌 3124004176

GitHub项目地址:
https://github.com/G1ngk020060530/ArithmeticGenerator

本项目使用Python实现小学四则运算题目的自动生成与批改,支持自然数、分数、带分数以及括号。程序能够生成一万道题目,并将题目、标准答案和批改结果分别保存到文本文件。

开发过程中使用了AI辅助提供代码示例、排查问题和整理文档,相关功能通过本地运行和自动化测试进行验证。

二、PSP2.1时间记录

单位:分钟。

PSP2.1 Personal Software Process Stages 预估耗时 实际耗时
Planning 计划 20 14
· Estimate · 估计这个任务需要多少时间 20 14
Development 开发 590 676
· Analysis · 需求分析(包括学习新技术) 60 62
· Design Spec · 生成设计文档 40 25
· Design Review · 设计复审 20 14
· Coding Standard · 代码规范 20 13
· Design · 具体设计 50 76
· Coding · 具体编码 240 334
· Code Review · 代码复审 40 33
· Test · 测试(自我测试,修改代码,提交修改) 120 119
Reporting 报告 110 102
· Test Report · 测试报告 40 36
· Size Measurement · 计算工作量 20 19
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 50 47
合计 720 792

预估列采用项目讨论初期给出的参考估算,合计720分钟。实际列根据开发过程回顾估算,合计792分钟。

性能分析与优化相关工作约耗时94分钟,其中部分已经包含在编码、测试等阶段,部分属于额外投入。

从各阶段看,具体设计和编码耗时超过预估。表达式结构、去重规则、异常输入处理和自动批改比最初设想复杂,说明前期对实现细节的估计还不够充分。

三、需求分析与实现约定

3.1 功能需求

程序提供两种运行模式。

题目生成模式:

python main.py -n 10 -r 10

其中:

  • -n指定生成数量,省略时默认生成10道。
  • -r指定数值范围,生成模式下必须提供。
  • 题目中的数字及非整数的分母均小于范围上界。
  • 每道题包含1~3个运算符。
  • 减法中间结果不能为负数。
  • 除法不能除以零,商应符合真分数要求。
  • 同一次生成的题目按照规定去重。

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

Exercises.txt
Answers.txt

答案批改模式:

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

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

3.2 对题目中歧义的处理

题目将带分数也列入“真分数”示例,因此对除法结果的限制存在解释空间。本项目采用较严格的实现约定:

0 < 除法结果 < 1

带分数仍然可以作为操作数或其他运算的结果。该约定已在README中说明,若课程要求采用其他解释,需要对应调整校验规则。

数值范围只限制题目中的操作数,不限制中间结果和最终答案。例如,范围为10时,8 + 5合法,答案可以为13。

四、设计与实现过程

4.1 模块划分

模块 主要职责
main.py 解析命令行参数,选择生成或批改模式
numbers.py 分数、带分数的解析与格式化
expression.py 表达式树、求值、合法性检查和去重标识
generator.py 随机生成题目,处理小范围和重试上限
parser.py 将题目文本解析为表达式树
grader.py 读取编号文件,比较答案并统计
tests/ 自动化测试
benchmark.py 测量生成耗时
plot_benchmark.py 根据测量报告生成性能图

核心类包括:

  • Number:表示一个非负数值。
  • Binary:表示一个运算符及其左右子表达式。
  • ExpressionParser:负责解析运算优先级与括号。

生成器构造NumberBinary对象,批改器则通过解析器还原这些对象,两条流程共用分数处理和表达式求值逻辑。

4.2 为什么使用表达式树

直接拼接字符串不方便检查每个中间结果,也不方便按照交换律去重。

例如:

(1 + 2) × 3

其结构为一个乘法节点,左侧是加法表达式,右侧是数字3。通过树结构可以递归完成:

  1. 计算左右子表达式。
  2. 检查当前运算是否合法。
  3. 统计运算符数量。
  4. 输出保持计算顺序的题目文本。
  5. 生成结构化去重标识。

4.3 题目生成流程

每道题先随机确定1~3个运算符,再将剩余运算符数量分配给左右子树。

flowchart TD A["读取数量和范围"] --> B{"范围是否为1"} B -->|是| C["枚举只使用0的合法结构"] C --> D{"可用数量是否足够"} D -->|否| E["报错并结束"] D -->|是| F["抽取所需题目"] B -->|否| G["随机构造表达式树"] G --> H{"计算过程是否合法"} H -->|否| K{"是否达到尝试上限"} H -->|是| I{"去重标识是否已存在"} I -->|是| K I -->|否| J["保存题目和去重标识"] J --> L{"是否达到目标数量"} L -->|否| K K -->|否| G K -->|是| E L -->|是| M["写入题目和答案文件"] F --> M

范围为1时只能使用数字0,合法结构数量有限,因此单独枚举。其他范围采用有上限的随机重试,避免因重复题过多而无限循环。达到随机尝试上限不代表已经穷尽全部可能题目。

五、关键代码与思路说明

5.1 使用Fraction精确计算

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

from fractions import Fraction

result = Fraction(1, 6) + Fraction(1, 8)

结果为精确的7/24,避免用浮点近似值判断答案。

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

if denominator == 1:
    return str(numerator)

if numerator < denominator:
    return f"{numerator}/{denominator}"

whole = numerator // denominator
remainder = numerator % denominator
return f"{whole}’{remainder}/{denominator}"

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

5.2 检查每一步运算是否合法

求值时先递归计算左右子表达式,再检查当前节点:

left_value = self.left.evaluate()
right_value = self.right.evaluate()

减法检查:

if left_value < right_value:
    raise ValueError("减法不能产生负数")

除法检查:

if right_value == 0:
    raise ValueError("除数不能为零")

result = left_value / right_value

if not 0 < result < 1:
    raise ValueError("除法结果必须是大于0且小于1的真分数")

这种方式能够拒绝(1 − 3) + 5:虽然最终算术结果为正数,但其内部减法已经违反要求。

5.3 按交换律生成去重标识

去重只允许交换加法、乘法的左右子表达式,不改变分组:

def canonical_key(self) -> tuple:
    left_key = self.left.canonical_key()
    right_key = self.right.canonical_key()

    if self.operator in ("+", "*"):
        left_key, right_key = sorted((left_key, right_key))

    return ("binary", self.operator, left_key, right_key)

数字节点使用约分后的分子、分母作为标识。所有题目标识存入集合,重复标识不再加入题目列表。

因此:

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

这里没有将多个加数或乘数展开排序,因而保留了题目要求的分组差异。

5.4 解析优先级与左结合

解析器分为加减、乘除、数字与括号三个层次。加减层先读取完整的乘除表达式:

def parse_addition(self):
    expression = self.parse_multiplication()

    while self.peek() in ("+", "-"):
        operator = self.consume()
        right = self.parse_multiplication()
        expression = Binary(operator, expression, right)

    return expression

这样能够保证乘除优先于加减,并将8 − 3 − 2解析为(8 − 3) − 2。括号通过递归解析处理,没有使用eval()执行输入文本。

5.5 按题号批改

批改器将学生答案解析成Fraction后比较:

try:
    actual = parse_number(answers.get(number, ""))
except ValueError:
    wrong.append(number)
    continue

if actual == expected:
    correct.append(number)
else:
    wrong.append(number)

缺失、空白或无法解析的答案记为错误;等值分数按相同数值处理,例如2/41/2

六、测试与运行结果

6.1 测试环境和命令

实测环境:

  • Windows,系统标识为Windows-10-10.0.26200-SP0
  • Python 3.9.13。
  • 使用标准库unittest

执行:

python -m unittest discover -s tests -v

最终35项自动化测试全部通过。测试数量不等于输入数量,其中万题测试逐题检查了10000道题,多组参数测试也包含多个输入案例。

01_自动化测试通过

6.2 代表性测试用例

编号 测试内容 预期结果 实际结果
1 1/6 + 1/8 7/24 通过
2 3 − 1’1/2 1’1/2 通过
3 (1 − 3) + 5 拒绝负数中间结果 通过
4 2 − 2 0 通过
5 1 ÷ 2 1/2 通过
6 1 ÷ 0 拒绝除零 通过
7 4 ÷ 2 按严格真分数约定拒绝 通过
8 2 + 33 + 2 标识相同 通过
9 两组指定的嵌套加法案例 去重判定符合题目示例 通过
10 1 + 2 × 3 7 通过
11 (1 + 2) × 3 9 通过
12 8 − 3 − 2 3 通过
13 缺失右括号、非法字符等 拒绝解析 通过
14 标准答案1/2,提交2/4 判对 通过
15 缺失、空白或非法答案 对应题号判错 通过
16 重复答案题号 报错 通过
17 范围1请求10000道题 明确报错,不无限循环 通过
18 范围10生成10000道题 数量、范围、运算及去重检查通过 通过

此外,手动运行验证了生成模式缺少-r时会报错。

6.3 题目与答案展示

执行:

python main.py -n 10 -r 10 --seed 42

02_题目与标准答案

左侧为生成的10道题,右侧为标准答案。题目包含整数、分数、带分数、括号及不同运算符。

6.4 一万道题生成

执行:

python main.py -n 10000 -r 10 --seed 42

随后检查:

(Get-Content .\Exercises.txt).Count
(Get-Content .\Answers.txt).Count

两个文件均为10000行。

03_一万道题生成与行数

自动化测试还逐题检查了数值范围、运算符数量、中间结果和去重标识,并验证题目输出为文本后能够还原相同结构和答案。

6.5 对错混合批改

复制10道题的标准答案,手动将第2、4题改错,执行:

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

结果:

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

04_对错混合批改

6.3节截图右侧展示的是标准答案Answers.txt;终端批改使用的是另行修改过第2、4题的StudentAnswers.txt,因此两处展示的答案文件不同。

6.6 正确性依据与限制

正确性依据包括人工已知答案、边界案例、题目原文指定的去重案例、独立递归计算,以及万题批量检查。

生成和批改共用部分代码,所以“用程序自己的答案全部判对”只能说明流程一致,不能单独证明算法正确。人工案例和独立计算检查用于补充这一点。测试也无法保证覆盖所有输入,后续仍可以扩充异常文件和极端参数测试。

七、效能分析与优化

7.1 分析耗时与方法

性能分析、修改、测量和绘图约耗时94分钟,时间统计与PSP部分阶段存在交叉。

首先使用cProfile分析:

python -m cProfile -o reports/generation_before.prof main.py -n 10000 -r 10 --seed 42

优化前主要耗时如下:

函数 累计耗时
generate_exercises 0.721秒
build_expression 0.646秒
random_number 0.356秒
Binary.evaluate 0.238秒
Fraction.__new__ 0.178秒
write_exercises 0.168秒

其中Fraction.__new__调用198106次,自身耗时约0.129秒,是该报告中的显著热点。

累计时间包含子函数耗时,不能直接相加。cProfile本身也有额外开销,因此没有直接使用上述时间作为优化前后正常运行的对比数据。

05_性能热点表

7.2 优化思路

第一处优化是直接构造带分数对应的分子和分母。

优化前:

value = Fraction(whole) + Fraction(numerator, denominator)

优化后:

value = Fraction(
    whole * denominator + numerator,
    denominator,
)

这省去了额外分数对象及一次分数加法,数值保持不变。

第二处优化是避免重复复制已有的Fraction对象,并通过分子判断非负性:

if not isinstance(self.value, Fraction):
    object.__setattr__(self, "value", Fraction(self.value))

if self.value.numerator < 0:
    raise ValueError("数字不能为负数")

Fraction不可变,已有对象可以复用,其分母为正,因此可以通过分子的符号判断数值是否为负。

7.3 测量结果

固定测试条件:

  • 数量10000,范围10,随机种子42。
  • 预热一次,正式测量5次。
  • 使用time.perf_counter()计时,取中位数。
  • 只统计generate_exercises,不包含文件写入。
  • 校验和摘要计算在计时范围外进行。
次数 优化前(秒) 优化后(秒)
1 0.309718 0.260573
2 0.299818 0.239190
3 0.304098 0.260400
4 0.291985 0.231302
5 0.288963 0.257830
中位数 0.299818 0.257830

本次测量耗时下降约14.00%,加速比约1.16倍。前后固定种子下的题目和答案SHA-256指纹相同,35项回归测试仍全部通过。

06_性能对比图

这个结论仅适用于当前机器与测量条件,不意味着所有规模和运行环境下都会获得相同提升。

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

项目采用Git进行增量提交,记录了以下阶段:

  1. 初始化项目和验证分数计算。
  2. 实现分数解析与带分数格式化。
  3. 实现表达式求值和交换律去重。
  4. 实现题目生成与文件输出。
  5. 实现表达式文本解析。
  6. 实现答案批改。
  7. 添加万题生成检查。
  8. 性能优化及测量记录。
  9. 完善README。

代码、测试、性能报告和运行说明已经上传GitHub。虚拟环境、缓存和生成的运行结果通过.gitignore管理。

程序仅使用Python标准库,下载项目后可在根目录运行README中的命令。

九、项目总结与结对感受

9.1 项目收获

本项目让我们认识到,简单的功能描述也可能包含复杂的规则。分数精度、中间结果合法性和去重标准都需要在编码前明确,而不能等到程序运行后再凭结果猜测是否正确。

表达式树使求值、格式化和去重能够围绕同一种结构展开。自动化测试则让修改后的回归验证更方便,尤其是在性能优化后,可以检查已有行为是否受到影响。

环境配置和目录组织同样影响开发效率。过程中出现过Git命令无法识别、Python缩进错误以及测试目录放错位置等问题,说明工程实践不仅包括算法,也包括工具与项目结构的管理。

9.2 不足与改进

本次时间记录不够及时,导致部分工作事后难以准确归类。今后应按任务记录起止时间,并同步保存测试证据。

程序目前保留较多括号,题目可读性还有提升空间;随机生成在小范围、大数量时可能达到尝试上限。这些限制已经在文档中说明,后续可以改进排版和生成策略。

同时,AI提供的代码需要进一步理解。测试通过不等于已经掌握所有实现,后续应尝试独立修改解析规则和去重逻辑,并解释相应测试为什么能够发现错误。

9.3 实际分工

本项目中,陈泽翔主要负责项目计划、需求分析、设计与编码,吕灿斌主要负责设计和代码复审、规范制定、测试及报告整理。PSP中的“开发”包含多个子阶段,具体分工如下。

成员 实际承担的工作 参与的复审或测试
陈泽翔 项目计划与时间估算、需求分析、生成设计文档、具体设计和编码,实现分数处理、表达式树、题目生成、去重、解析及批改功能 根据复审和测试反馈修改代码,说明关键实现思路,并验证修改后的功能
吕灿斌 设计复审、代码规范制定、代码复审、测试与测试报告、工作量统计、事后总结及过程改进计划 检查实现与需求是否一致,验证正常输入、边界条件及异常情况,整理测试结果和改进建议

9.4 双方感受与互评

陈泽翔的感受与对吕灿斌的评价:

这次项目让我体会到,负责实现功能不仅需要把代码写出来,还需要把设计思路和模块之间的关系解释清楚。分数计算、表达式去重和文本解析之间存在联系,如果前期没有统一数据表示和接口,后续修改就容易影响其他模块。

吕灿斌负责的复审和测试工作,为代码实现提供了另一种检查角度。编写程序时,我容易关注正常输入能否运行,而测试还需要考虑除零、负数中间结果、错误答案和小范围生成等情况。我认为他的闪光点是重视需求与结果的对应关系,有助于避免仅凭“运行成功”就判断功能完成。

对后续合作的建议是,在具体编码前更早讨论测试用例和验收标准。这样可以在实现过程中提前考虑边界条件,减少后期反复调整的成本。同时,我们也需要及时记录各阶段用时,让PSP记录更加准确。

吕灿斌的感受与对陈泽翔的评价:

这次项目让我认识到,复审和测试同样需要理解程序设计。只有弄清表达式树、去重标识和分数表示,才能判断测试结果是否符合要求。例如,两个表达式的答案相同不代表题目重复,测试必须依据题目规定的交换规则进行设计。

陈泽翔主要承担了需求分析、设计和编码工作,将功能划分为分数处理、表达式、生成器、解析器和批改器等模块,使复审和测试能够围绕不同职责开展。我认为他的闪光点是能够将需求逐步落实为可运行、可验证的功能,并通过Git保存阶段性成果。

建议今后在实现关键算法后及时补充接口说明,特别是输入格式、异常处理和返回值约定,方便复审与测试同步进行。对于AI辅助提供的实现,双方还应通过相互讲解和独立修改加深理解,不能将测试通过等同于完全掌握代码。

posted on 2026-09-16 23:02  G1ngk0  阅读(43)  评论(0)    收藏  举报