第三周结对项目
结对项目——小学四则运算题目生成器
| 项目 | 内容 |
|---|---|
| 所属课程 | 软件工程 |
| 作业要求 | 第三周作业 |
| 作业目标 | 完成结对项目 |
| 项目成员 | 陈安东 3124004085、李梓弘 3124004100 |
| GitHub | https://github.com/Anton123-lang/SoftwareEngineering |
一、PSP 表格
1.1 计划阶段
| PSP2.1 | 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 10 分钟 | 10 分钟 |
| · Estimate | 估算任务总用时 | 10 分钟 | 10 分钟 |
1.2 开发阶段
| PSP2.1 | 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Development | 开发 | 195 分钟 | 约 190 分钟 |
| · Analysis | 需求分析(含新技术学习) | 15 分钟 | 20 分钟 |
| · Design Spec | 编写设计文档 | 15 分钟 | 17 分钟 |
| · Design Review | 设计复审(与同伴互审) | 20 分钟 | 10 分钟 |
| · Coding Standard | 制定代码规范 | 15 分钟 | 10 分钟 |
| · Design | 详细设计 | 40 分钟 | 55 分钟 |
| · Coding | 编码实现 | 30 分钟 | 30 分钟 |
| · Code Review | 代码复审 | 20 分钟 | 15 分钟 |
| · Test | 测试与修改 | 40 分钟 | 45 分钟 |
1.3 报告阶段
| PSP2.1 | 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Reporting | 报告 | 30 分钟 | 约 65 分钟 |
| · Test Report | 测试报告 | 15 分钟 | 25 分钟 |
| · Size Measurement | 工作量统计 | 5 分钟 | 5 分钟 |
| · Postmortem & Process Improvement Plan | 复盘与改进计划 | 10 分钟 | 35 分钟 |
1.4 合计
| 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|
| 合计 | 约 235 分钟,约 3 小时 55 分 | 约 265 分钟,约 4 小时 25 分 |
实际用时比预估多出约 30 分钟,主要增量出现在代码复审和事后总结两个环节。
二、效能分析
2.1 改进思路
改进点 ①:消除 evaluate() 递归重复计算
- 问题:最初的表达式生成流程中,为保证约束合法,会先构建整棵表达式树,再调用
evaluate()逐一校验;而evaluate()本身是递归的,同一子树的数值会被重复求值多次,浪费大量算力。 - 改进思路:让生成函数在构建每个节点时同时返回该节点的求值结果,父节点直接拿子树结果计算自身值;最终生成的表达式树的答案无需再遍历求值。
- 改进效果):
evaluate函数的递归调用次数由 162,160 次 降至 122,542 次,降幅约 24.4%;_build函数的递归调用次数由 82,641 次 降至 54,955 次,降幅约 33.5%;- 单题生成时的求值开销明显降低,无效的重复计算被大幅消除。


改进点 ②:把“先生成后校验”改为“生成时保证合法”
- 问题:原策略下,减法极易违反“不产生负数”的约束(例如随机得到
3 - 7),一旦校验失败就丢弃整棵已生成的子树,进入循环重试,造成大量无效的树构建开销。 - 改进思路:在生成
-节点时,若发现“被减数 < 减数”,直接交换左右子树;生成/节点时,若发现“被除数 ≥ 除数”,同样交换,必要时直接丢弃该节点重试,从而保证约束天然成立。 - 改进效果:
- 程序总运行时间由 0.736 秒 下降到 0.522 秒,降幅约 29.1%;
generate_exercises累计耗时从 0.736 秒 降至 0.522 秒;_build累计耗时由 0.582 秒 降至 0.362 秒,降幅约 37.8%;evaluate累计耗时由 0.141 秒 降至 0.115 秒,降幅约 18.4%;- 总函数调用量降至约 198 万次,无效计算开销显著减少。
效能分析-第二次优化后

2.2 性能分析结果
对程序整体进行 cProfile 性能分析,得到消耗最大的函数排名已在上方显示。两次改进分别从“减少重复递归求值”和“生成时保证约束合法”两个角度切入,最终将总运行时间压缩至约 0.522 秒。
2.3 消耗最大的函数展示
def _build(operator_count: int, r: int):
"""
递归生成一棵运算符数量为 operator_count 的合法表达式树。
r 太小时可能失败,返回 None。
"""
if operator_count == 0:
v = generate_number(r)
if v is None:
return None
return ExpressionNode(value=v)
for _ in range(80):
op = random.choice(OPERATORS)
remaining = operator_count - 1
left_count = random.randint(0, remaining)
right_count = remaining - left_count
left = _build(left_count, r)
right = _build(right_count, r)
if left is None or right is None:
continue
lv = left.evaluate()
rv = right.evaluate()
if op == '-':
# 保证 e1 >= e2(不产生负数)
if lv < rv:
left, right = right, left
lv, rv = rv, lv
elif op == '/':
if rv == 0 or lv == 0:
continue
# 保证 e1 / e2 是真分数,即 e1 < e2
if lv >= rv:
left, right = right, left
lv, rv = rv, lv
if lv >= rv:
continue
return ExpressionNode(operator=op, left=left, right=right)
return None
三、设计实现过程
3.1 代码组织
| 文件 | 主要职责 | 对外接口 |
|---|---|---|
utils.py |
分数格式转换、文件读写 | fraction_to_string / parse_number / save_lines / read_lines |
expression.py |
表达式树:求值、最小括号、去重规范化 | 类 ExpressionNode,4 个方法 |
generator.py |
随机出题、约束控制、去重 | generate_number / generate_exercises,类 Exercise |
expr_parser.py |
递归下降解析 | 类 ExpressionParser |
grader.py |
批改 | grade + 内部读文件函数 |
main.py |
命令行入口 | main / run_generate / run_grade |
gui.py |
tkinter 界面 | 类 App |
- 类数量:4 个,分别是
ExpressionNode、Exercise、ExpressionParser、App。 - 独立函数数量:约 12 个,其中
utils.py4 个、generator.py3 个、grader.py3 个、main.py3 个。
3.2 模块关系图
main.py / gui.py
├── generator.py ──┐
├── grader.py ─────┤──> expression.py ──> utils.py
└── expr_parser.py ┘ └──────────────┘
expression.py仅依赖utils.py;generator.py/expr_parser.py依赖expression.py与utils.py;grader.py依赖expr_parser.py与utils.py;main.py/gui.py依赖所有业务模块。
3.3 关键函数流程图
(1)generate_exercises() 随机生成与去重流程

该函数是出题主流程:先控制题型,再检查合法性,最后通过 canonical 标准化过滤重复题,达到指定数量后返回题目集合。
(2)ExpressionNode.to_string() 运算优先级与最小括号

to_string() 会结合四则运算优先级,以及当前节点在父节点中的左右位置,判断是否必须加括号,从而在保持计算顺序不变的前提下减少冗余括号。
(3)ExpressionParser._parse_expression() 递归下降解析

_parse_expression() 通过循环与递归下降处理加减运算,并调用 _parse_term() 处理更高优先级的乘除运算,最终把输入字符串还原为表达式树。
四、代码说明
4.1 fraction_to_string —— 分数输出三种形态
def fraction_to_string(value: Fraction) -> str:
n, d = value.numerator, value.denominator
if d == 1: # 整数:8/4 -> "2"
return str(n)
if abs(n) < d: # 真分数:3/5 -> "3/5"
return f"{n}/{d}"
# 带分数:11/8 -> "1'3/8"
sign = "-" if n < 0 else ""
a = abs(n)
return f"{sign}{a // d}'{a % d}/{d}"
说明:内部计算统一使用 Fraction,输出时才转换格式。判断顺序为先整数、再真分数,剩余情况按带分数处理。这样既能简化分支,也能覆盖负数带分数,例如 -1'3/8。
4.2 ExpressionNode.evaluate —— 表达式树递归求值
def evaluate(self) -> Fraction:
if self.is_number(): # 叶子:直接返回
return self.value
lv = self.left.evaluate() # 递归左
rv = self.right.evaluate() # 递归右
if self.operator == '+':
return lv + rv
if self.operator == '-':
return lv - rv
if self.operator == '*':
return lv * rv
if self.operator == '/':
if rv == 0:
raise ZeroDivisionError("除以零")
return lv / rv
raise ValueError(f"未知运算符: {self.operator}")
说明:叶子节点直接返回数值,内部节点先递归求左右子树,再按当前运算符合并结果。该方式与树结构天然契合,并用 Fraction 保证全程无浮点误差。
4.3 ExpressionNode.to_string —— 最小括号规则
PRECEDENCE = {'+': 1, '-': 1, '*': 2, '/': 2}
def to_string(self, parent_op=None, is_right=False) -> str:
if self.is_number():
return fraction_to_string(self.value)
need_paren = False
if parent_op is not None:
my_p = PRECEDENCE[self.operator]
pa_p = PRECEDENCE[parent_op]
if my_p < pa_p: # 条件 ①
need_paren = True
elif my_p == pa_p and is_right and parent_op in ('-', '/'):
need_paren = True # 条件 ②
left = self.left.to_string(self.operator, is_right=False)
right = self.right.to_string(self.operator, is_right=True)
op_disp = {'*': '×', '/': '÷'}.get(self.operator, self.operator)
s = f"{left} {op_disp} {right}"
return f"({s})" if need_paren else s
说明:是否加括号由两种情况决定:
- 当前节点优先级低于父节点;
- 优先级相同,但当前节点是右孩子,且父运算符为
-或/,因为减法和除法不满足结合律。
这样既能保证计算顺序正确,又能去掉不必要的括号。
4.4 canonical —— 等价去重
def canonical(self) -> str:
if self.is_number():
return f"{self.value.numerator}/{self.value.denominator}"
left = self.left.canonical()
right = self.right.canonical()
if self.operator in ('+', '*'): # 仅这两者可交换
if left > right: # 按字符串排序归一化
left, right = right, left
return f"{self.operator}({left},{right})"
说明:需求要求 3+(2+1) 与 (1+2)+3 视为等价,但 1+2+3 与 3+2+1 不能视为等价。实现上只对 + / * 节点交换左右子树,不把加法展开成多元形式,因此保留了左结合造成的树形差异:
1+2+3→+(+(1,2),3)3+(2+1)→ 内层排序后 →+(+(1,2),3)✔ 判重3+2+1→+(+(2,3),1)✘ 与上面不同,不判重
4.5 ExpressionParser —— 递归下降解析
class ExpressionParser:
def parse(self): # 入口
return self._parse_expression()
def _parse_expression(self): # 加减层,最低优先级
left = self._parse_term()
while self._peek() in ('+', '-'):
op = self._next()
right = self._parse_term()
left = ExpressionNode(operator=op, left=left, right=right)
return left
def _parse_term(self): # 乘除层
left = self._parse_factor()
while self._peek() in ('*', '/'):
op = self._next()
right = self._parse_factor()
left = ExpressionNode(operator=op, left=left, right=right)
return left
def _parse_factor(self): # 括号或数字
if self._peek() == '(':
self._next()
node = self._parse_expression() # 递归回上层
assert self._next() == ')'
return node
return self._parse_number()
说明:解析器分为三层,每层对应一个优先级。上层调用下层,括号由最底层处理。这种结构能够把字符串准确还原成表达式树。
五、测试运行
5.1 测试用例
| 编号 | 测试内容 | 测试输入 / 规模 | 预期结果 |
|---|---|---|---|
| T01 | 分数格式转换 | 31、1/4、3'5/6、6'1/2 |
分别正确转换为整数、真分数、带分数、带分数 |
| T02 | 数字格式解析 | "31"、"1/4"、"3'5/6" |
正确转换为 Fraction |
| T03 | 分数往返转换 | 多组 Fraction(如图3数据) | 转换后再次解析,数值保持一致 |
| T04 | 基本表达式计算 | 1 × (4 - 1/6) × 1、7/8 - 1/2 |
分别得到 3'5/6、3/8 |
| T05 | 运算符优先级 | 3 ÷ (4 × 3)、8 × 2 + 8 + 7 |
分别得到 1/4、31 |
| T06 | 左结合规则 | 1/7 ÷ (8 - 1) + 1/2、2 × (2/3 + 5) × 1/2 |
分别得到 51/98、5'2/3 |
| T07 | 表达式去重 | 1 × (4 - 1/6) × 1 与 1 × 1 × (4 - 1/6) |
判断为重复 |
| T08 | 去重结合规则 | 7/8 - 1/2 与 1/2 - 7/8 |
判断为不重复(减法不满足交换律) |
| T09 | 数值范围 | 随机生成 10 道题,r=10 | 所有数值满足范围要求 |
| T10 | 表达式合法性 | 图2中生成的 10 道题 | 无负数、除法结果均为真分数 |
| T11 | 题目去重 | 图2生成的 10 道题 | canonical 结果无重复 |
| T12 | r=1 边界 | generate_exercises(100,1) |
在无法满足题目要求时正常报错 |
| T13 | 生成与解析闭环 | 将图2的10道题重新解析 | 重新计算结果与图3答案一致 |
| T14 | 批改全对 | 3 道正确答案(对应图3) | 全部进入 Correct |
| T15 | 批改部分错误 | 5 道题,3 对 2 错 | 正确、错误题号分别统计 |
| T16 | 等价答案 | 2/4 对 1/2 |
判断为正确 |
| T17 | 缺失答案 | 缺少某一道答案 | 对应题目判错,不发生异常 |
| T18 | 非法答案 | 答案填写 abc |
对应题目判错,不发生异常 |
测试运行截图:
运行成功:



运行失败:

5.2 正确性验证
(1)精确计算验证
程序使用 Fraction 保存和计算所有分数,不使用浮点数,从根本上避免精度损失。
(2)核心约束验证
结合图2实际生成的10道题目(如 1 × (4 - 1/6) × 1、3 ÷ (4 × 3) 等),并递归检查每个运算节点,验证了以下约束:
减法不会产生负数(如 7/8 - 1/2 结果为 3/8);
除法不会除以零(如 3 ÷ (4 × 3) 正常计算);
除法结果为真分数(如 1/7 ÷ (8 - 1) 结果为真分数);
每道题运算符数量均不超过 3 个;
(3)闭环一致性验证
将图2中生成的题目写入 Exercises.txt 后,再通过 ExpressionParser 重新解析,并调用 evaluate() 重新计算答案。若重新计算结果始终与 Answers.txt(图3)中的答案一致,则说明:
表达式生成 → to_string() → ExpressionParser → ExpressionNode → evaluate()
这一完整链路保持一致。(注:图1的自动化测试中也包含了闭环测试 T13-闭环 的 PASS 记录)。
将程序生成的题目写入 `Exercises.txt` 后,再通过 `ExpressionParser` 重新解析,并调用 `evaluate()` 重新计算答案。若重新计算结果始终与 `Answers.txt` 中的答案一致,则说明以下链路保持一致:
(4)批改功能验证
针对批改模块(T14-T18),通过代码逻辑覆盖了全对、全错、部分错误、等价分数(如 2/4 与 1/2)、缺失答案以及非法输入(如 abc)等场景。批改程序在解析用户答案时引入了异常处理机制,确保在输入不规范时不会崩溃,而是能准确区分 Correct 与 Wrong,并输出对应题号。该部分验证以逻辑走查与用例设计为主。
5.3 测试总结
测试结果表明,在已覆盖的输入范围内,程序均能得到预期结果。特别是表达式计算、题目生成约束、题目去重、生成—解析闭环以及批改功能均通过了针对性测试。需要指出的是,软件测试无法通过有限测试证明程序在所有可能输入下绝对正确,因此本节结论限定于已覆盖的测试范围。综合来看,本次结对项目已达成预期开发目标,程序质量符合作业要求。
六、项目小结
6.1 陈安东 心得体会
这次结对项目让我更具体地体会到“把需求拆成可验证的小模块”的重要性。我主要负责题目生成与约束校验。最初的想法是先完整构造表达式树,再检查合法性,但减法很容易触发负数问题,导致大量重试。后来改为在生成节点时主动调整左右子树,让约束在构造阶段就成立,效率提升明显。过程中也认真使用了 Fraction 处理分数,避免浮点误差。和梓弘结对时,我们经常互相 review 代码,他总能指出我忽略的边界情况,比如 r=1 时程序应直接报错而不是死循环。这种即时反馈比自己单独调试快很多。最大的收获是:写代码前先想清楚“这个函数要保证什么”,比事后补一堆 if 更有效。
6.2 李梓弘 心得体会
我在这次结对中主要负责解析、批改和图形界面部分。以前觉得递归下降解析比较抽象,真正动手写 ExpressionParser 后才发现,只要按优先级分层,每层只做一件事,逻辑就会清晰很多。批改功能让我意识到“输入并不总是规范的”,因此对缺失答案、非法字符都做了容错处理。图形界面使用 Tkinter,虽然不复杂,但把命令行功能搬到窗口上后,直观性提升不少。测试方面,我补充了不少边界用例,例如等价分数判对、缺少 -r 参数报错等,这些在后期帮我们避免了几个隐藏 bug。和陈安东合作很愉快:他写生成逻辑时更注重性能,我写解析时更关注鲁棒性,两人刚好互补。如果重做一次,我会更早开始写测试,而不是等功能全部完成后再补。
附录:运行说明
环境要求
- Python 3.8+;
- 仅使用标准库,无第三方依赖。
生成题目
python main.py -n 10 -r 10
# 生成 10 道 10 以内的题目
# 结果写入 Exercises.txt 与 Answers.txt
批改
python main.py -e Exercises.txt -a Answers.txt
# 结果写入 Grade.txt
图形界面
python main.py -g
参数说明
| 参数 | 含义 |
|---|---|
-n |
生成题目数量,默认 10 |
-r |
数值范围(自然数、真分数、真分数分母均 < r),生成模式必填 |
-e |
待批改题目文件 |
-a |
待批改答案文件 |
-g |
启动图形界面 |

浙公网安备 33010602011771号