第三周结对项目

第三次作业-结对项目

1. Github项目地址

参与人员:许艺琳-3224004083,兰羽-3224004079

Github项目地址:https://github.com/lanzero-seven/3224004079_twinning_project.git

这个作业属于哪个班级 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15694
这个作业的目标 结对完成题目为自动生成小学四则运算题目的图像界面程序。

2. PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 10 10
· Estimate · 估计这个任务需要多少时间 10 10
Development 开发 195 192
· 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
Reporting 报告 30 65
· Test Report · 测试报告 15 25
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 10 35
合计 235 267

3. 效能分析

3.1 改进思路

(1)evaluate() 递归重复计算问题

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

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

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

微信图片_20260917202822_231_2

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

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

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

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

3.2 性能分析

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

top_functions

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
expression.py 表达式树 1 个类*ExpressionNode,4 个方法
generator.py 随机生成 + 约束 + 去重 generate_number / generate_expression / generate_exercises,1 个类 Exercise
expr_parser.py 递归下降解析 1 个类ExpressionParser
grader.py 批改 grade + 2 个内部读文件函数
main.py 命令行入口 main / run_generate_mode / run_grade_mode
gui.py tkinter 界面 1 个类 App

:4 个(ExpressionNodeExerciseExpressionParserApp

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

4.2 模块关系

main.py / gui.py
   ├── generator.py ──┐
   ├── grader.py ─────┤──> expression.py ──> utils.py
   └── expr_parser.py ┘         └──────────────┘

说明:expression.py 只依赖 utils.pygenerator.pyexpr_parser.py 只依赖 expression.py + utils.pygrader.py 依赖 expr_parser.py + utils.pymain.py / gui.py 依赖所有业务模块。

以下展示关键函数的流程图:

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

作业3流程图1

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

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

作业3流程图2

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

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

屏幕截图 2026-09-18 150620

有图可见,_parse_expression() 采用循环和递归下降方式解析加减运算,并通过调用 _parse_term() 处理更高优先级的乘除运算,从而将输入字符串正确构造成表达式树。

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

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

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

6. 测试运行

6.1 测试用例

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

编号 测试内容 测试输入/规模 预期结果
T01 分数格式转换 3/58/411/8-11/8 分别正确转换为真分数、整数、带分数和负带分数
T02 数字格式解析 "3""3/5""2'3/8" 正确转换为 Fraction
T03 分数往返转换 多组 Fraction 转换后再次解析,数值保持一致
T04 基本表达式计算 3+51/6+1/8 分别得到 87/24
T05 运算符优先级 3+5×2(3+5)×2 分别得到 1316
T06 左结合规则 8-3-18÷4÷2 分别得到 41
T07 表达式去重 3+55+3 判断为重复
T08 去重结合规则 1+2+33+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/41/2 判断为正确
T17 缺失答案 缺少某一道答案 对应题目判错,不发生异常
T18 非法答案 答案填写 abc 对应题目判错,不发生异常

6.2 正确性验证

(1)精确计算验证

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

(2)核心约束验证

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

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

(3)闭环一致性验证

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

(4)批改功能验证

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

测试总结

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

7. 项目小结

许艺琳心得体会:这次作业我按分层来做,把分数处理、表达式树、出题、解析、批改和界面各自拆开来写代码,底层用 Fraction 保证算得准,用表达式树统一搞定括号、优先级和去重。测试方面不单纯止步于只看到几十道题,而是去设计随机跑了 15000 次查约束,用 1000 道题验证“生成”和“解析”两条路算出来一致,再拿 7 组边界情况测批改,确保在合理范围内生成的题目质量没有问题,整个运行的链路没有大问题出现。

兰羽心得体会:经过本次项目,学习到的新知识有表达式树、四则运算优先级与括号处理、分数精确计算以及随机题目生成与去重等知识,初步掌握了Tkinter程序界面的实现。同时,与同事积极合作交流,锻炼了口头表述能力,通过熟悉项目从需求分析、模块设计、代码实现,到性能分析、优化和程序测试的完整开发流程。提高了自己解决实际问题、调试程序以及进行小型软件项目开发的能力。

posted @ 2026-09-19 12:16  zero_7(兰羽)  阅读(42)  评论(0)    收藏  举报