第三周结对项目

结对项目——小学四则运算题目生成器

项目 内容
所属课程 软件工程
作业要求 第三周作业
作业目标 完成结对项目
项目成员 陈安东 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%;
    • 单题生成时的求值开销明显降低,无效的重复计算被大幅消除。

image

image

改进点 ②:把“先生成后校验”改为“生成时保证合法”

  • 问题:原策略下,减法极易违反“不产生负数”的约束(例如随机得到 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 万次,无效计算开销显著减少。

效能分析-第二次优化后
image

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.py 4 个、generator.py 3 个、grader.py 3 个、main.py 3 个。

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() 随机生成与去重流程

图1

该函数是出题主流程:先控制题型,再检查合法性,最后通过 canonical 标准化过滤重复题,达到指定数量后返回题目集合。

(2)ExpressionNode.to_string() 运算优先级与最小括号

图2

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

(3)ExpressionParser._parse_expression() 递归下降解析

图3

_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

说明:是否加括号由两种情况决定:

  1. 当前节点优先级低于父节点;
  2. 优先级相同,但当前节点是右孩子,且父运算符为 - 或 /,因为减法和除法不满足结合律。

这样既能保证计算顺序正确,又能去掉不必要的括号。

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 对应题目判错,不发生异常

测试运行截图:

运行成功:

image

image

image

运行失败:

image

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 启动图形界面
posted @ 2026-09-19 14:06  不转的秋月  阅读(30)  评论(0)    收藏  举报