小学四则运算题目生成器
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694 |
| 这个作业的目标 | <实现一个小学四则运算题目生成器> |
小学四则运算题目生成器
一、信息
姓名:陈憬乐
学号:3124004050
Github项目地址:https://github.com/LAYG13/LAYG13/tree/main/arithmetic-generator
二、PSP 表格(预估)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 50 |
| · Estimate | · 估计这个任务需要多少时间 | 20 |
| Development | 开发 | 60 |
| · Analysis | · 需求分析(包括学习新技术) | 60 |
| · Design Spec | · 生成设计文档 | 45 |
| · Design Review | · 设计复审(和同事审核设计文档) | 30 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 30 |
| · Design | · 具体设计 | 100 |
| · Coding | · 具体编码 | 180 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 |
| Reporting | 报告 | 40 |
| · Test Report | · 测试报告 | 40 |
| · Size Measurement | · 计算工作量 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 820 |
三、效能分析
3.1 改进花费的时间与思路
我们在"判重算法"和"约束校验"上各做了一轮重构,总共约90分钟。
初版的两个性能缺陷:
- 约束校验重复求值。初版先随机生成一棵表达式树,再对整个树求值去检查"减法不为负、除法为真分数"。树越大,一次求值越贵;而随机生成的树大概率不满足约束,于是大量时间花在"生成->整树求值->丢弃"的循环里。
- 判重是 O(n²) 的文本线性比较。初版把题目渲染成字符串后线性查找,题目数一多就明显变慢。而且文本判重语义是错的:“23+45”与“45+23”文本不同,却被当成两道不同的题。
最终版(v2)的三处关键改进: - 建树时自底向上返回子树值(gen_expression返回(node, value)),约束在构造过程中就地判断,不满足直接返回None由上层重试(拒绝采样)。整棵树最多求值一次,彻底消除重复求值。
- 判重改为"交换律归一化规范串+set哈希",单次判重 O(1),总复杂度 O(n)。同时规范串在语义上是正确的:+/×的子节点按规范串排序后拼接,“23+45 ≡ 45+23”、“3+(2+1) ≡ 1+2+3”都能判重,而“1+2+3”与“3+2+1”因树结构不同不会误判。
- 数值统一使用fractions.Fraction,自动约分、精确无误差,省去手写gcd约分与比较逻辑的维护成本。
3.2 性能数据与对比
在同一台机器(Python 3.12)上,v1 与 v2 生成 n 道题(r=100)的耗时对比:

结论:小数据量下两种实现差距不大(n=2000 时仅1.5倍),因为题目生成本身占主导。但随n增大,初版的O(n²) 判重开销迅速放大:n从2000涨到20000(10倍),初版耗时从0.035s涨到1.433s(约40倍,二次增长),改进版仅从0.024s涨到0.241s(约10倍,近线性),加速比从1.5×单调拉大到5.9×。这说明在一万道题目的规模下,规范串判重是必要的。一万道题目最终约0.12秒,满足题目需求。
生成耗时随题目数量、数值范围的变化如下图(benchmark.py实测,取 3 次最优):

3.3 消耗最大的函数
用cProfile对"生成一万道(r=100)"采样结果:

结论:消耗最大的函数是随机建树+约束校验(gen_expression及其内部的随机数与Fraction运算),而判重逻辑canonical开销很小。这符合预期:约束拒绝采样意味着大量"生成后被丢弃"的树,随机数生成与分数比较自然成为主要成本。若需进一步优化,可考虑"先生成满足约束的数值对再拼树"来降低拒绝率,但当前性能已远超需求,收益不大。
四、设计实现过程
4.1 总体设计
程序按"数值表示->表达式树->约束生成->归一化判重->渲染/求值->CLI"分层组织,全部代码在“main.py”一个文件中,按功能划分为 7 个区块:
| 模块 | 主要函数/类 | 职责 |
|---|---|---|
| 数值表示 | format_number/parse_number | 自然数、真分数3/5、带分数2'3/8的格式化与解析 |
| 表达式解析 | tokenize / Parser / eval_node / parse_expr | 把题目文本解析成 AST 并求值(批改模式用) |
| 判重 | canonical | 交换律归一化规范串,O(1) 判重 |
| 渲染 | render / render_child | 按优先级与结合性输出题目文本(含括号) |
| 生成 | random_leaf / gen_expression / generate_problem / generate_all | 随机建树 + 约束校验 + 去重循环 |
| 批改 | grade | 读文件、重算标准答案、比对 |
| 入口 | main | 命令行参数解析与模式分发 |
核心数据结构--表达式树(AST):
- 叶子:('num', Fraction),例如('num', Fraction(1, 2))表示1/2;
- 内部节点:('bin', 运算符, 左子树, 右子树),例如('bin', '×', ('num', 2), ('num', 3))表示2×3。
关系图:

4.2 关键函数流程
程序结构(生成模式)与约束校验逻辑如下:

gen_expression的递归流程:

4.3 判重
需求规定:任何两道题目不能通过有限次交换“+”和“×”左右的算术表达式变换为同一道题目。
这等价于:两棵表达式树在"允许交换“+/×”节点的左右子节点"的意义下同构。因此:
- 对“+”与“×”节点:把左右子树的规范串排序后再拼接:“23+45”与“45+23”得到同一个串。
- 对“−”与“÷”节点:保持左右顺序(减法、除法不可交换)。
- 不打平嵌套结构:“1+2+3”(即 “(1+2)+3”)与“3+2+1”(即“(3+2)+1”)结构不同,规范串不同,不会被误判为重复。
流程图:

4.4 括号渲染规则
render_child根据"子表达式运算符优先级vs父运算符优先级"和"左右位置"决定是否加括号:
| 情况 | 处理 | 示例 |
|---|---|---|
| 子优先级更高 | 不加括号 | c+a×b |
| 子优先级更低 | 加括号 | (a+b)×c |
| 同级且子在左侧 | 不加括号(左结合) | a+b+c、a−b−c |
| 同级且子在右侧 | 加括号(保留树结构) | 3+(2+1)、1÷(2×3) |
规则第 4 行是关键:“1+2+3”与“3+(2+1)”数值相等但结构不同,后者必须用括号保住“(2+1)”这个子树,否则渲染成“3+2+1”就悄悄改变了树结构,会导致判重语义不一致。
流程图:

五、代码说明
以下为关键代码的摘录与思路说明:
5.1 约束就地校验的生成函数
def gen_expression(rng, r, op_count):
"""递归生成一棵含 op_count 个运算符的表达式树。
返回 (node, value);约束不满足时返回 None(由上层重试)。
返回子树值可避免约束检查时对整棵树重复求值。
"""
if op_count == 0:
v = random_leaf(rng, r)
return ('num', v), v
op = rng.choice(OPS)
lo = rng.randint(0, op_count - 1) # 左右子树分配运算符个数
ro = op_count - 1 - lo
got = gen_expression(rng, r, lo)
if got is None:
return None
lnode, lv = got
got = gen_expression(rng, r, ro)
if got is None:
return None
rnode, rv = got
if op == '−' and lv < rv: # 约束1:减法不产生负数
return None
if op == '÷': # 约束2:除法结果必须是真分数
if lv <= 0 or lv >= rv:
return None
v = lv / rv
elif op == '+':
v = lv + rv
elif op == '−':
v = lv - rv
else:
v = lv * rv
return ('bin', op, lnode, rnode), v
思路:递归返回子树值“lv/rv”,在父节点构造时直接比较两个值即可判定约束,无需对已构造的树重新求值;不满足约束就返回“None”,上层拒绝采样重试,实现简单且不会产生非法题目。
5.2 判重核心:交换律归一化
def canonical(node) -> str:
"""返回表达式的规范串,用于 O(1) 判重。"""
if node[0] == 'num':
v = node[1]
return f"n{v.numerator}/{v.denominator}" if v.denominator != 1 else f"n{v.numerator}"
_, op, l, r = node
cl, cr = canonical(l), canonical(r)
if op in ('+', '×'):
return f"{op}({min(cl, cr)},{max(cl, cr)})" # 交换律:子节点排序
return f"{op}({cl},{cr})" # 减法/除法保持顺序
思路:“+/×”的左右子树可交换,因此把两个子节点的规范串排序后拼接;“−/÷” 不可交换,保持原顺序;嵌套结构不打平,保证“1+2+3”与“3+2+1”不误判。规范串作为“set”的键,单次判重 O(1)。
5.3 括号渲染
def render_child(node, parent_op, side) -> str:
s = render(node)
if node[0] == 'num':
return s
child_op = node[1]
if PREC[child_op] > PREC[parent_op]:
return s # 如 c + a×b
if PREC[child_op] < PREC[parent_op]:
return f"({s})" # 如 (a+b)×c
if side == 'left':
return s # 左结合:a+b+c、a−b−c
return f"({s})" # 右侧同级加括号保留结构:3+(2+1)
思路:只比较"子/父运算符优先级"与"左右位置"四个组合即可决定是否加括号,无需复杂的括号栈处理。
5.4 生成主循环(去重+上限保护)
while len(problems) < n and attempts < max_attempts:
attempts += 1
node = generate_problem(rng, r, max_ops)
if node is None:
continue
key = canonical(node) # 规范串判重,O(1)
if key in seen:
continue
seen.add(key)
problems.append(node)
if len(problems) < n:
raise RuntimeError(
f"无法在 -r {r} 范围内生成 {n} 道互不重复的题目"
f"(仅生成 {len(problems)} 道)。请增大 -r 或减小 -n。")
思路:“-r 1”这类极小范围下合法题目空间有限(如只有“0”可用的场景),设置尝试次数上限并给出友好报错,避免死循环或长时间空转。
六、测试运行
6.1 测试方式
编写自动化测试套件“test_main.py”(基于“unittest”,23 个用例),覆盖需求1-8的每一条;对生成的题目做独立的二次校验(重新解析题目文本、遍历 AST、逐子树求值断言),而非仅依赖程序自身输出。运行结果:

6.2 测试用例清单
| # | 用例 | 输入 / 操作 | 预期结果 | 实际结果 |
|---|---|---|---|---|
| 1 | 数值格式 | format_number 处理 5、3/5、11/8、7/24 | 5、3/5、1'3/8、7/24 | 通过 |
| 2 | 真分数运算示例 | 解析并求值 1/6 + 1/8 | 等于 7/24 | 通过 |
| 3 | 交换律判重 | 23+45 vs 45+23;6×8 vs 8×6 | 规范串相同(判重) | 通过 |
| 4 | 嵌套判重 | 3+(2+1) vs 1+2+3 | 规范串相同(判重) | 通过 |
| 5 | 非重复判定 | 1+2+3 vs 3+2+1 | 规范串不同(不判重) | 通过 |
| 6 | 括号渲染 | 渲染 3+(2+1)、(1+2)×3、1÷(2×3) 等 6 组 | 括号正确;且渲染文本再解析后值不变 | 通过 |
| 7 | 生成数量/范围 | generate_all(200, 10) | 200 道;全部叶子 ∈ [0,10),分数分母 < 10 | 通过 |
| 8 | 约束:无负数 | 遍历全部 200 道题的所有子树 | 任意子树求值 ≥ 0 | 通过 |
| 9 | 约束:除法为真分数 | 遍历全部 200 道题的所有 ÷ 子树 | 0 < 左值 < 右值 | 通过 |
| 10 | 一次运行不重复 | 200 道题两两比较规范串 | 无重复 | 通过 |
| 11 | 答案一致性 | 重解析每道题并求值,与 Answers 记录比对 | 全部一致 | 通过 |
| 12 | 缺 -r 报错 | python main.py -n 10 | 非零退出码,stderr 报错并含 -r 帮助 | 通过 |
| 13 | 一万道 | python main.py -n 10000 -r 100 | 文件各 10000 行、无重复、约束全过 | 通过 |
| 14 | 边界 -r 1 | -n 1 -r 1;-n 10000 -r 1 | 小规模成功;大规模友好报错不崩溃 | 通过 |
| 15 | 批改(混合对错) | 6 题中第 4 题答案错误 | Correct: 5 (1, 2, 3, 5, 6) / Wrong: 1 (4) | 通过 |
| 16 | 批改(全对) | 2 题全对 | Correct: 2 (1, 2) / Wrong: 0 () | 通过 |
| 17 | 批改(带分数答案) | 3/4 + 5/8 的答案为 1'3/8 | 判为正确 | 通过 |
| 18 | 批改参数缺失 | -e Ex.txt 无 -a | 报错 | 通过 |
| 19 | 题数不匹配 | 2 题 1 答案 | 报错"数量不一致" | 通过 |
| 20 | 除法压力测试 | 3000 次生成,统计全部 ÷ 子树 | 均满足 0 < 左值 < 右值 | 通过 |
6.3 演示输出
生成 10 道(“-n 10 -r 10”):

回答:

批改(其中第 2/4/6/8/10 行被故意改错):

6.4 为什么能确定程序是正确的
- 数值层面:全程使用Fraction精确有理数,不存在浮点误差,答案一致性用例(11)用"重解析+重求值"的独立路径核对。
- 约束层面:用例8、9、20不是抽样目测,而是遍历每道题的每个子树做断言(任意子树值 ≥ 0;每个“÷”满足 0<左<右),配合 200/10000/3000 的批量规模。
- 判重层面:用例3-5直接构造需求文档中的四个例子逐一断言,10、13再做全量唯一性校验(10000道规范串两两唯一);
- 文本与结构一致性:用例6验证"渲染->再解析->值不变"的往返性质,保证括号规则没有改变表达式的含义。
- 批改层面:用例15~19用手工构造的文件验证统计编号、全对/全错边界、参数缺失与数量不匹配的错误处理。
七、PSP 表格(实际)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 50 | 60 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | 60 | 70 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 50 |
| · Design Spec | · 生成设计文档 | 45 | 40 |
| · Design Review | · 设计复审(和同事审核设计文档) | 30 | 25 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 30 | 20 |
| · Design | · 具体设计 | 100 | 120 |
| · Coding | · 具体编码 | 180 | 200 |
| · Code Review | · 代码复审 | 30 | 35 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 | 75 |
| Reporting | 报告 | 40 | 50 |
| · Test Report | · 测试报告 | 40 | 40 |
| · Size Measurement | · 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 30 |
| 合计 | 820 | 860 |
八、项目小结
这个项目中我觉得做的好的地方是每个模块都想清楚再动手,在判重模块里,需求的“1+2+3”与 “3+2+1”"不重复"的判定非常反直觉,一开始很容易做成"全排序打平"导致误判。讨论之后我把"有限次交换"翻译成"树在交换律下同构",再落到canonical的排序规则上。做的不好的地方是有些细节和极端情况一开始没有想到,比如-r参数的数值范围过小时合法题目空间有限,会出现死循环,后来加了尝试次数上限和友好报错。
我最大的感受是"讨论 > 各自闷头写"。判重规则是一起把需求文字翻译成树操作才定下来的,如果一个人写大概率会想偏。分工上我负责画流程图和定数据结构。结对效率比各自为战高很多,且错误在当场就被发现。

浙公网安备 33010602011771号