第三次作业
1. 项目基本信息
| 姓名 | 学号 |
|---|---|
| 林华杰 | 3124004213 |
| 蔡志冲 | 3124004197 |
Github 项目地址:https://github.com/Lin-huaj/Lin-huaj/tree/main/four_arithmetic_operations
2. 需求与目标
本项目实现一个命令行四则运算题目生成器,覆盖题目要求的两个工作模式:
- 生成模式:
python3 main.py -n 10 -r 10生成 10 道题,并在当前目录写入Exercises.txt和Answers.txt。 - 判题模式:
python3 main.py -e Exercises.txt -a StudentAnswers.txt读取题目和学生答案,生成Grade.txt。
表达式支持自然数、真分数、带分数、括号,以及 +、-、×、÷。每道题最多 3 个运算符;减法的每个子表达式都不产生负数;除法不允许除以 0;同一次生成中的题目不能在交换加法或乘法左右子树后重复。
2.1 约束的落地方式
-n和-r由argparse解析,并在进入生成器前检查为正整数。- 采用
fractions.Fraction保存所有中间值和答案,避免浮点数误差。 - 随机生成表达式树时,先生成左子树,再按减法和除法约束重试右子树。
- 通过递归规范化键
norm_key()处理交换律:+、×的左右键排序,-、÷保留顺序。 - 输出时依据优先级和结合性决定是否补括号,保证文本重新解析后语义不变。
3. PSP 计划与实际记录
开发前先估算各阶段耗时,完成后再记录实际耗时。单位均为分钟,实际值是本次实现、测试和整理文档的工作记录。
| Personal Software Process Stages | 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning / Estimate | 计划与估计 | 20 | 20 |
| Development / Analysis | 需求分析(含 Fraction、递归下降解析) | 40 | 45 |
| Development / Design Spec | 设计文档 | 30 | 35 |
| Development / Design Review | 设计复审 | 20 | 20 |
| Development / Coding Standard | 代码规范 | 10 | 10 |
| Development / Design | 表达式树、生成器、判题器设计 | 45 | 50 |
| Development / Coding | 具体编码 | 120 | 150 |
| Development / Code Review | 代码复审 | 30 | 35 |
| Development / Test | 测试、修复和回归 | 60 | 70 |
| Reporting / Test Report | 测试报告 | 25 | 30 |
| Reporting / Size Measurement | 工作量统计 | 10 | 10 |
| Reporting / Postmortem & Process Improvement Plan | 事后总结与改进计划 | 25 | 30 |
| 合计 | 435 | 505 |
实际时间比估计多 70 分钟,主要花在括号渲染、交换律去重和判题格式兼容上。
4. 设计与实现
4.1 模块组织
项目保持单文件实现,按职责分成五个区域:
| 区域 | 主要函数/类 | 职责 |
|---|---|---|
| 数值格式化 | frac_to_str()、str_to_frac() |
Fraction 与自然数、真分数、带分数文本互转 |
| 表达式树 | Node、Num、Bin |
保存结构,计算值,统计运算符,生成规范化键和文本 |
| 随机生成 | rand_number()、gen_expr()、generate_problems() |
生成满足约束且不重复的题目 |
| 解析与判题 | Parser、calc_answer()、grade() |
递归下降解析表达式并比较学生答案 |
| 文件与入口 | write_exercises_and_answers()、main() |
写文件、参数检查、选择生成/判题模式 |
表达式采用二叉树,而不是直接拼接字符串。这样同一份结构可以同时用于计算、去重、括号渲染和运算符计数,避免“显示字符串”和“内部计算”出现两套规则。
4.2 核心数据结构
Num 是叶子节点,保存一个 Fraction;Bin 是二叉运算节点,保存运算符、左子树和右子树。所有计算都递归向下,再由父节点组合结果。
class Bin(Node):
def __init__(self, op, left, right):
self.op = op
self.left = left
self.right = right
def value(self):
a, b = self.left.value(), self.right.value()
if self.op == "+":
return a + b
if self.op == "-":
return a - b
if self.op == "×":
return a * b
if self.op == "÷":
return a / b
raise ValueError(f"未知运算符 {self.op}")
def op_count(self):
return 1 + self.left.op_count() + self.right.op_count()
Fraction 会自动约分。例如 1/6 + 1/8 的内部结果直接是 Fraction(7, 24),输出函数再将它格式化为 7/24;带分数则使用题目规定的 ASCII 撇号,例如 11/2 输出为 5'1/2。
4.3 生成与去重
生成器先随机决定 1~3 个运算符,再把运算符配额递归分给左右子树。减法和除法在生成右子树后验证约束,不满足就重试。generate_problems() 使用集合保存规范化键,直到收集到指定数量。
def norm_key(self):
left = self.left.norm_key()
right = self.right.norm_key()
if self.op in ("+", "×") and left > right:
left, right = right, left
return f"({self.op}{left}{right})"
这个递归规则同时处理嵌套交换。例如 3 + (2 + 1) 和 (1 + 2) + 3 的子树最终都会得到同一规范键;而减法、除法不排序,所以 10 - 3 与 3 - 10 仍然是不同题目。
4.4 文本化与解析
Bin.to_text() 使用 +/- 一级、×/÷ 二级的优先级。左子树优先级低于父节点时加括号;右子树在同优先级下遇到 - 或 ÷ 也加括号,从而正确表达 a - (b - c) 和 a ÷ (b ÷ c)。
4.5 流程图
命令行程序总流程图

题目生成流程图

判题流程图

5. 效能分析
本次性能检查花费约 30 分钟,重点确认 10000 道题的生成能力和重复检查开销。使用 time 和 Python cProfile 分别测量端到端耗时与函数热点。
| 场景 | 实测时间 | 平均每题 |
|---|---|---|
100 题,r=10 |
0.0017 s | 0.017 ms |
1000 题,r=50 |
0.0168 s | 0.017 ms |
10000 题,r=100 |
0.1754 s | 0.018 ms |

在带解释器启动和文件写入的 cProfile 运行中,总耗时约 0.719 s。累计耗时最高的是 generate_problems(),其内部主要热点是 gen_expr()、rand_number() 和表达式节点的 value()。原因是每次候选题都要递归构造、计算约束、规范化并格式化,这是需求本身要求的工作,而不是多余的重复扫描。
当前实现的优化思路是:
- 用集合保存
norm_key,将重复判断从线性查找降为均摊 O(1)。 - 用
Fraction直接保存规范化分数,避免字符串反复解析和浮点误差修正。 - 将运算符配额作为递归参数传递,保证不会先生成超长表达式再截断。
- 只在违反减法或除法约束时重试右子树,避免整棵树作废。
在 r 很小、可生成的等价题数量不足时,程序会在重试上限后给出明确错误,而不是无限循环。这是可用性和性能之间的必要边界。
6. 关键代码说明
6.1 约束生成
def gen_expr(rng, r, max_ops):
if max_ops <= 0:
return Num(rand_number(rng, r))
op = rng.choice(["+", "-", "×", "÷"])
left_ops = 0 if max_ops == 1 else rng.randint(0, max_ops - 1)
right_ops = max_ops - 1 - left_ops
left = gen_expr(rng, r, left_ops)
for _ in range(80):
right = gen_expr(rng, r, right_ops)
if op == "-" and left.value() < right.value():
continue
if op == "÷" and right.value() == 0:
continue
return Bin(op, left, right)
# 极端范围下的兜底,确保函数能够返回
if op == "-":
return Bin("-", left, Num(Fraction(0)))
if op == "÷":
return Bin("+", left, Num(Fraction(0)))
return Bin(op, left, Num(rand_number(rng, r)))
这里的 max_ops 同时承担“深度预算”和“运算符数量预算”。减法约束只比较已经生成的两个子树值,因此嵌套减法也能保证每一步不出现负数。
6.2 判题与文件输出
def grade(ex_file, ans_file, dir_="."):
ex_lines = read_lines(ex_file)
ans_lines = read_lines(ans_file)
correct, wrong = [], []
for i in range(min(len(ex_lines), len(ans_lines))):
try:
right = calc_answer(_strip_number_prefix(ex_lines[i]))
user = str_to_frac(_strip_number_prefix(ans_lines[i]))
(correct if right == user else wrong).append(i + 1)
except Exception:
wrong.append(i + 1)
path = os.path.join(dir_, "Grade.txt")
with open(path, "w", encoding="utf-8") as f:
f.write(f"Correct: {len(correct)} ({', '.join(map(str, correct))})\n")
f.write(f"Wrong: {len(wrong)} ({', '.join(map(str, wrong))})\n")
return path, correct, wrong
答案解析失败会被判为错误并继续处理后续题目,单个坏答案不会中断整批统计。
7. 测试运行
测试命令:
python3 -m unittest discover -s tests -v
实测结果:31 个测试全部通过,总耗时约 0.014 秒。下面列出至少 10 个有代表性的测试用例及其目的:
| 编号 | 测试输入/场景 | 预期结果 | 验证点 |
|---|---|---|---|
| 1 | Fraction(4, 1) 格式化 |
4 |
自然数输出 |
| 2 | Fraction(3, 5) 格式化 |
3/5 |
真分数输出 |
| 3 | Fraction(11, 2) 格式化 |
5'1/2 |
带分数输出 |
| 4 | 1/6 + 1/8 |
7/24 |
分数精确加法 |
| 5 | 2'1/2 + 1/2 |
3 |
带分数解析 |
| 6 | (1 + 2) × 3 |
9 |
括号优先级 |
| 7 | 1 + 2 × 3 |
7 |
乘法优先于加法 |
| 8 | 10 - (5 - 3) |
8 |
右子树括号与结合性 |
| 9 | 23 + 45 与 45 + 23 |
规范键相同 | 加法交换律去重 |
| 10 | 6 × 8 与 8 × 6 |
规范键相同 | 乘法交换律去重 |
| 11 | 10 - 3 与 3 - 10 |
规范键不同 | 减法不交换 |
| 12 | generate_problems(200, 15) |
无重复 | 批量去重 |
| 13 | 100 道随机题 | 每题运算符不超过 3 个 | 生成上限 |
| 14 | 200 道随机题 | 所有答案 >= 0 |
过程/结果非负 |
| 15 | 三题判题:答案 2、5、1 对应 2、6、1 |
正确 [1,3],错误 [2] |
Grade.txt 统计 |
测试不仅检查最终答案,也检查格式化的往返一致性、AST 规范化、括号渲染和随机生成约束,因此能够覆盖“生成正确但输出不可解析”这类问题。另用 python3 main.py -n 10000 -r 100 实测成功生成 10000 道题,验证了题目要求的规模。
8. 项目运行与目录结构
four_arithmetic_operations/
├── main.py
├── tests/test_main.py
├── examples/StudentAnswers.txt
├── README.md
├── requirements.txt
├── flow.dot
├── flow.png
├── flow.svg
├── generation_flow.dot
├── generation_flow.svg
├── generation_flow.png
├── grading_flow.dot
├── grading_flow.svg
├── grading_flow.png
├── 性能分析图.png
└── blog.md
项目只使用 Python 标准库,不需要安装第三方包。最小运行示例:
python3 main.py -n 10 -r 10
python3 main.py -e Exercises.txt -a StudentAnswers.txt
如果只输入 -n 而遗漏 -r,程序会打印错误和帮助信息;如果题目文件或答案文件不存在,判题模式也会给出明确错误并返回非零状态码。
9. 项目小结与结对反思
做得比较好的地方
- 用 AST 统一承载计算、渲染和去重逻辑,减少了字符串规则之间的不一致。
- 用
Fraction解决了真分数和带分数的精确计算问题。 - 测试从单个运算扩展到随机生成、文件判题和 10000 题规模,能够较早暴露边界问题。
- 命令行错误信息和输出文件格式与需求保持一致,便于批量使用。
不足与改进计划
- 当前文件只有一个核心模块,后续可以拆分为
model.py、generator.py、parser.py和cli.py,降低维护成本。 - 生成器对极小
r的可生成题目数量有限,未来可以先估计搜索空间,再在参数不足时提前提示。 - 除法目前保证除数非零且结果精确表示;若评分严格解释“结果必须是真分数”,应增加专门的非整数结果约束和测试。
- 可以引入属性测试,随机验证“渲染后再解析”的值保持不变,以及规范化键对交换变换的不变性。
结对感受
这次作业最有价值的地方是把“随机生成”变成一组可验证的约束,而不是只追求能打印出题目。结对过程中,一位同学适合先梳理需求边界和测试矩阵,另一位同学集中处理 AST、解析器和异常路径;交叉复审时,双方都能发现自己单独编码时容易忽略的结合性和格式问题。
浙公网安备 33010602011771号