结对项目(段翔晖,王尉人)

姓名 学号 GitHub
段翔晖 3124004167 https://github.com/zayuchips/3124004167-pair-project
王尉人 3124004180 https://github.com/akcow

这个作业属于哪个课程 计科24级78班 软件工程
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703
这个作业的目标 两人合作完成一个「自动生成小学四则运算题目并判卷」的命令行程序,练习模块化设计、单元测试、性能分析与 PSP 流程

程序语言:Python 3(只用标准库)| 代码目录:仓库根目录下 3124004167/


一、PSP 表格(开工前的预估)

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

开工前我们对任务做了拆解:先把「题目要求」逐条抄下来变成一张清单(-n-r、不许出负数、除法结果必须是真分数、运算符不超过 3 个、题目不能重复、写两个文件、支持一万道、判卷输出 Grade.txt),再按「表达式生成 / 判重 / 文件读写 / 判卷 / 性能」五块分工。事后看,这个拆解对预估帮助很大,但测试具体编码两项仍然超了。


二、效能分析

2.1 怎么采样的

在命令行下用 Python 自带的 cProfile 跑一万道题(就是需求 9 要求的那一万道),只看我们自己写的模块:

python3 profile_report.py

这个脚本会做三件事:真的生成一万道题、打印「自身耗时 tottime」和「累计耗时 cumtime」两张排名表、再把排名画成一张图(profile_chart.png)。

2.2 采样结果

在生成一万道题、用时 0.40 秒 的那次采样里,自身耗时(tottime)排名前 8 是:

函数 自身耗时(秒) 占比 调用次数
build_tree (expression.py) 0.036 9.0% 10028
new (fractions.py) 0.031 7.7% 66746
_randbelow_with_getrandbits (random.py) 0.030 7.5% 119479
builtins.pow 0.027 6.9% 60148
format_fraction (fraction_utils.py) 0.024 6.1% 40046
randrange (random.py) 0.023 5.7% 94008
hash (fractions.py) 0.021 5.2% 60148
random_operand (expression.py) 0.019 4.7% 38950

累计耗时(cumtime)排名前几:

函数 累计耗时(秒) 说明
make_problems (generator.py) 0.394 出题主流程,占了几乎全部时间
random_expression (expression.py) 0.231 摇一道题的总开销(含重摇)
build_tree (expression.py) 0.217 递归造表达式树
random_operand (expression.py) 0.074 生成操作数
apply_op (expression.py) 0.043 顺手算当前节点的值

消耗最大的函数:build_tree 自身耗时第一,累计耗时也仅次于它的上层函数;它是递归造表达式的核心,所有约束检查(减法不能出负数、除法的商必须是真分数)都在里面完成,一次造不成还要重摇。相对「冤枉」的开销是 randomFraction 的内部调用——一万道题要摇出 10 万个随机数、构造 6 万多个 Fraction,这部分是标准库的固有成本。

性能分析图(左:自身耗时;右:累计耗时):

!性能分析图:左为自身耗时 tottime,右为累计耗时 cumtime(cProfile 采样,一万道题

性能分析图:左为自身耗时 tottime,右为累计耗时 cumtime(cProfile 采样,一万道题

2.3 为了提速做了什么

  1. 判重从「逐个比较」改成「规范化指纹 + 集合」。 一开始判重是把新题目和已经出过的题目一条条比:这是 O(n²)。实测 3000 道题做一次朴素判重需要 152 毫秒,换成集合后只要 5.2 毫秒,快了约 29 倍;按平方增长推算,一万道题时朴素做法要 1.7 秒左右,已经超过现在整个程序 0.4 秒的总耗时了。
  2. 数值一律用 fractions.Fraction,不用浮点数。 浮点数在小数上是近似的:0.1 + 0.2 得到 0.30000000000000004,作业原文的例子 1/6 + 1/8 用浮点算出来是 0.29166666666666663,而用 Fraction 得到的是精确的 7/24,输出到答案文件时正好是题目要求的写法。这一条不是「更快」,而是「更对」——分值最高的 20 分是功能分,答案错一切白搭。
  3. 约束前置到生成阶段。 不是先生成一大堆再筛掉,而是在 build_tree 造每个节点时就判断:减法若 e1 < e2 就把左右子树换过来,除法若不满足 0 < 被除数 < 除数 就直接重摇这个节点。这样返工量小,一万道题很快就能凑齐。
  4. 顺手做了一层「别太水」的筛选。 第一版会出现 0 × 1/3 × 6 × 7 =2 × 1 = 这种题,虽然合法但没营养,于是当 -r >= 4 时,加法不要 0、乘法不要 0 和 1、减法不要 0 和被减数相等的情况。范围很小(比如 -r 2)时不做这层筛选,否则根本出不了题。

三、设计实现过程

3.1 模块划分

代码按「一个模块一件事」拆成 5 个文件 + 1 个测试文件:

文件 职责 对外主要接口
main.py 命令行入口:解析 -n / -r / -e / -a,决定出题还是判卷,检查参数合法性 main(argv)
expression.py 表达式核心:随机生成、求值、判重指纹、与文字互转 random_expression / build_tree / expression_key / to_text / parse_tree / evaluate_text
generator.py 出题:去重、限次、写 Exercises.txtAnswers.txt make_problems / write_problems
checker.py 判卷:读题目与答案文件、逐题比对、写 Grade.txt check
fraction_utils.py 分数的文字格式(3/52'3/8)与反向解析 format_fraction / parse_fraction
tests/test_program.py 35 个单元测试

3.2 表达式的表示:元组树

这是整个程序的地基。一个算式在内存里就是一棵树:

叶子节点:(值,) 例如 (Fraction(1, 2),) 表示 1/2

内部节点:(运算符, 左子树, 右子树) 例如 ("÷", (Fraction(3,4),), (...)) 表示 3/4 ÷ ...

为什么不用字符串拼?因为括号关系天生就在结构里。如果存字符串 "3/4 ÷ (1/2 + 1/4)",那么「谁先算」要靠解析才知道;存成树之后:

  • 求值=递归一趟,不会算错优先级;
  • 判重=比较树形,不用去猜两个字符串是不是同一个题;
  • 打印=按优先级决定加不加括号,规则只有两条。

3.3 关键流程

出题流程:

用户输入 -n 10 -r 10

└── main.generate()

├── generator.make_problems(10, 10)

│ └── 循环:expression.random_expression(10)

│ ├── build_tree(r, 运算符个数 1~3) ← 边造边验约束,不合规就重摇

│ ├── expression_key(tree) ← 规范化指纹

│ └── 指纹已在集合里?丢弃重摇;否则记下

└── generator.write_problems()

├── Exercises.txt: 1. 8 + 5/9 =

└── Answers.txt: 1. 8'5/9

判卷流程:

main.grade() → checker.check(题目文件, 答案文件)

└── 逐行:去掉行首题号

├── parse_tree(题目) → tree_value(树) ← 自己重算一遍标准答案

├── parse_fraction(答案) ← 支持 5、3/5、2'3/8

└── 相等 → 记入 Correct;否则/看不懂 → 记入 Wrong

└── Grade.txt: Correct: 8 (1, 2, 4, 5, 6, 8, 9, 10)

Wrong: 2 (3, 7)

判卷时是程序自己重算一遍答案,而不是去比对答案文件——这样即使答案文件被改坏(第 8 个用例就是这么测的),也能判出对错。

3.4 一个容易忽略的坑:打印括号的规则

生成时树是对的,但打印成文字时如果括号省错了,题面就变了。最典型的例子:

树:("×", 6, ("÷", 1/2, 4/7))

正确打印:6 × (1/2 ÷ 4/7) =

错误打印:6 × 1/2 ÷ 4/7 = ← 按从左到右算,(6×1/2) ÷ 4/7 = 21/4,已经不是真分数

所以 to_text 的规则定为:我的优先级比爹低(如 (1+2)×3)或者 我是右孩子且和爹同级(如 a-(b+c)a÷(b×c)6×(1/2÷4/7))时必须加括号;左孩子同级不用加,因为文字按同样的规则解析回来还是同一棵树。这条规则写成了测试用例,见第五节第 12 条。


四、代码说明

挑 4 段最关键的代码。完整代码见(仓库 3124004167/ 目录)。

4.1 判重:只交换 + 和 × 的左右,不重新结合

def expression_key(tree):
    """判重用的「规范化指纹」。
    题目规定:+ 和 × 的左右两个操作数可以互换,互换后算同一个题目。
    所以只要在每一层把 +、× 的两棵子树按同一套规则排好序,
    两个等价题目的指纹就会完全一样,直接放进 set 就能去重。
    """
    if len(tree) == 1:
        return ("数值", tree[0])
    op, left, right = tree
    first, second = expression_key(left), expression_key(right)
    if op in ("+", "×") and second < first:
        first, second = second, first
    return (op, first, second)

思路:作业原文说「任何两道题目不能通过有限次交换 + 和 × 左右的算术表达式变换为同一道题目」。这句话翻译成程序就是——把每个 +× 节点的两棵子树排序,再看两棵树是否完全一样。于是等价题目得到同一个指纹,放进 set 就完成了去重,判重从「两两比较」变成了「查一次集合」。

注意这里故意不做「加法可以重新结合」的化简1+2+33+2+1 的树形分别是 (1+2)+3(3+2)+1,只交换左右的规则无法把一个变成另一个,所以它们不算重复;而 3+(2+1) 经过一次交换就变成 (1+2)+3,算重复。这和作业原文举的两个例子完全一致。

4.2 生成:一边造树一边守三条约束

def build_tree(r, operators, tries=BUILD_TRIES):
    """随机造一棵运算符个数恰好为 operators 的表达式树;条件凑不齐返回 None。"""
    if operators == 0:
        value = random_operand(r)
        return (value,), value

    for _ in range(tries):
        op = random.choice(OPERATORS)
        left_count = random.randrange(operators)          # 0 ~ operators-1 个
        right_count = operators - 1 - left_count

        left = build_tree(r, left_count, tries)
        if left is None:
            continue
        right = build_tree(r, right_count, tries)
        if right is None:
            continue

        left_tree, left_value = left
        right_tree, right_value = right
        if op == "-":
            if left_value < right_value:                  # 交换左右,保证 e1 >= e2
                left_tree, right_tree = right_tree, left_tree
                left_value, right_value = right_value, left_value
        elif op == "÷" and not 0 < left_value < right_value:
            continue          # 要求“被除数 > 0 且 商 < 1”,也就是商必须是真分数

        if r >= TRIVIAL_GUARD and not TRIVIAL_RULESop:
            continue          # 范围够大,就不要 “× 1”“+ 0” 这种没营养的位置

        return (op, left_tree, right_tree), apply_op(op, left_value, right_value)

    return None

思路:运算符的个数由递归参数 operators 决定(最多 3 个),左右子树的运算符个数随机分配。每造完一个节点就检查:

  • 减法:要求 e1 >= e2,所以 left < right 时直接把左右子树换过来——这比「重摇」划算得多;
  • 除法:要求商是真分数,也就是 0 < 被除数 < 除数,不满足就重摇这个节点(顺便排除了除数为 0);
  • 出不来就返回 None,让上一层再试;试满 tries 次还是不行就往上传,最终由 make_problems 决定是继续还是收工。

4.3 判卷:自己重算一遍答案

correct, wrong = [], []
for index, (exercise, answer) in enumerate(zip(exercises, answers), start=1):
    try:
        expected = evaluate_text(_strip_index(exercise).rstrip("="))
        given = parse_fraction(_strip_index(answer))
    except (ValueError, ZeroDivisionError):
        wrong.append(index)        # 抄错或者看不懂的答案,一律算错
        continue
    if expected == given:
        correct.append(index)
    else:
        wrong.append(index)

with open(grade_file, "w", encoding="utf-8") as handle:
    handle.write(f"Correct: {len(correct)} ({', '.join(map(str, correct))})\n")
    handle.write(f"Wrong: {len(wrong)} ({', '.join(map(str, wrong))})\n")

思路:题目文件里只有算式(如 3. 8 - (4 + (2 + 1/2)) =),所以判卷时把算式解析成树、重新算一遍标准答案,再和学生答案比。学生答案允许三种写法:53/52'3/8(中文排版的 2’3/8 也认)。行首题号、多余空格、行尾等号都用正则或解析器顺手去掉,这样别人交来的格式略有差异也能判。输出格式严格按作业要求:Correct: 8 (1, 2, 4, 5, 6, 8, 9, 10)

4.4 命令行:-r 不给就报错并给帮助信息

def generate(args, parser):
    """生成题目模式。"""
    if args.r is None:
        print("错误:必须用 -r 参数指定数值范围,例如 python3 main.py -n 10 -r 10")
        parser.print_help()
        return 1
    if args.r < 1:
        print("错误:-r 必须是大于等于 1 的自然数")
        return 1

    count = DEFAULT_COUNT if args.n is None else args.n
    if count < 1:
        print("错误:-n 必须是大于等于 1 的自然数")
        return 1

思路:作业要求「-r 必须给定,否则程序报错并给出帮助信息」,正好对应 Python 的 argparse-r 不设默认值,缺了就自己打印一句人话错误 + parser.print_help(),并返回退出码 1(方便脚本判断)。-n 不填时默认生成 10 道题。另外 -e-a 必须成对出现,只给一个也会报错并提示正确用法。


五、测试运行

5.1 自动化测试

35 个单元测试,一条命令跑完:

python3 -m unittest discover -s tests -v

结果:

Ran 35 tests in 0.364s

OK

5.2 12 个测试用例

编号 测试内容 输入(命令 / 操作) 预期结果 实际结果
1 基本生成 python3 main.py -n 10 -r 10 两个文件各 10 行,题面以 n. 算式 = 结尾 通过,见 5.3
2 只给 -r 时默认道数 python3 main.py -r 10 生成 10 道题 通过:共生成 10 道题目
3 -r 报错 python3 main.py -n 10 报错 + 帮助信息,退出码 1 通过,见 5.3
4 -r 非法 python3 main.py -n 10 -r 0 提示范围必须 ≥ 1,退出码 1 通过
5 -n 非法 python3 main.py -n 0 -r 10 提示个数必须 ≥ 1,退出码 1 通过
6 判卷参数不全 python3 main.py -e Exercises.txt 提示 -e-a 要成对使用,退出码 1 通过
7 全部答对 用刚生成的 Answers.txt 判卷 Correct: 10 (1, 2, ..., 10) / Wrong: 0 () 通过
8 部分答错 样例目录里故意改错第 3、7 题 Correct: 8 (...) / Wrong: 2 (3, 7) 通过,见 5.3
9 判卷容错 题面多打空格、撇号写成中文 仍然判对 通过(test_grade_tolerates_spaces_and_chinese_apostrophe
10 约束:不出现负数 生成 300 道题逐题检查每个减法子表达式 全部 e1 >= e2 通过
11 约束:除法商是真分数 同上,检查每个除法 全部满足 0 < 被除数 < 除数 通过
12 约束:运算符 ≤ 3 且括号不丢 生成 300 道题,把题面解析回树再求值 题面能算出答案文件里的值;6 × (1/2 ÷ 4/7) 这类括号必须保留 通过
13 判重规则 直接比较 3+(2+1)1+2+33+2+11×22×1 的指纹 前两个相同;3+2+1 与它们不同;1×22×1 相同 通过
14 小范围不崩 python3 main.py -n 5 -r 1-r 2 正常生成不报错 通过
15 凑不出时收工 python3 main.py -n 10000 -r 1 提示范围太小,给出实际生成的题数,不死循环 通过:只生成 84 道并给出提示
16 一万道题 python3 main.py -n 10000 -r 100 一万行、秒级完成 通过:0.20 秒,见 5.3

5.3 实际运行记录

生成 10 道 10 以内的题目:

$ python3 main.py -n 10 -r 10

题目已写入 Exercises.txt,答案已写入 Answers.txt

共生成 10 道题目,用时 0.00 秒

Exercises.txt

  1. 8 + 5/9 =
  2. 2/3 - 1/3 ÷ 3/4 =
  3. 8 - (4 + (2/3 + 1/2)) =
  4. 6 - (1/2 + 2/3) =
  5. 6 + 1/7 - (4 - 1/2) =
  6. 1/2 ÷ 2/3 =
  7. 1/6 × (1/2 - (6/7 - 3/5)) =
  8. 2/5 × 1/2 + 3/4 =
  9. 1/4 × 8 =
  10. (3 + 2/9) ÷ 9 + 1/3 =

Answers.txt

  1. 8'5/9
  2. 2/9
  3. 2'5/6
  4. 4'5/6
  5. 2'9/14
  6. 3/4
  7. 17/420
  8. 19/20
  9. 2
  10. 56/81

判卷(样例里故意答错第 3、7 题):

$ python3 main.py -e 样例/Exercises.txt -a 样例/我的答案.txt

判卷完成,结果已写入 Grade.txt

正确 8 题,错误 2 题

Grade.txt

Correct: 8 (1, 2, 4, 5, 6, 8, 9, 10)

Wrong: 2 (3, 7)

一万道题:

$ python3 main.py -n 10000 -r 100

题目已写入 Exercises.txt,答案已写入 Answers.txt

共生成 10000 道题目,用时 0.20 秒

-r 时报错并给出帮助信息:

$ python3 main.py -n 10

错误:必须用 -r 参数指定数值范围,例如 python3 main.py -n 10 -r 10

usage: Myapp [-h] [-n 数量] [-r 范围] [-e 题目文件] [-a 答案文件]

自动生成小学四则运算题目的命令行程序

-n 数量 生成题目的个数(不填默认 10)

-r 范围 题目中数值(自然数、真分数和真分数分母)的范围,必须给定

-e 题目文件 待批改的题目文件,需与 -a 一起使用

-a 答案文件 待批改的答案文件,需与 -e 一起使用

(退出码 1)

5.4 为什么能确定程序是正确的

  1. 答案不是「看着对」,而是能互相验证:每道题都把题面文字重新解析成树、再算一遍,必须等于答案文件里的答案(用例 12)。这一条同时验证了「求值正确」和「括号没丢」,一旦打印括号的规则写错,这一条立刻报红。
  2. 约束是逐条检查的,不是抽查:负数、除法商不是真分数、运算符超过 3 个、操作数超出 -r 范围,都在 300 道题上逐题验证过。
  3. 判重规则有作业原文的两个例子做锚点3+(2+1)1+2+3 必须判为重复,3+2+11+2+3 必须判为不同——这是原文写明的,程序的行为与之一致(用例 13)。另外还检查了题目文件里没有两行完全相同的算式。
  4. 边界与异常都测了-r 1-r 2-n 10000 -r 1(凑不齐)、缺参数、参数非法、文件不存在、答案文件被改坏——不崩、不死循环、给得出人话提示。
  5. 有理数运算本身精确:内部全部用 fractions.Fraction,不存在浮点误差,所以「答案对不对」这件事是确定的,不是近似的。

六、PSP 表格(实际耗时)

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

实际比预估多了 140 分钟,多出来的部分几乎都在「具体编码」和「测试」两栏:括号打印规则和判重规则各返工了一次(见下面小结)。


七、项目小结

7.1 做得好的地方

  1. 先把要求翻译成清单,再动手写代码。 需求里的「不能产生负数」「除法的结果必须是真分数」「运算符不超过 3 个」「不能重复」这几条,都是先写成测试检查项,再去写生成逻辑,避免了「先写出来再说」。
  2. 表达式用树表示,一次性解决了三个问题。 求值不用管优先级、判重不用比字符串、打印括号有明确规则。中途虽然因为树的打印规则返工过一次,但整体上这个选择省下了大量调试时间。
  3. 判卷独立重算答案。 不去信任答案文件,而是自己算一遍,所以答案文件被改坏、格式不统一都能处理。
  4. 性能不是「感觉快」,而是有数据。cProfile 采样,知道 build_tree 是热点,也知道判重改成集合到底快了多少(3000 道题从 152 毫秒降到 5.2 毫秒)。
  5. 一万道题真的跑得动-n 10000 -r 100 实测 0.2 秒,满足需求 9。

7.2 踩过的坑

  1. 括号省过头,题面就变了。 最初打印时只在「优先级低于父节点」时加括号,结果 6 × (1/2 ÷ 4/7) 被印成 6 × 1/2 ÷ 4/7,按从左到右算那一步的商不是真分数。改成「右孩子同级必须加括号」后,并且加了一条测试用例专门盯这个形状。
  2. 判重一开始想复杂了。 曾打算把加减乘除的等价形式都展开(甚至考虑过乘法分配律),后来回到作业原文:只允许交换 +× 的左右。规则简单了,实现也从「两两比较」变成了「查集合」。
  3. 浮点数差点埋雷。 早期用 float 存分数,1/6 + 1/8 会算出 0.29166666666666663,输出格式也不对。换成 Fraction 之后,答案永远是最简分数,格式转换只用处理「整数/真分数/带分数」三种情况。
  4. 范围太小的极端输入。 -n 10000 -r 1 时只有 0 能用,理论上凑不出 10000 道互不重复的题目。如果不设上限,程序会一直转。最后改成「连续 20000 次摇不出新题就收工」,并在终端说明实际生成了多少道(84 道)。

7.3 结对感受

说明:下面这段需要两人一起补充真实感受,这里先给出框架,替换成自己的话再提交。

两人的分工大致是:一人主攻「表达式生成 + 判重」,另一人主攻「文件读写 + 判卷 + 测试」,接口先定好(随机生成一道题 → (题面文字, 答案文字)判卷(题目文件, 答案文件) → 对错题号),之后各自写各自的模块,每完成一块就互相读一遍代码。

感受:两个人一起看代码,确实比一个人看得清楚。 上面第 1 个坑(括号)就是互查时发现的——自己写的时候一直觉得「不加括号也差不多」,对方拿作业原文一对,立刻发现那一步的商已经不是真分数了。此外「一个人写、一个人盯需求清单」的方式很有效,避免写着写着漏掉某条要求。

7.4 对彼此的闪光点和建议

  • 队友的闪光点:对项目的需求拆解细致、测试用例设计得很全面。
  • 我给队友的建议:接口可以先写注释再去实现、提交注释要写清楚改了什么。
  • 队友对我的建议:建议我动手写生成逻辑之前,先把边界情况(比如 -r 1、-n 10000)列成清单再写,免得写完才发现题目凑不出来;另外建议我每次提交前先自己跑一遍出题和判卷流程,确认推上去的代码是能跑的。

7.5 经验与教训

一次能跑通的代码不是终点,「每条要求都有对应用例守着」才是。

这次真正省时间的两个决定,一个是先用 Fraction 把「答案正确」变成确定的事,另一个是把判重从 O(n²) 换成集合。

相反的教训是:打印格式这类「看起来无关紧要」的地方最容易出错,因为错了程序也不报错,只是题目悄悄变了意思——所以给它单独写了一个测试用例。

posted @ 2026-09-21 09:52  zayu  阅读(1)  评论(0)    收藏  举报