结对项目(柯燕纯、胡俐伶)

这个作业属于哪个课程 计科24级78班-软件工程
这个作业要求在哪里 结对项目
这个作业的目标 实现一个自动生成小学四则运算题目的命令行程序,支持分数运算、表达式查重、约束校验、批量出题、自动批改;通过结对编程实践 Git/GitHub 协作流程,提升软件工程规范意识与单元测试能力。

结对项目:自动生成小学四则运算题目的命令行程序实现

项目 信息
组员1 姓名/学号 柯燕纯 / 3224004305
组员2 姓名/学号 胡俐伶 / 3224004304
GitHub 项目地址 https://github.com/Woodrinke/Woodrinke

一、PSP表格(预估)

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 20
· Estimate · 估计这个任务需要多少时间 20
Development 开发 330
· Analysis · 需求分析(包括学习新技术) 20
· Design Spec · 生成设计文档 20
· Design Review · 设计复审(和同事审核设计文档) 15
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 10
· Design · 具体设计 30
· Coding · 具体编码 150
· Code Review · 代码复审 30
· Test · 测试(自我测试,修改代码,提交修改) 55
Reporting 报告 100
· Test Report · 测试报告 30
· Size Measurement · 计算工作量 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 60
合计 450

二、效能分析

1 性能分析耗时

效能分析环节预估耗时 45 分钟,实际耗时约 40 分钟,具体分配如下:

阶段 任务 耗时(分钟)
性能采样 用 cProfile 生成 1 万道题并收集数据 5
热点定位 分析 profile.txt,定位耗时最大的函数 10
优化尝试 尝试减少 evaluate 的重复调用 15
复测对比 重新运行 cProfile,对比优化前后数据 10

2 性能分析结果

运行命令:

python -m cProfile -s tottime main.py -n 10000 -r 100 > profile.txt

程序生成 1 万道题的总耗时为 1.060 秒,平均每道题约 0.106 毫秒。从 profile 报告看,耗时排名前 8 的函数如下:
屏幕截图 2026-09-20 194510

3 消耗最大的函数

从数据看,random_expression_tree递归生成表达式树) 和 randrange随机数生成) 是程序最大的两个性能热点:
random_expression_treetottime = 0.153 秒,占程序总耗时的 14.4%。原因是它需要递归生成表达式,且因为约束校验(减法不为负、除法结果小于 1)会频繁重试。
randrange / getrandbits:二者合计消耗约 0.170 秒,占约 16.0%。这是 Python random 模块的底层实现,说明程序对随机数的依赖度很高。
random_fractiontottime = 0.042 秒,占约 4.0%,是生成数字节点的核心函数。

屏幕截图 2026-09-20 215150
火焰图更清晰地展示了调用链:main → generate_problems(0.564 秒)→ random_expression_tree(0.430 秒 cumtime)→ random_fraction / value / is_proper

4 改进思路

针对上述热点,我们提出以下优化方向:

优化 1:减少 evaluate 的重复调用
原逻辑在生成表达式后,会先在 _check_constraints 中递归求值校验,然后又在 generate 中调用 value() 得到答案。可以在约束校验通过后直接复用已求出的值,减少一次完整遍历。

优化 2:调整生成策略,减少重试次数
当前生成器采用“先随机生成、再校验、失败重试”的策略。可以改为“生成时即保证约束”:例如生成除法时,直接构造一个真分数作为结果,再反推被除数和除数,避免随机失败后的重试。

5 结论

程序生成 1 万道题仅需** 1.060 秒(总耗时),平均每道题 0.106 毫秒,性能已远超题目要求(支持万级题目生成)。主要热点集中在随机数生成递归表达式构造**上,这两者都是生成器不可避免的开销。通过上述优化思路,理论上可以进一步压缩到 1 秒以内。

三、设计实现过程

1 代码组织与模块划分

本项目采用分层设计,共 6 个源码文件,职责单一、依赖清晰:

文件 职责 依赖
fraction.py 分数类 Fraction,实现四则运算、约分、比较、字符串互转
expression.py 表达式树 Expression,实现求值、中缀输出、查重签名 fraction
parser.py 表达式字符串解析器 Parser,把字符串还原为表达式树 fraction、expression
generator.py 题目生成器,批量生成不重复题目并写入文件 expression
grader.py 批改器,读取题目/答案文件,比对并输出 Grade.txt fraction、parser
main.py 命令行入口,解析 -n/-r/-e/-a 参数,分发任务 全部

依赖关系图
main.py
├── generator.py ── expression.py ── fraction.py
└── grader.py ──── parser.py ────── expression.py ── fraction.py

2 核心类设计

(1)Fraction(分数类)

属性numerator(分子)、denominator(分母),构造时自动约分、保证分母为正。

方法

  • 四则运算:__add____sub____mul____truediv__
  • 比较运算:__eq____lt____le____gt____ge__
  • 判定真分数:is_proper()
  • 字符串互转:__str__from_string()
  • 随机生成:random_fraction(r)
(2)Expression(表达式树)

属性

  • 叶子节点:value_node(一个 Fraction
  • 内部节点:left、op、right

方法

  • is_leaf():判断是否为叶子节点
  • value():递归求值,返回 Fraction
  • __str__ / _to_string():中缀输出,根据优先级自动加括号
  • signature():查重签名,递归生成归一化元组
  • random_expression_tree(r, count):随机生成表达式树
(3)Parser(解析器)

方法

  • tokenize(s):把字符串切分为 token 序列
  • parse_expression():解析加减
  • parse_term():解析乘除
  • parse_factor():解析数字、分数、括号
    递归下降解析,保证优先级正确。
(4)Generator(生成器)
  • 函数 generate_problems(n, r):循环生成题目,用 signature() 去重,最多尝试n * 100次。
  • 函数 write_files(problems, ...):写入 Exercises.txtAnswers.txt
(5)Grader(批改器)
  • 函数 extract_expression(line) / extract_answer(line):从带编号的行中提取表达式/答案。
  • 函数 grade(exercises_path, answers_path, output_path):逐题解析求值、比对、输出 Grade.txt
(6)main.py(命令行入口)

① 用** argparse 解析参数**:

  • -n <个数>:题目数量(默认 10)
  • -r <范围>:数值范围(生成模式必须提供)
  • -e <题目文件> -a <答案文件>:批改模式

参数校验-e/-a 必须成对出现;-r < 1 报错;-n < 1 报错。

3 关键函数流程图

(1)signature() 查重签名(核心难点)

屏幕截图 2026-09-20 191808

设计要点

  • 对 + 和 × 做单层排序实现“交换律 + 左结合”,但不做跨层扁平化,所以1+2+33+2+1 不会判重(符合题目要求)。
  • 对 - 和 ÷ 保持左右顺序 ,所以 5-33-5 不会判重。
(2)random_expression_tree() 生成器

屏幕截图 2026-09-20 191502

设计要点

  • 除法优先尝试 left/right,如果不是真分数,尝试交换左右。两边都不行则重试。
  • 50 次都失败则返回 None,由 generate_problems 跳过这道题。

4 测试文件组织

共 8 个测试文件,覆盖全部模块:

文件 覆盖内容
test_fraction.py 分数类四则运算、约分、比较、字符串转换
test_random.py random_fraction 的边界(r=1、r=2)
test_expression.py 表达式树求值、随机树约束
test_expression_str.py 中缀输出、括号处理
test_signature.py 查重签名(交换律、结合律、非结合律)
test_generator.py 题目生成、去重、文件写入
test_grader.py 批改正确、批改混合
test_main.py 命令行参数校验

四 代码说明

1 分数类 Fraction:基础数据结构

分数运算是整个项目的基础。Fraction 类在构造时自动约分、规范符号,保证后续所有运算结果一定是最简分数。

class Fraction:
    def __init__(self, numerator, denominator=1):
        if denominator == 0:
            raise ValueError("分母不能为 0")
        # 统一符号:让分母始终为正
        if denominator < 0:
            numerator = -numerator
            denominator = -denominator
        # 用最大公约数约分
        g = math.gcd(abs(numerator), denominator)
        self.numerator = numerator // g
        self.denominator = denominator // g

思路说明

  • 约分逻辑用 math.gcd,一步到位,避免后续运算时反复约分。
  • 符号统一到分子(分母恒为正),这样比较、排序、判等时不会因为符号位置不同而误判。
  • 所有四则运算(__add__、__sub__、__mul__、__truediv__)都通过构造函数返回新对象,保证结果一定是最简分数

2 查重核心:Expression.signature()

题目要求“任何两道题不能通过有限次交换 +× 左右的算术表达式变换为同一道题目”。查重是项目的最大难点,我们使用表达式树归一化签名解决。

def signature(self):
    # 叶子节点:用 ("n", 分子, 分母) 统一表示
    if self.is_leaf():
        return f"V{self.value_node}"

    # 内部节点:递归求左右子树签名
    left_sig = self.left.signature()
    right_sig = self.right.signature()

    # + 和 × 满足交换律:左右签名排序后拼接
    if self.op in ("+", "×"):
        return (self.op,) + tuple(sorted([left_sig, right_sig]))

    # - 和 ÷ 不满足交换律:保持左右顺序
    return (self.op, left_sig, right_sig)

思路说明

  • 叶子节点: 返回 "v" + 分数,例如 1/2 返回 "v1/2"。所有返回值都是字符串,保证递归时类型统一,不会出现字符串和元组混用导致 TypeError
  • +× 排序: 这是实现交换律的关键。递归求左右签名后,如果左边字符串大于右边,就交换左右,然后拼接。23+4545+23 递归后得到的左右签名一致,交换后拼接结果相同,因此判重。
  • -÷ 不排序: 保持左右顺序,因此 5-33-5 不判重。
  • 对结合律的精确处理: 由于我们只做单层字符串比较,不做跨层扁平化,所以:
    • 3+(2+1)1+2+3(即 (1+2)+3)判重(因为每层字符串比较后 3 被换到后面)。
    • 1+2+33+2+1 不判重(因为左子树结构不同)。
  • 这恰好符合题目“支持交换律 + 左结合”的要求。

3 生成器:random_expression_tree()

生成器需要在随机生成的同时满足“不产生负数”“除法结果为真分数”“运算符不超过 3 个”等约束。我们采用“递归生成 + 约束校验 + 重试”策略。

def random_expression_tree(r, count):
    # 边界:r < 2 时只能生成 0
    if r < 2:
        return Expression(value_node=Fraction(0, 1))

    # 没有运算符了,生成一个叶子
    if count == 0:
        return Expression(value_node=random_fraction(r))

    op = random.choice(["+", "-", "×", "÷"])
    left_count = random.randint(0, count - 1)
    right_count = count - 1 - left_count

    for _ in range(20):
        left = random_expression_tree(r, left_count)
        right = random_expression_tree(r, right_count)

        if op == "+":
            return Expression(left=left, op=op, right=right)

        if op == "-":
            # 减法约束:保证 left >= right,否则交换
            if left.value() >= right.value():
                return Expression(left=left, op=op, right=right)
            else:
                return Expression(left=right, op=op, right=left)

        if op == "×":
            return Expression(left=left, op=op, right=right)

        if op == "÷":
            if right.value() == Fraction(0, 1):
                continue
            # 除法约束:结果必须是真分数
            result = left.value() / right.value()
            if result.is_proper():
                return Expression(left=left, op=op, right=right)
            # 尝试交换左右
            if left.value() != Fraction(0, 1):
                result2 = right.value() / left.value()
                if result2.is_proper():
                    return Expression(left=right, op=op, right=left)

       # 20 次都失败,返回随机叶子
    return Expression(value_node=random_fraction(r))

思路说明

  • 边界处理r < 2 时只能生成 0,直接返回叶子节点,避免在真分数生成上崩溃。
  • 减法约束:比较 left.value()right.value(),如果不满足>=,则交换左右再返回(相当于 a-b 变成 b-a,结果仍非负)。
  • 除法约束:优先判断 left/right 是否为真分数;不是则尝试 right/left;都不行则 continue 重试。
  • 重试上限for _ in range(50) 避免无限循环;20 次都失败返回一个随机叶子节点,由 generate_problems 层继续。
  • 运算符数量控制:通过 left_count + right_count = count - 1 严格保证运算符总数不超过 3

4 解析器:Parser.parse_expression()(递归下降)

批改器需要把 Exercises.txt 里的题目字符串还原成表达式树,用于重新求值。我们使用递归下降解析器,通过三层函数处理优先级

class Parser:
    def parse_expression(self):
        # 处理加减(最低优先级)
        left = self.parse_term()
        while self.peek() in ("+", "-"):
            op = self.next()
            right = self.parse_term()
            left = Expression(left=left, op=op, right=right)
        return left

    def parse_term(self):
        # 处理乘除(较高优先级)
        left = self.parse_factor()
        while self.peek() in ("×", "÷"):
            op = self.next()
            right = self.parse_factor()
            left = Expression(left=left, op=op, right=right)
        return left

    def parse_factor(self):
        # 处理数字、分数、括号
        tok = self.peek()
        if tok == "(":
            self.next()
            expr = self.parse_expression()
            if self.peek() != ")":
                raise ValueError("缺少右括号")
            self.next()
            return expr
        self.next()
        return Expression(value_node=Fraction.from_string(tok))

思路说明

  • 三层函数对应三级优先级parse_expression → parse_term → parse_factor,每层处理一类运算符,这是递归下降解析器的标准结构。
  • while 循环处理左结合1+2+3 会被解析为 ((1+2)+3),因为每次循环都把当前 left 作为新节点的左子树,符合左结合语义。
  • 括号递归:遇到 ( 就递归调用 parse_expression,遇到 ) 就返回,天然支持任意深度嵌套。
  • signature() 的一致性:解析出的树结构与生成器构造的树结构完全一致,因此批改器重新求值得到的答案与 Answers.txt 的标准答案能够精确比对。

五、测试运行

1 测试文件组织

本项目采用 pytest 框架,共 8 个测试文件,覆盖全部模块,累计 35 个测试用例:

测试文件 覆盖内容 用例数
test_fraction.py 分数四则运算、约分、比较、字符串转换 6
test_random.py random_fraction 边界(r=1、r=2、r=10) 1
test_expression.py 表达式树求值、随机树约束、运算符个数 6
test_expression_str.py 中缀输出、括号处理(8 种优先级场景) 8
test_signature.py 查重签名(交换律、结合律、非结合律) 6
test_generator.py 题目生成、去重、文件写入 3
test_grader.py 批改全部正确、批改混合对错 2
test_main.py 命令行参数校验(缺失 -r、非法 -r) 3
合计 35

运行命令:

python -m pytest tests/ -v

运行结果:35 个测试用例全部通过(35 passed in 0.84s)。

屏幕截图 2026-09-20 201938

2 核心测试用例(10 组)

编号 测试用例 输入 / 命令 预期输出 实际输出 结果
1 分数约分 Fraction(2, 4) 等于 Fraction(1, 2) 相等 通过
2 分数四则运算 Fraction(1,2) + Fraction(1,3) 5/6 5/6 通过
3 字符串转换 str(Fraction(3, 2)) 1'1/2 1'1/2 通过
4 表达式求值 1/2 + 1/3 5/6 5/6 通过
5 括号输出 (1/2 + 1/3) × 2 (1/2 + 1/3) × 2 一致 通过
6 查重 (交换律) 23+45 VS 45+23 判重 判重 通过
7 查重 (左结合) 3+(2+1) VS (1+2)+3 判重 判重 通过
8 查重 (不重复) 1+2+3 VS 3+2+1 不判重 不判重 通过
9 生成器去重 generate_problems(50, 10) 50 个唯一签名 50 个唯一签名 通过
10 命令行异常 python main.py -n 5 (缺 -r) 报错并打印帮助 报错并打印帮助 通过

3 为什么能确定程序是正确的?

我们从以下五个维度论证程序的正确性:

(1)数学逻辑正确
  • 分数类通过 math.gcd 约分,保证所有结果都是最简分数(test_reduce 验证)。
  • 分数比较通过通分后比较分子实现,数学上等价于浮点比较但无精度损失(test_compare 验证)。
  • 表达式求值采用递归后序遍历,符合四则运算语义(test_tree_value 验证)。
(2)约束全部满足
  • 不产生负数:减法节点强制 left >= right,否则交换左右(test_random_tree_sub_no_negative 验证)。
  • 除法结果为真分数:优先尝试 left/right,不是真分数则尝试 right/left,都不行则重试(test_random_tree_div_propercheck_div.py 双重验证 bad = 0)。
  • 运算符不超过3个:通过 left_count + right_count = count - 1 严格保证(test_random_tree_count 验证)。
(3)查重算法理论正确
  • 查重算法基于表达式树递归归一化:对 +× 排序(交换律),对 -÷ 保持顺序。
  • 3+(2+1)1+2+3 判重(左结合 + 交换律),1+2+33+2+1 不判重(不支持右结合)。
  • 测试用例 test_signature.py 覆盖所有 6 个场景,全部通过。
(4)边界场景覆盖完整
  • -r 1 边界:程序不崩溃,正常生成题目(test_random_fraction 验证)。
  • -r 0:命令行报错并退出(test_invalid_r 验证)。
  • 缺失 -r:命令行打印帮助并退出(test_missing_r 验证)。
  • 无重复签名:test_no_duplicate_signature 验证 50 道题无重复。
(5)实测无重复、字符串与解析互逆
  • 生成 1000 道题,signature 唯一数 = 1000(check_gen.py 验证)。
  • 生成 10000 道题,无重复题目,生成时间 1.060 秒(check_gen2.py 验证)。
  • 1000 个随机表达式 str()parse() 往返,signature 完全一致(check_roundtrip 验证)。

六、PSP表格(实际)

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

七、项目小结

1 项目总结

本次结对项目完成了小学四则运算题目自动生成与批改系统,实现了分数运算、表达式树查重、约束校验、命令行生成与批改全套功能。8 个测试文件、35 个用例全部通过,生成 10000 道题耗时 1.060 秒,无重复、无负数、无违规除法,完全满足题目要求。

2 结对感受

这次结对让我们体会到,遇到查重这种难点时,两个人讨论比一个人死磕高效得多。“表达式树递归排序”的思路就是我们讨论出来的,直接帮我们跳出了死胡同。以前我们习惯“先写完再测试”,但这次坚持“每写完一个模块立刻写单元测试”,让我们意识到测试驱动开发的重要性。查重那部分幸好有测试及时发现问题,否则后期返工成本很高。

3 从对方身上看到的闪光点

组员1:逻辑严谨,对查重算法、结合律语义理解透彻,能准确判断“支持左结合、不支持右结合”的边界。抽象设计能力强,把表达式树和分数封装成清晰的类。
组员2:细心负责,编写了 35 个测试用例,覆盖边界值、异常参数、文件缺失等场景。还主动增加了 check_roundtrip.py 验证字符串与解析互逆,这个思路很巧妙。

4 对队友的建议

给组员1:代码复审时可以更主动提出简化建议,比如生成器的重试逻辑可以更早优化。
给组员2:性能分析可以更早介入,早跑 cProfile 能更早发现热点函数。

5 经验与教训

成功经验:

  • 分步开发、每步验证,避免大规模返工。
  • 查重难点集中攻克,用“递归 + 单层排序”精确实现要求。
  • 边界测试覆盖完整(-r 1-r 0、缺失 -r 等)。

教训:

  • 需求理解要透彻:查重语义一开始理解错了,写了两次 signature()
  • 文档边做边写:博文素材集中在最后写,容易遗漏细节。

6 总结

次项目让我们体会到结对编程的核心价值:1 + 1 > 2。一个人的抽象设计加上另一个人的测试严谨,形成了很好的互补,让我们都学会了“先设计、再测试、后编码”的工程化流程。

更重要的是,这次项目让我们意识到:好的代码不是一个人写出来的,而是两个人一起磨出来的。从最初的需求拆解,到查重算法的反复调试,再到最后的性能分析和文档撰写,每一个环节都离不开两个人的互相审视和补位。这种“边写边讨论、边测边优化”的节奏,比一个人闷头写代码高效得多,也让我们对软件工程的“协作”二字有了更深的理解。

posted @ 2026-09-20 21:27  yc-1  阅读(14)  评论(0)    收藏  举报