结对项目(李忠林,曲浩宾)
结对项目:自动生成小学四则运算题目
一、项目信息
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 结对项目 |
| 这个作业的目标 | 实现一个自动生成四则运算题目的命令行程序,可通过命令行参数控制题目数量、题目难度,并存在一些约束使其符合“小学”的应用场景 |
| 队员 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 <答案文件>。
生成模式需要满足以下约束:
- 支持自然数、真分数和带分数。
- 支持加、减、乘、除和括号。
- 每道题最多包含 3 个运算符。
- 每一个减法子表达式都不能产生负数。
- 每一个除法子表达式都必须满足
0 < 左值 < 右值,所以商一定是真分数。 - 同一次运行中生成的题目不能重复。
-r必须指定,自然数值和分数分母都小于-r。- 题目写入
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 + 45 与 45 + 23 字符串不同,但通过交换加法左右操作数可以得到同一道题,应当视为重复。乘法同理。加法和乘法的连续同级运算还可以结合,所以 1 + 2 + 3、3 + (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 | 写两个结果文件 |
消耗最大的部分是递归建立表达式树,符合预期。主要优化点是:
- 使用不可变规范键和
set去重,避免和之前所有题目逐个比较。 - 内部节点先生成左右子树,再选择满足条件的运算符,减少无效重试。
- 题目和答案只在最后统一写文件,避免反复打开文件。
实测生成的 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
浙公网安备 33010602011771号