结对项目

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

项目 内容
这个作业属于哪个课程 计科24级56班
这个作业要求在哪里 结对项目
这个作业的目标 结对完成一个自动生成小学四则运算题目的命令行程序,实践需求分析、设计、编码、测试与性能分析
姓名 学号 GitHub 仓库
陈建志 3124007214 https://github.com/DexJocelyn/PairWork
陈信儒 3124004160 https://github.com/xingwangxiang/PairWork

一、PSP 表格

在开始编码前,我们对项目各阶段所需时间进行了预估;项目完成后,再根据实际结对开发过程补充实际耗时。记录如下:

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 40
· Estimate · 估计这个任务需要多少时间 30 40
Development 开发 630 745
· Analysis · 需求分析(包括学习新技术) 60 90
· Design Spec · 生成设计文档 30 25
· Design Review · 设计复审(和队友审核设计) 30 20
· Coding Standard · 制定代码规范 20 15
· Design · 具体设计 60 70
· Coding · 具体编码 300 360
· Code Review · 代码复审 40 45
· Test · 测试、修改代码并提交 90 120
Reporting 报告 75 95
· Test Report · 测试报告 30 40
· Size Measurement · 计算工作量 15 10
· Postmortem & Process Improvement Plan · 事后总结并提出改进计划 30 45
合计 735 880

从结果来看,实际总耗时比预估多 145 分钟。差异主要集中在编码和测试两个阶段:编码比预估多 60 分钟,主要花在除法真分数约束与交换律去重的边界情况处理上;测试比预估多 30 分钟,是因为要构造足够的边界用例来验证「交换律等价」这类容易出错的逻辑。需求分析也比预估多 30 分钟——「任何两道题不能通过有限次交换 + 和 × 变换为同一道题」这句话(例如 1+2+3 与 3+(2+1) 等价、但与 3+2+1 不等价)需要仔细推敲才能转化为可实现的标准。这说明我在开始项目前低估了理解题意和约束边界所需的时间,今后会在需求分析阶段投入更多精力,并在编码前先厘清关键约束的数学定义。

二、项目需求分析

本项目需要实现一个自动生成小学四则运算题目的命令行程序。题目中的数字包括自然数和真分数(如 3/5),运算结果可能出现带分数(如 2'3/8)。核心需求如下:

  1. -n 参数控制生成题目的个数;
  2. -r 参数控制题目中数值(自然数、真分数及分母)的范围,必填,缺失时报错并给出帮助信息;
  3. 计算过程中不能产生负数,即 e1 − e2 需满足 e1 ≥ e2;
  4. 若存在 e1 ÷ e2 子表达式,其结果必须是真分数,即 0 < e1 < e2;
  5. 每道题中出现的运算符个数不超过 3 个;
  6. 一次运行生成的题目不能重复,即任何两道题不能通过有限次交换 + 和 × 左右表达式变换为同一道题;
  7. 题目存入当前目录的 Exercises.txt;
  8. 计算所有题目的答案并存入 Answers.txt(如 1/6 + 1/8 = 7/24);
  9. 支持一万道题目的生成;
  10. 支持 -e <题目文件> -a <答案文件> 判分,结果写入 Grade.txt。

运行示例:

python main.py -n 10 -r 10          # 生成 10 道题,数值范围 10 以内
python main.py -e Exercises.txt -a Answers.txt   # 判分

除基本功能外,程序还需要处理参数缺失、-r 范围过小导致无法生成足够题目、题目文件与答案文件数量不一致等异常情况。

三、计算模块接口的设计与实现

3.1 项目结构

项目采用模块化结构,核心逻辑与入口、测试相互分离:

PairWork/
├── arithmetic.py          # 核心模块:分数格式化、表达式树、生成器、解析器、判分
├── main.py                # 命令行入口
├── tests.py               # 22 个测试用例
├── README.md              # 运行说明
└── .gitignore

程序运行部分只使用 Python 标准库 fractions、argparse 和 random,评测环境无需安装任何第三方运行依赖。

3.2 模块接口

类 / 函数 功能
Expression 表达式树节点,op 为运算符,叶子节点存 Fraction 值,构造时即求值
Generator 题目生成器,gen_expr 递归生成表达式,generate 负责去重与批量生成
frac_to_str(f) 分数转字符串(整数 / 3/5 / 2'3/8)
canonical(node) 计算交换律去重的规范键
parse_expression(s) 将题目字符串解析为表达式树(判分用)
parse_answer(s) 将答案字符串解析为 Fraction
write_problems(list) 将题目与答案写入 Exercises.txt / Answers.txt
judge(ex, ans) 比对题目与答案,输出 Grade.txt
main() 解析命令行参数,分发生成 / 判分

模块之间的调用关系如下:

main()
 ├── Generator.generate()          # 生成模式
 │    ├── gen_expr()               # 递归生成表达式
 │    │    └── random_number()     # 生成自然数 / 真分数叶子
 │    ├── canonical()              # 计算去重键
 │    └── write_problems()         # 写文件
 │         └── to_str() / frac_to_str()
 └── judge()                       # 判分模式
      ├── parse_expression()
      ├── parse_answer()
      └── (写入 Grade.txt)

生成器的主流程如下:

3.3 核心算法

1. 分数表示

程序统一使用 fractions.Fraction 表示所有数字,保证四则运算全程精确、无浮点误差。输出时还原成作业要求的三种格式:

def frac_to_str(f: Fraction) -> str:
    if f.denominator == 1:
        return str(f.numerator)                 # 整数
    if f.numerator < f.denominator:
        return f"{f.numerator}/{f.denominator}" # 真分数 3/5
    whole, rest = divmod(f.numerator, f.denominator)
    if rest == 0:
        return str(whole)
    return f"{whole}'{rest}/{f.denominator}"    # 带分数 2'3/8

2. 表达式树

每道题表示为一棵二叉树。叶子节点存数字,内部节点存运算符,构造节点时立即用 Fraction 求出该子树的值,供后续约束判断使用:

class Expression:
    __slots__ = ('op', 'left', 'right', 'value')
    def __init__(self, op=None, left=None, right=None, value=None):
        self.op = op
        self.left = left
        self.right = right
        if op is None:
            self.value = value
        else:
            self.value = _apply(op, left.value, right.value)

3. 递归生成(含约束)

gen_expr(k) 递归生成含 k 个运算符的表达式。减法和除法的约束在生成时直接保证,而非生成后再整树拒绝:

def gen_expr(self, k: int) -> Expression:
    if k == 0:
        return Expression(value=self.random_number())
    ops = ['+', '-', '*', '/'] if self.r >= 3 else ['+', '-', '*']
    op = random.choice(ops)
    k1 = random.randint(0, k - 1)
    k2 = k - 1 - k1
    if op == '-':
        left, right = self.gen_expr(k1), self.gen_expr(k2)
        if left.value < right.value:
            left, right = right, left          # 交换保证非负
        return Expression('-', left, right)
    if op == '/':
        for _ in range(300):
            left, right = self.gen_expr(k1), self.gen_expr(k2)
            if 0 < left.value < right.value:   # 保证结果为真分数
                return Expression('/', left, right)
        n = random.randint(2, self.r - 1)      # 兜底:构造 m/n
        m = random.randint(1, n - 1)
        return Expression('/', Expression(value=Fraction(m, 1)),
                          Expression(value=Fraction(n, 1)))
    return Expression(op, self.gen_expr(k1), self.gen_expr(k2))

4. 交换律去重

需求 6 是本题最易出错的点。关键洞察是:+ 和 × 满足交换律、− 和 ÷ 不满足;而结合律不算作等价(1+2+3 与 3+2+1 是两道不同的题)。因此去重的方法是计算「规范键」——把 +、× 的左右子树规范键排序,−、÷ 保持原序:

def canonical(self) -> str:
    if self.is_leaf():
        return f"{self.value.numerator}/{self.value.denominator}"
    l = self.left.canonical()
    r = self.right.canonical()
    if self.op in ('+', '*') and l > r:
        l, r = r, l
    return f"({l}{self.op}{r})"

以题目原文的例子验证:1+2+3 按左结合解析为 (1+2)+3,与 3+(2+1) 规范化后同为 ((1+2)+3),判定重复;而 3+2+1 解析为 (3+2)+1,规范化为 ((2+3)+1),与前者不同,判定不重复。

四、计算模块接口部分的性能改进

4.1 性能分析方法

我们先用 Python 自带的 cProfile 生成性能数据,再用 SnakeViz 可视化各函数的调用关系与耗时。分析对象为生成一万道题(-n 10000 -r 100)的场景。

python -m cProfile -o 效能分析.prof main.py -n 10000 -r 100
snakeviz 效能分析.prof

ec5d4bdd959fb4ad0e084945903b559b

4.2 热点定位

cProfile 结果显示,生成一万道题总耗时约 0.321s,其中最耗时的函数是递归生成器 gen_expr(约 0.225s,占总量约 70%),其次是 random_number(随机数字生成,约 0.0881s)。这些时间主要花在随机数生成和 Fraction 构造上,属于算法本身的开销,并非异常热点。

4.3 优化方法

真正值得优化的地方在去重。最初我们考虑用列表保存已生成题目的规范键,每次生成后线性查找是否重复,这会导致 O(n²) 的时间复杂度。优化方案是改用 set(哈希集合),使查重从 O(n) 降为 O(1):

# 优化前:list 线性查找
seen = []
if key not in seen:          # O(n)
    seen.append(key)

# 优化后:set 哈希查找
seen = set()
if key not in seen:          # O(1)
    seen.add(key)

此外还有两处设计层面的优化:一是「约束先行」——减法直接交换保证非负、除法有界重试加兜底,避免生成完整表达式后再整树拒绝;二是 Expression 使用 __slots__ 声明字段,一万道题约 3~4 万个节点时内存占用显著下降。

4.4 优化结果

指标 优化前(list 去重) 优化后(set 去重) 变化
一万道题去重耗时 0.2250s 0.0007s 加速约 302 倍
一万道题总生成耗时 — 0.321s 满足附加分要求

从结果可以看出,仅把去重容器从列表换成集合,去重环节就加速了约 302 倍。这让我们认识到,性能优化不能只靠主观猜测,而应先用分析工具定位热点,再有针对性地修改数据结构,并用真实数据验证效果。

五、计算模块部分的单元测试

5.1 测试方法

由于程序只依赖标准库,我们使用 Python 内置的 assert 编写测试脚本 tests.py,共 22 个测试用例,覆盖正常、边界和异常情况。运行命令:

python tests.py

17badaef277722310028b68d9ea5e309

主要测试内容如下:

测试对象 测试内容
frac_to_str() 真分数 3/5、带分数 2'3/8、整数、零、约分
parse_expression() 1/6+1/8=7/24、混合数、括号优先级、除法
canonical() 23+45/45+23、6×8/8×6、1+2+3/3+(2+1)、1+2+3/3+2+1 等 5 组等价/不等价
生成约束 随机 2000 题全量校验:无非负、除法为真分数、运算符 ≤3、无重复
一万道题 10000 题无重复
judge() 构造错误答案,验证 Correct/Wrong 编号正确

部分核心测试代码如下:

# 交换律去重(需求 6 的边界情况)
a = parse_expression("1 + 2 + 3 =").canonical()
b = parse_expression("3 + (2 + 1) =").canonical()
c = parse_expression("3 + 2 + 1 =").canonical()
check("1+2+3 与 3+(2+1) 重复", a == b)
check("1+2+3 与 3+2+1 不重复", a != c)

# 生成约束:随机 2000 题全量校验
def verify_constraints(expr):
    if expr.is_leaf():
        return expr.value >= 0
    if expr.op == '-' and expr.left.value < expr.right.value:
        return False
    if expr.op == '/' and not (0 < expr.left.value < expr.right.value):
        return False
    return verify_constraints(expr.left) and verify_constraints(expr.right)

5.2 为什么能确定程序正确

  1. 约束用随机大样本全量校验:随机生成 2000 道题,逐题递归检查每个子表达式是否非负、除法是否为真分数,而非只测几个手写例子,能覆盖更多组合情况;
  2. 去重以规范键的数学含义为准:规范键严格对应「交换律等价」,并用题目原文给出的 4 组例子逐一验证,确保与题意一致;
  3. 判分用已知错误答案反向验证:故意改错部分答案,确认程序既能识别对的、也能揪出错的,而非只测「全对」的平凡情况。

六、异常处理说明

6.1 缺少 -r 参数

-r 为必填参数。缺失时打印帮助信息并以非零状态退出:

错误:-r 参数必须给定。

6.2 非法参数值

-r 必须为正整数、-n 必须为正整数,否则程序报错退出,不会继续执行。

6.3 范围过小导致题目数不足

当 -r 过小(如 -r 2)时,可行的不重复题目数量有限,可能无法生成要求的 n 道题。程序检测到后会抛出带说明的异常,提示用户增大 -r,而不是陷入死循环:

-r 2 下最多只能生成 3 道不重复题目,少于要求的 100 道,请增大 -r。

6.4 除法除零

除法子式要求 0 < 左值 < 右值,因此右值恒大于 0,从根源上杜绝了除零的可能。

6.5 判分文件数量不一致

判分时若题目数与答案数不一致,程序抛出异常并提示数量,而不是静默地对错位比对。

七、运行结果

7.1 生成题目

使用 -n 10 -r 10 生成 10 道题
a039ad986e0340bcf0d0bf331e8e3f57

7.2 题目与答案文件

生成的 Exercises.txt(题目)与 Answers.txt(答案)如下:

0851e40fc9d158df14c14fb60d136706

7533baea46ea8cb19486936975d5f404

可以看到答案中出现了带分数(如 8'2/3、3'2/3、6'2/7),符合需求对真分数 3/5 与带分数 2'3/8 的格式要求。

7.3 判分

将答案文件复制一份并故意改错 3 处,用 -e -a 判分:
4f8456ce-5213-4ae6-81fe-46ce37b9ea85

image

程序正确识别出 3 道错题,Grade.txt 输出 Correct: 7 (1, 3, 4, 6, 7, 9, 10) / Wrong: 3 (2, 5, 8)。

7.4 一万道题

使用 -n 10000 -r 100 生成一万道题:

8d6b35225ed1ae975746c63e22da68c1

整个程序无需任何第三方运行依赖,可直接通过 Python 命令行执行。

八、Git 版本管理

本项目使用 Git 记录结对开发过程,主要提交阶段包括:

  1. feat:实现四则运算题目生成器核心功能(分数表示、表达式树、约束生成、交换律去重、判分);
  2. docs:添加 README 运行说明、效能分析报告与性能图;
  3. refactor:精简代码注释,效能分析改用 snakeviz。

将功能实现、文档和重构分别提交,使版本历史更加清晰,也便于在出现问题时定位改动范围。

九、总结与改进计划

通过本次结对项目,我们完成了从需求分析、算法设计、代码实现到单元测试和性能分析的完整流程。过去写程序时我们更关注「能不能跑通」;这次作业让我们认识到,一个较完整的软件项目还需要考虑约束边界、异常输入、去重等价关系、性能数据和版本管理。

在算法方面,我们用表达式树 + 规范键解决交换律去重,用「约束先行」保证减法非负、除法为真分数,用 Fraction 保证分数运算精确。这套方案结构清晰、无第三方依赖,一万道题能在 0.2 秒左右生成完成。

结对过程中,我们采用「同一台电脑、轮流编码、轮流复审」的方式:一人编写核心算法时,另一人在旁即时指出边界问题,这种即时复审比事后 review 更早地发现了去重和除法约束中的错误。

陈建志: 这次结对让我体会最深的是「把题意想清楚再动手」。拿到需求后,我们俩在「任何两道题不能通过有限次交换 + 和 × 变换为同一道题」这句话上反复确认了很久——1+2+3 和 3+(2+1) 算重复、3+2+1 不算,光是把这条翻译成代码里的「规范键」就改了三版。我主要负责表达式树、约束生成和交换律去重这几块核心算法,陈信儒负责命令行参数解析、文件读写、判分模块和测试用例。印象最深的是除法真分数约束:一开始我们打算生成完再整树拒绝,是陈信儒提出「直接在生成除法节点时保证结果是真分数」,省掉了大量无效生成。他构造的 1/6+1/8=7/24 这类带括号、混合数的用例,帮我揪出了两个约分和带分数格式的 bug。一个人写代码容易钻牛角尖,有队友在旁边即时复审,很多低级错误当场就被拦下来了。

陈信儒: 这次结对我主要负责参数解析、文件读写、判分模块,以及测试用例的设计和代码复审。和陈建志配合最大的感受是分工要清楚、边界要先定:他编码快、对递归和 Fraction 很熟,我则更擅长抠题意的边界和构造测试。我们一起把需求 6 拆成几个等价/不等价的例子逐一验证,这一步花的时间最多,但也是最值的一步——后来测试全过,很大程度上归功于前期把这些边界定义清楚了。过程中我也学到不少:用 Fraction 做精确分数运算、用规范键做去重,这些是我以前写作业不会主动去想的方法。

彼此的闪光点与建议:

  • 陈信儒 → 陈建志:递归生成表达式的写法清晰、约束都落在生成点上,注释恰到好处。建议是写代码时更早同步思路,有几处我复审时才明白设计意图,边写边讲会更顺。
  • 陈建志 → 陈信儒:测试用例设计得很全,尤其是构造「对错各半」的答卷验证判分器,这个思路很关键。建议是再大胆一点,直接动手改核心代码而不只是提建议,配合会更高效。

后续可以从以下方面继续改进:

  1. 题目生成时进一步平衡运算符的分布,避免连续除法导致题目形态过于单一;
  2. 为 -r 较小时的可生成题目总数建立一个精确上界,提前给出更友好的提示;
  3. 增加更多边界测试,例如 -r 1、-r 3、答案文件含空行等场景;
  4. 在继续优化前建立稳定的性能基线,多次运行取平均值,减少单次测量误差。

本次作业中,PSP 表格帮助我们比较预估与实际耗时,tests.py 帮助我们验证约束正确性,cProfile 与 SnakeViz 则让我们能够根据真实调用耗时定位热点。通过这些工具,我们对「可运行、可测试、可维护、可分析」的软件开发过程有了更具体的认识。

posted @ 2026-09-21 15:18  玖斯琳  阅读(10)  评论(0)    收藏  举报