第三周作业

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

郑剀元3124004075
陈梓铤3124004053

1. GitHub 项目地址

https://github.com/Zztt417/----3

项目 内容
这个作业属于哪个课程 广工2024级计算机科学与技术56班
这个作业要求在哪里 结对项目作业要求
这个作业的目标 结对完成一个自动生成小学四则运算题目的命令行程序,并支持对答题结果进行批改统计

2. PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 15 15
· Estimate · 估计这个任务需要多少时间 15 15
Development 开发 240 260
· Analysis · 需求分析(包括学习新技术) 20 25
· Design Spec · 生成设计文档 20 18
· Design Review · 设计复审 15 12
· Coding Standard · 代码规范 10 10
· Design · 具体设计 40 50
· Coding · 具体编码 80 90
· Code Review · 代码复审 20 20
· Test · 测试(自我测试,修改代码,提交修改) 35 35
Reporting 报告 40 45
· Test Report · 测试报告 15 15
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 20 25
合计 295 320

3. 效能分析

3.1 改进思路

(1) 表达式求值递归重复计算问题

问题:原先表达式树生成时,evaluate() 会递归重复计算,同一个子表达式的值被多次求值,大量浪费算力。

改进思路:修改表达式生成函数,返回 (表达式节点, 当前节点计算结果) 二元组;递归构建子树时直接拿到子树的值,父节点直接用子树结果计算自身值,最后直接拿到完整表达式的答案,无需后续遍历求值。

结果:改进前总函数调用量 286 万+;改进后总函数调用量下降至 257 万+,generate_exercises 的累计耗时降低,单次生成题目的平均耗时下降。

(2) 大量减法不满足题目约束问题

问题:原先生成表达式采用"先生成整棵表达式树,再校验合法性"的策略。减法很容易不满足题目约束,一旦校验失败就直接丢弃整棵已经生成的子树,进入循环重试,容易造成大量无效的树构建开销。

改进思路:把"先生成后校验"改成"生成时主动保证合法"。当被减数 < 减数时,提前进行交换处理保证被减数 ≥ 减数。

结果:对比优化 1 和优化 2 后的性能分析结果,总运行时间从 0.929s 下降到 0.861s,平均生成耗时从 0.518s 降到 0.451s。证明该改进降低了无用计算开销,进一步缩短了总耗时。

3.2 性能分析

对程序进行性能分析得到前 15 个消耗最大的函数排名图如下:

(此处可插入性能分析截图)

3.3 消耗最大函数展示

def _generate_pure_integer_expression(operator_count: int,
                                      r: int):
    """
    生成一棵所有叶子都是整数的表达式树。
    r 太小时可能失败,返回 None。
    """
    if r < 2:
        return None   # r=1 无法生成纯整数题(不能出现 0,只能生成 0,没法运算)

    if operator_count == 0:
        return ExpressionNode(value=Fraction(random.randint(1, r - 1), 1))

    for _ in range(50):
        operator = random.choice(['+', '-', '*', '/'])
        remaining = operator_count - 1
        left_count = random.randint(0, remaining)
        right_count = remaining - left_count

        left = _generate_pure_integer_expression(left_count, r)
        right = _generate_pure_integer_expression(right_count, r)
        if left is None or right is None:
            continue

        try:
            lv = left.evaluate()
            rv = right.evaluate()
        except ZeroDivisionError:
            continue

        if not _is_valid(operator, lv, rv):
            continue

        return ExpressionNode(operator=operator, left=left, right=right)

    return None

4. 设计实现过程

4.1 代码组织

文件 职责 对外接口
utils.py 分数格式转换 + 文件读写 fraction_to_string / parse_number / save_exercises / save_answers
fraction.py 分数类:约分、四则运算、格式化输出 Fraction 类
expression.py 表达式 AST:求值、约束校验、判重、解析与渲染 ExpressionNode 类
generator.py 题目生成器:随机生成、去重 generate_number / generate_expression / generate_exercises
grader.py 批改模块:解析题目与答案文件,比对统计 grade + 2 个内部读文件函数
main.py 程序入口,解析命令行参数,调度各模块 main / run_generate_mode / run_grade_mode

类:2 个主要类(Fraction、ExpressionNode)

独立函数:约 12 个(utils 4 个、generator 3 个、grader 3 个、main 3 个)

4.2 模块关系

main.py
   ├── generator.py ──┐
   ├── grader.py ─────┤──> expression.py ──> fraction.py ──> utils.py
   └──────────────────┘         └──────────────┘

说明:fraction.py 只依赖 utils.py,expression.py 只依赖 fraction.py + utils.py,generator.py 只依赖 expression.py + fraction.py,grader.py 依赖 expression.py + utils.py,main.py 依赖所有业务模块。

4.3 关键函数流程图

(1)generate_exercises() 随机生成与去重流程

开始
  │
  ▼
初始化题目集合、计数器
  │
  ▼
循环:生成表达式
  │
  ▼
校验表达式合法性
  │  ├── 不合法 → 重新生成
  │  └── 合法 → 计算 canonical 值
  │
  ▼
检查是否重复
  │  ├── 重复 → 重新生成
  │  └── 不重复 → 加入集合
  │
  ▼
判断数量是否达到 n
  │  ├── 否 → 继续循环
  │  └── 是 → 返回题目集合
  ▼
结束

由上图可见,generate_exercises() 是程序的核心出题函数,通过题目类型控制、合法性检查和 canonical 标准化实现随机出题与重复题目过滤,并在达到指定数量后返回题目集合。

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

开始
  │
  ▼
当前节点是数字?
  │  ├── 是 → 调用 fraction_to_string 获取当前运算符优先级
  │  │         └── 返回数字字符串
  │  └── 否 → 存在父运算符?
  │            ├── 否 → 不加括号
  │            └── 是 → 比较当前节点与父节点优先级
  │                      ├── 当前优先级 < 父优先级? → 需要加括号
  │                      └── 优先级相同 且为右子树 且父运算符为 - 或 /?
  │                            ├── 是 → 需要加括号
  │                            └── 否 → 不加括号
  ▼
递归生成左右子树字符串
  │
  ▼
拼接左右字符串和运算符
  │
  ▼
需要加括号?
  │  ├── 是 → 在结果两侧添加括号
  │  └── 否 → 直接返回结果
  ▼
返回字符串

由上图可见,ExpressionNode.to_string() 根据四则运算优先级以及节点在父节点中的左右位置判断是否需要添加括号,在保证表达式计算顺序不变的同时减少不必要的括号。

(3)ExpressionNode.evaluate() 表达式树递归求值

开始 evaluate()
  │
  ▼
是叶子节点?
  │  ├── 是 → 返回 value
  │  └── 否 → 递归左子树求值
  │            │
  │            ▼
  │         递归右子树求值
  │            │
  │            ▼
  │         根据 operator 计算结果
  │            │
  │            ▼
  │         返回计算结果
  ▼
结束

由上图可见,evaluate() 采用递归方式对表达式树进行求值,叶子节点直接返回数值,内部节点先计算左右子树再根据运算符计算自身值,天然匹配树的递归结构。

5. 代码说明

5.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 ""
    abs_n = abs(n)
    return f"{sign}{abs_n // d}'{abs_n % d}/{d}"

思路:程序内部统一用 Fraction,只在输出时才转成整数、真分数、带分数。先判整数,再判真分数,剩下一定是带分数,这样比逐一判断更简洁。

5.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}")

思路:叶子返回数值,内部节点递归求值后按运算符计算。先算左右,再算自己,天然匹配树的递归结构。

5.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, pa_p = PRECEDENCE[self.operator], 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

思路:两个条件决定是否加括号:① 自己优先级低于父;② 优先级相同且自己是右孩子、父是 - 或 /(因为减法和除法不满足结合律)。

5.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 不等价。做法:只交换 + / * 节点的左右子树,不展开成 n 元加法。- / / 不可交换。

5.5 表达式解析递归下降

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()

思路:三层函数,每层对应一个优先级。上层调用下层,括号由最底层处理。这是能把字符串准确还原成树的关键结构。

6. 测试运行

6.1 测试用例

本程序采用分层测试方法,对分数处理、表达式计算、题目生成、文件读写、表达式解析以及批改功能进行测试,并通过边界测试和随机测试验证程序的关键约束。

编号 测试内容 测试输入/规模 预期结果
T01 分数格式转换 3/5、8/4、11/8、-11/8 分别正确转换为真分数、整数、带分数和负带分数
T02 数字格式解析 "3"、"3/5"、"2'3/8" 正确转换为 Fraction
T03 分数往返转换 多组 Fraction 转换后再次解析,数值保持一致
T04 基本表达式计算 3+5、1/6+1/8 分别得到 8、7/24
T05 运算符优先级 3+5×2、(3+5)×2 分别得到 13、16
T06 左结合规则 8-3-1、8÷4÷2 分别得到 4、1
T07 表达式去重 3+5 与 5+3 判断为重复
T08 去重结合规则 1+2+3 与 3+2+1 判断为不重复
T09 数值范围 随机生成 2000 个数,r=10 所有数值满足范围要求
T10 表达式合法性 随机生成 3000 个表达式 无负数、除法结果均为真分数
T11 题目去重 生成 50 道题 canonical 结果无重复
T12 r=1 边界 generate_exercises(100,1) 在无法满足题目要求时正常报错
T13 生成与解析闭环 随机生成题目后重新解析 重新计算结果与原答案一致
T14 批改全对 3 道正确答案 全部进入 Correct
T15 批改部分错误 5 道题,3 对 2 错 正确、错误题号分别统计
T16 等价答案 2/4 对 1/2 判断为正确
T17 缺失答案 缺少某一道答案 对应题目判错,不发生异常
T18 非法答案 答案填写 abc 对应题目判错,不发生异常

6.2 正确性验证

(1)精确计算验证

程序使用 Fraction 保存和计算所有分数,不使用浮点数。

(2)核心约束验证

通过随机生成大量表达式,并递归检查每个运算节点,验证了以下功能:

  • 减法不会产生负数;
  • 除法不会除以零;
  • 除法结果为真分数;
  • 每道题运算符数量不超过 3 个;
  • 生成题目不存在重复。

(3)闭环一致性验证

将程序生成的题目写入 Exercises.txt 后,再通过 ExpressionParser 重新解析,并调用 ExpressionNode.evaluate() 重新计算答案。如果重新计算的结果始终与 Answers.txt 中的答案一致,则说明表达式生成 → to_string() → ExpressionParser → ExpressionNode → evaluate() 这一完整链路保持一致。

(4)批改功能验证

通过全对、全错、部分错误、等价分数、缺失答案和非法答案等测试,验证批改程序能够正确区分 Correct 和 Wrong,并输出对应题号。

测试总结

测试结果表明,程序在上述测试用例覆盖的输入范围内均能够得到预期结果,核心功能和主要需求约束均得到验证。特别是表达式计算、题目生成约束、题目去重、生成—解析闭环以及批改功能均通过了针对性测试。需要指出的是,软件测试无法通过有限测试证明程序在所有可能输入下绝对正确,因此本节结论限定于已覆盖的测试范围。

7. 项目小结

陈梓铤心得体会:这次作业我主要负责分数处理、表达式树和题目生成这几个核心模块。按分层来做,把分数处理、表达式树、出题各自拆开来写代码,底层用 Fraction 保证算得准,用表达式树统一搞定括号、优先级和去重。测试方面不单纯止步于只看到几十道题,而是去设计随机跑了 15000 次查约束,用 1000 道题验证"生成"和"解析"两条路算出来一致,确保在合理范围内生成的题目质量没有问题,整个运行的链路没有大问题出现。通过本次项目,我学习到了表达式树、四则运算优先级与括号处理、分数精确计算以及随机题目生成与去重等知识,熟悉了项目从需求分析、模块设计、代码实现,到性能分析、优化的完整开发流程,提高了自己解决实际问题、调试程序以及进行小型软件项目开发的能力。

郑剀元心得体会:这次作业我主要负责批改模块、命令行参数解析和测试验证这几部分。在批改模块里,需要读取题目文件和答案文件,逐题比对并统计正确与错误的题号,还要处理缺失答案、非法答案、等价分数(如 2/4 与 1/2)这些边界情况,让我对文件读写和异常处理有了更深的体会。测试方面,我设计了 18 组测试用例,覆盖分数转换、优先级、左结合、去重规则、批改统计等场景,并配合搭档完成生成—解析闭环的一致性验证。通过这次结对项目,我初步掌握了模块化设计的思想,也锻炼了与搭档沟通协作、共同定位问题的能力。从需求分析、模块设计、代码实现到测试验证和性能优化,我完整地走了一遍小型软件开发流程,收获很大。

posted @ 2026-09-21 10:47  zkyy  阅读(18)  评论(0)    收藏  举报