小学四则运算题目生成器

这个作业属于哪个课程 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分钟。
初版的两个性能缺陷:

  1. 约束校验重复求值。初版先随机生成一棵表达式树,再对整个树求值去检查"减法不为负、除法为真分数"。树越大,一次求值越贵;而随机生成的树大概率不满足约束,于是大量时间花在"生成->整树求值->丢弃"的循环里。
  2. 判重是 O(n²) 的文本线性比较。初版把题目渲染成字符串后线性查找,题目数一多就明显变慢。而且文本判重语义是错的:“23+45”与“45+23”文本不同,却被当成两道不同的题。
    最终版(v2)的三处关键改进:
  3. 建树时自底向上返回子树值(gen_expression返回(node, value)),约束在构造过程中就地判断,不满足直接返回None由上层重试(拒绝采样)。整棵树最多求值一次,彻底消除重复求值。
  4. 判重改为"交换律归一化规范串+set哈希",单次判重 O(1),总复杂度 O(n)。同时规范串在语义上是正确的:+/×的子节点按规范串排序后拼接,“23+45 ≡ 45+23”、“3+(2+1) ≡ 1+2+3”都能判重,而“1+2+3”与“3+2+1”因树结构不同不会误判。
  5. 数值统一使用fractions.Fraction,自动约分、精确无误差,省去手写gcd约分与比较逻辑的维护成本。

3.2 性能数据与对比

在同一台机器(Python 3.12)上,v1 与 v2 生成 n 道题(r=100)的耗时对比:

屏幕截图 2026-09-20 205218

结论:小数据量下两种实现差距不大(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 次最优):

屏幕截图 2026-09-20 195715

3.3 消耗最大的函数

用cProfile对"生成一万道(r=100)"采样结果:

屏幕截图 2026-09-20 195838

结论:消耗最大的函数是随机建树+约束校验(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。
    关系图:

屏幕截图 2026-09-20 211700

4.2 关键函数流程

程序结构(生成模式)与约束校验逻辑如下:

屏幕截图 2026-09-21 210437

gen_expression的递归流程:

屏幕截图 2026-09-20 212007

4.3 判重

需求规定:任何两道题目不能通过有限次交换“+”和“×”左右的算术表达式变换为同一道题目。
这等价于:两棵表达式树在"允许交换“+/×”节点的左右子节点"的意义下同构。因此:

  • 对“+”与“×”节点:把左右子树的规范串排序后再拼接:“23+45”与“45+23”得到同一个串。
  • 对“−”与“÷”节点:保持左右顺序(减法、除法不可交换)。
  • 不打平嵌套结构:“1+2+3”(即 “(1+2)+3”)与“3+2+1”(即“(3+2)+1”)结构不同,规范串不同,不会被误判为重复。
    流程图:

屏幕截图 2026-09-20 212313

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”就悄悄改变了树结构,会导致判重语义不一致。
流程图:

屏幕截图 2026-09-20 212452


五、代码说明

以下为关键代码的摘录与思路说明:

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、逐子树求值断言),而非仅依赖程序自身输出。运行结果:

屏幕截图 2026-09-21 212951

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”):

屏幕截图 2026-09-21 213851

回答:

屏幕截图 2026-09-21 214027

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

屏幕截图 2026-09-21 214135

6.4 为什么能确定程序是正确的

  1. 数值层面:全程使用Fraction精确有理数,不存在浮点误差,答案一致性用例(11)用"重解析+重求值"的独立路径核对。
  2. 约束层面:用例8、9、20不是抽样目测,而是遍历每道题的每个子树做断言(任意子树值 ≥ 0;每个“÷”满足 0<左<右),配合 200/10000/3000 的批量规模。
  3. 判重层面:用例3-5直接构造需求文档中的四个例子逐一断言,10、13再做全量唯一性校验(10000道规范串两两唯一);
  4. 文本与结构一致性:用例6验证"渲染->再解析->值不变"的往返性质,保证括号规则没有改变表达式的含义。
  5. 批改层面:用例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参数的数值范围过小时合法题目空间有限,会出现死循环,后来加了尝试次数上限和友好报错。
我最大的感受是"讨论 > 各自闷头写"。判重规则是一起把需求文字翻译成树操作才定下来的,如果一个人写大概率会想偏。分工上我负责画流程图和定数据结构。结对效率比各自为战高很多,且错误在当场就被发现。


posted @ 2026-09-21 23:31  LAYG  阅读(14)  评论(0)    收藏  举报