结对项目(李忠林,曲浩宾)

结对项目:自动生成小学四则运算题目

一、项目信息

这个作业属于哪个课程 软件工程
这个作业要求在哪里 结对项目
这个作业的目标 实现一个自动生成四则运算题目的命令行程序,可通过命令行参数控制题目数量、题目难度,并存在一些约束使其符合“小学”的应用场景
队员 1 李忠林,学号:3124004474
队员 2 曲浩宾,学号:3124004483
GitHub 仓库 https://github.com/AijiA-forever/software-engineering-homework3
开发语言 Python 3.8+
程序入口 main.py

二、PSP2.1 耗时统计

下表中的预估时间在开始编码前填写,实际时间按本次结对开发过程统计。

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 35
· Estimate · 估计任务总时间 30 35
Development 开发 355 380
· Analysis · 需求分析(包括学习新技术) 45 50
· Design Spec · 生成设计文档 35 30
· Design Review · 设计复审 20 25
· Coding Standard · 代码规范 15 15
· Design · 具体设计 45 50
· Coding · 具体编码 120 125
· Code Review · 代码复审 25 25
· Test · 测试(自我测试、修改代码、提交修改) 50 60
Reporting 报告 90 100
· Test Report · 测试报告 35 40
· Size Measurement · 计算工作量 20 20
· Postmortem & Process Improvement Plan · 事后总结并提出过程改进计划 35 40
合计 475 515

三、需求分析

程序分为两种工作模式。

  • 生成模式:python main.py -n <题量> -r <范围>
  • 批改模式:python main.py -e <题目文件> -a <答案文件>

生成模式需要满足以下约束:

  1. 支持自然数、真分数和带分数。
  2. 支持加、减、乘、除和括号。
  3. 每道题最多包含 3 个运算符。
  4. 每一个减法子表达式都不能产生负数。
  5. 每一个除法子表达式都必须满足 0 < 左值 < 右值,所以商一定是真分数。
  6. 同一次运行中生成的题目不能重复。
  7. -r 必须指定,自然数值和分数分母都小于 -r
  8. 题目写入 Exercises.txt,答案写入 Answers.txt

批改模式读取题目和答案后,把正确题号、错误题号分别保存到 Grade.txt,格式为:

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

3.1 对两个需求细节的处理

第一,减法和除法约束作用在每一个子表达式上,而不只是最终答案。例如 10 - (6 - 8) 虽然最后结果可能非负,但 6 - 8 已经违反规则,因此不能生成。

第二,去重时不能只比较字符串。23 + 4545 + 23 字符串不同,但通过交换加法左右操作数可以得到同一道题,应当视为重复。乘法同理。加法和乘法的连续同级运算还可以结合,所以 1 + 2 + 33 + (2 + 1) 也按同一道题处理。

四、设计与实现过程

4.1 模块划分

文件 职责
main.py 解析命令行参数,分发生成模式和批改模式
arithmetic/core.py 分数格式化、表达式树、求值、打印括号、规范键、表达式解析
arithmetic/generator.py 随机生成题目、约束检查、查重、写题目和答案文件
arithmetic/grader.py 读取题目与答案文件,比较精确结果,生成批改内容
tests/test_arithmetic.py 自动测试

整体调用关系如下:

生成模式
main.py -> ProblemGenerator -> Expression -> Exercises.txt / Answers.txt

批改模式
main.py -> grader -> parse_expression -> evaluate -> Grade.txt

4.2 精确分数与表达式树

程序没有使用浮点数,而是统一使用 Python 标准库里的 fractions.Fraction。这样 1/6 + 1/8 会直接得到 7/24,不会出现浮点误差。

题目在内存中使用表达式树表示:

  • Number 是数字叶子节点,内部保存一个 Fraction
  • Binary 是运算节点,保存运算符、左子树和右子树。

例如 (1 + 2) × 3 会保存成:

        ×
      /   \
     +     3
   /   \
  1     2

使用表达式树后,求值、打印括号、判断重复都能在同一套结构上完成,不需要反复解析字符串。

4.3 题目生成

生成器先随机确定本题的运算符数量,范围为 1 到 3 个,然后递归把运算符分配到左右子树。每生成一个内部节点,就立刻计算左右子式的值,再根据结果选择可行运算符:

  • 加法和乘法始终可行。
  • 只有左值不小于右值时,才允许选择减法。
  • 只有左值大于 0 且小于右值时,才允许选择除法。

这种做法把约束放在生成阶段解决,比“先生成、再检查、失败重来”更容易保证正确性。对于 -r 2 这种范围很小的情况,也不会因为反复选中非法除法而陷入大量重试。

4.4 题目查重

每个表达式都会递归生成一个规范键。遇到加法和乘法时,把同一运算符的子树展开,再对操作数排序;减法和除法保持原来的左右顺序。

def _canonical(expression):
    if isinstance(expression, Number):
        return ("number", expression.value.numerator, expression.value.denominator)

    if expression.operator in ("+", "×"):
        operands = tuple(
            sorted(
                _canonical(operand)
                for operand in _flatten(expression, expression.operator)
            )
        )
        return (expression.operator, operands)

    return (
        expression.operator,
        _canonical(expression.left),
        _canonical(expression.right),
    )

生成器用 set 保存已经出现的规范键。平均情况下,查重只需要一次哈希查找。

4.5 括号打印

把表达式树还原成题目字符串时,根据运算符优先级和结合方向决定是否加括号:

  • 左子式优先级低于父节点时加括号。
  • 右子式优先级低于或等于父节点时加括号。

这样既能保持原表达式的计算顺序,又不会给所有子式都套一层不必要的括号。

4.6 判分解析器

判分模式需要解析已有题目。解析器按“加减 -> 乘除 -> 括号或数字”的顺序递归下降,因此可以正确处理优先级和嵌套括号。

为了提高兼容性,解析器还支持:

  • 全角减号 、乘号 × 和除号 ÷
  • ASCII 的 */
  • 带分数的 ASCII 写法 2'3/8 和弯引号写法 2’3/8
  • 带 BOM 的文本文件,读取时使用 utf-8-sig

五、效能分析

测试命令:

python -m cProfile -s cumtime main.py -n 10000 -r 50

普通方式生成 10000 道题的实际端到端耗时约为 0.49s。使用 cProfile 插桩后总耗时约为 0.89s,主要函数耗时如下:

函数 累计耗时(秒) 说明
main() 0.863 包含生成、查重和写文件
ProblemGenerator.generate() 0.638 生成 10000 道题
ProblemGenerator._build() 0.448 递归建立表达式树
ProblemGenerator._random_operand() 0.165 随机生成数字叶子
canonical_key() 0.151 生成规范键并查重
write_problem_files() 0.202 写两个结果文件

消耗最大的部分是递归建立表达式树,符合预期。主要优化点是:

  1. 使用不可变规范键和 set 去重,避免和之前所有题目逐个比较。
  2. 内部节点先生成左右子树,再选择满足条件的运算符,减少无效重试。
  3. 题目和答案只在最后统一写文件,避免反复打开文件。

实测生成的 10000 道题行数为 10000,去重后的唯一题目行数也是 10000。

六、关键代码说明

6.1 数字格式化

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

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

这个函数负责把 Fraction 转成题目要求的三种显示形式:整数、真分数和带分数。

6.2 可行的运算符选择

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

feasible_operators = ["+", "×"]
if left_value >= right_value:
    feasible_operators.append("-")
if 0 < left_value < right_value:
    feasible_operators.append("÷")

operator = self.random.choice(feasible_operators)
return Binary(operator, left, right)

减法和除法的约束在建立节点时直接执行,不需要事后扫描整棵表达式树。

6.3 批改结果

actual = answers.get(identifier)
if actual is not None and actual == expected:
    correct.append(identifier)
else:
    wrong.append(identifier)

答案文件缺少某一题、答案格式错误或者答案与标准答案不相等时,都会把对应题号加入 Wrong,不会让整个批改过程崩溃。

七、测试运行

运行自动测试:

python -m unittest discover -s tests -v

本机运行结果为 Ran 12 tests,全部通过。测试内容如下:

编号 测试内容 结果
1 整数、真分数、带分数的格式化 通过
2 整数、真分数、带分数的解析 通过
3 乘除优先于加减 通过
4 括号改变运算顺序 通过
5 分数加法得到精确结果 7/24 通过
6 打印表达式时保留必要括号 通过
7 交换加法左右操作数后判定为重复 通过
8 连续同级加法按结合律判定为重复 通过
9 随机题目的减法和除法约束 通过
10 随机题目的操作数和分母范围 通过
11 生成题目没有重复规范键 通过
12 文件生成、全对判分、错题与漏答判分 通过

生成 5 道题的实测结果:

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

对应答案:

1. 1/12
2. 8'13/18
3. 3'5/6
4. 86'5/8
5. 3'8/9

直接用生成出的答案批改,结果为:

Correct: 5 (1, 2, 3, 4, 5)
Wrong: 0 ()

如果答案文件缺少第三题,并把第二题改成错误答案,则结果为:

Correct: 1 (1)
Wrong: 2 (2, 3)

缺少必需的 -r 参数时,程序会提示:

错误:生成模式必须同时指定 -n 和 -r

并继续打印完整帮助信息,不会直接抛出未处理的异常。

八、项目小结与结对感受

这次项目的难点不是随机选择几个数字和运算符,而是把“减法不产生负数”“除法结果是真分数”“题目不能重复”这些规则落实到每一个子表达式上。表达式树让约束可以集中在节点层处理,规范键则把复杂的去重问题转换成集合查找。另一个收获是自动测试的重要性:固定随机种子后批量生成并检查题目,比人工看十几道题更有说服力。

8.1 分工

成员 主要工作
李忠林 需求拆解、表达式树、题目生成、去重算法、命令行集成
曲浩宾 测试用例设计、边界检查、判分模块复核、博客校对

8.2 结对感受

李忠林:我在实现过程中主要负责核心逻辑。最开始容易只关注最终答案是否正确,后来才把减法和除法的检查放到每个内部节点上。通过这次结对,我意识到代码写完只是第一步,另一个人从测试角度不断追问边界条件,能让程序更可靠。

曲浩宾:我主要从需求和使用者的角度检查程序,包括分数格式、括号、错误答案、漏答和文件编码等问题。检查判分逻辑时,我发现输入输出格式本身也是正确性的一部分。后续如果继续改进,可以增加随机性质测试和更多异常文件用例。

九、运行方式

# 生成 10 道题,操作数小于 10
python main.py -n 10 -r 10

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

# 运行测试
python -m unittest discover -s tests -v
posted @ 2026-09-21 10:41  李忠林  阅读(3)  评论(0)    收藏  举报