第三次作业

1. 项目基本信息

姓名 学号
林华杰 3124004213
蔡志冲 3124004197

Github 项目地址:https://github.com/Lin-huaj/Lin-huaj/tree/main/four_arithmetic_operations

2. 需求与目标

本项目实现一个命令行四则运算题目生成器,覆盖题目要求的两个工作模式:

  1. 生成模式:python3 main.py -n 10 -r 10 生成 10 道题,并在当前目录写入 Exercises.txt 和 Answers.txt。
  2. 判题模式:python3 main.py -e Exercises.txt -a StudentAnswers.txt 读取题目和学生答案,生成 Grade.txt。

表达式支持自然数、真分数、带分数、括号,以及 +、-、×、÷。每道题最多 3 个运算符;减法的每个子表达式都不产生负数;除法不允许除以 0;同一次生成中的题目不能在交换加法或乘法左右子树后重复。

2.1 约束的落地方式

  • -n 和 -r 由 argparse 解析,并在进入生成器前检查为正整数。
  • 采用 fractions.Fraction 保存所有中间值和答案,避免浮点数误差。
  • 随机生成表达式树时,先生成左子树,再按减法和除法约束重试右子树。
  • 通过递归规范化键 norm_key() 处理交换律:+、× 的左右键排序,-、÷ 保留顺序。
  • 输出时依据优先级和结合性决定是否补括号,保证文本重新解析后语义不变。

3. PSP 计划与实际记录

开发前先估算各阶段耗时,完成后再记录实际耗时。单位均为分钟,实际值是本次实现、测试和整理文档的工作记录。

Personal Software Process Stages 阶段 预估耗时 实际耗时
Planning / Estimate 计划与估计 20 20
Development / Analysis 需求分析(含 Fraction、递归下降解析) 40 45
Development / Design Spec 设计文档 30 35
Development / Design Review 设计复审 20 20
Development / Coding Standard 代码规范 10 10
Development / Design 表达式树、生成器、判题器设计 45 50
Development / Coding 具体编码 120 150
Development / Code Review 代码复审 30 35
Development / Test 测试、修复和回归 60 70
Reporting / Test Report 测试报告 25 30
Reporting / Size Measurement 工作量统计 10 10
Reporting / Postmortem & Process Improvement Plan 事后总结与改进计划 25 30
合计 435 505

实际时间比估计多 70 分钟,主要花在括号渲染、交换律去重和判题格式兼容上。

4. 设计与实现

4.1 模块组织

项目保持单文件实现,按职责分成五个区域:

区域 主要函数/类 职责
数值格式化 frac_to_str()、str_to_frac() Fraction 与自然数、真分数、带分数文本互转
表达式树 Node、Num、Bin 保存结构,计算值,统计运算符,生成规范化键和文本
随机生成 rand_number()、gen_expr()、generate_problems() 生成满足约束且不重复的题目
解析与判题 Parser、calc_answer()、grade() 递归下降解析表达式并比较学生答案
文件与入口 write_exercises_and_answers()、main() 写文件、参数检查、选择生成/判题模式

表达式采用二叉树,而不是直接拼接字符串。这样同一份结构可以同时用于计算、去重、括号渲染和运算符计数,避免“显示字符串”和“内部计算”出现两套规则。

4.2 核心数据结构

Num 是叶子节点,保存一个 Fraction;Bin 是二叉运算节点,保存运算符、左子树和右子树。所有计算都递归向下,再由父节点组合结果。

class Bin(Node):
    def __init__(self, op, left, right):
        self.op = op
        self.left = left
        self.right = right

    def value(self):
        a, b = self.left.value(), self.right.value()
        if self.op == "+":
            return a + b
        if self.op == "-":
            return a - b
        if self.op == "×":
            return a * b
        if self.op == "÷":
            return a / b
        raise ValueError(f"未知运算符 {self.op}")

    def op_count(self):
        return 1 + self.left.op_count() + self.right.op_count()

Fraction 会自动约分。例如 1/6 + 1/8 的内部结果直接是 Fraction(7, 24),输出函数再将它格式化为 7/24;带分数则使用题目规定的 ASCII 撇号,例如 11/2 输出为 5'1/2。

4.3 生成与去重

生成器先随机决定 1~3 个运算符,再把运算符配额递归分给左右子树。减法和除法在生成右子树后验证约束,不满足就重试。generate_problems() 使用集合保存规范化键,直到收集到指定数量。

def norm_key(self):
    left = self.left.norm_key()
    right = self.right.norm_key()
    if self.op in ("+", "×") and left > right:
        left, right = right, left
    return f"({self.op}{left}{right})"

这个递归规则同时处理嵌套交换。例如 3 + (2 + 1) 和 (1 + 2) + 3 的子树最终都会得到同一规范键;而减法、除法不排序,所以 10 - 3 与 3 - 10 仍然是不同题目。

4.4 文本化与解析

Bin.to_text() 使用 +/- 一级、×/÷ 二级的优先级。左子树优先级低于父节点时加括号;右子树在同优先级下遇到 - 或 ÷ 也加括号,从而正确表达 a - (b - c) 和 a ÷ (b ÷ c)。

4.5 流程图

命令行程序总流程图
flow

题目生成流程图
generation_flow

判题流程图
grading_flow

5. 效能分析

本次性能检查花费约 30 分钟,重点确认 10000 道题的生成能力和重复检查开销。使用 time 和 Python cProfile 分别测量端到端耗时与函数热点。

场景 实测时间 平均每题
100 题,r=10 0.0017 s 0.017 ms
1000 题,r=50 0.0168 s 0.017 ms
10000 题,r=100 0.1754 s 0.018 ms

性能分析图

在带解释器启动和文件写入的 cProfile 运行中,总耗时约 0.719 s。累计耗时最高的是 generate_problems(),其内部主要热点是 gen_expr()、rand_number() 和表达式节点的 value()。原因是每次候选题都要递归构造、计算约束、规范化并格式化,这是需求本身要求的工作,而不是多余的重复扫描。

当前实现的优化思路是:

  • 用集合保存 norm_key,将重复判断从线性查找降为均摊 O(1)。
  • 用 Fraction 直接保存规范化分数,避免字符串反复解析和浮点误差修正。
  • 将运算符配额作为递归参数传递,保证不会先生成超长表达式再截断。
  • 只在违反减法或除法约束时重试右子树,避免整棵树作废。

在 r 很小、可生成的等价题数量不足时,程序会在重试上限后给出明确错误,而不是无限循环。这是可用性和性能之间的必要边界。

6. 关键代码说明

6.1 约束生成

def gen_expr(rng, r, max_ops):
    if max_ops <= 0:
        return Num(rand_number(rng, r))

    op = rng.choice(["+", "-", "×", "÷"])
    left_ops = 0 if max_ops == 1 else rng.randint(0, max_ops - 1)
    right_ops = max_ops - 1 - left_ops
    left = gen_expr(rng, r, left_ops)

    for _ in range(80):
        right = gen_expr(rng, r, right_ops)
        if op == "-" and left.value() < right.value():
            continue
        if op == "÷" and right.value() == 0:
            continue
        return Bin(op, left, right)

    # 极端范围下的兜底,确保函数能够返回
    if op == "-":
        return Bin("-", left, Num(Fraction(0)))
    if op == "÷":
        return Bin("+", left, Num(Fraction(0)))
    return Bin(op, left, Num(rand_number(rng, r)))

这里的 max_ops 同时承担“深度预算”和“运算符数量预算”。减法约束只比较已经生成的两个子树值,因此嵌套减法也能保证每一步不出现负数。

6.2 判题与文件输出

def grade(ex_file, ans_file, dir_="."):
    ex_lines = read_lines(ex_file)
    ans_lines = read_lines(ans_file)
    correct, wrong = [], []
    for i in range(min(len(ex_lines), len(ans_lines))):
        try:
            right = calc_answer(_strip_number_prefix(ex_lines[i]))
            user = str_to_frac(_strip_number_prefix(ans_lines[i]))
            (correct if right == user else wrong).append(i + 1)
        except Exception:
            wrong.append(i + 1)

    path = os.path.join(dir_, "Grade.txt")
    with open(path, "w", encoding="utf-8") as f:
        f.write(f"Correct: {len(correct)} ({', '.join(map(str, correct))})\n")
        f.write(f"Wrong: {len(wrong)} ({', '.join(map(str, wrong))})\n")
    return path, correct, wrong

答案解析失败会被判为错误并继续处理后续题目,单个坏答案不会中断整批统计。

7. 测试运行

测试命令:

python3 -m unittest discover -s tests -v

实测结果:31 个测试全部通过,总耗时约 0.014 秒。下面列出至少 10 个有代表性的测试用例及其目的:

编号 测试输入/场景 预期结果 验证点
1 Fraction(4, 1) 格式化 4 自然数输出
2 Fraction(3, 5) 格式化 3/5 真分数输出
3 Fraction(11, 2) 格式化 5'1/2 带分数输出
4 1/6 + 1/8 7/24 分数精确加法
5 2'1/2 + 1/2 3 带分数解析
6 (1 + 2) × 3 9 括号优先级
7 1 + 2 × 3 7 乘法优先于加法
8 10 - (5 - 3) 8 右子树括号与结合性
9 23 + 45 与 45 + 23 规范键相同 加法交换律去重
10 6 × 8 与 8 × 6 规范键相同 乘法交换律去重
11 10 - 3 与 3 - 10 规范键不同 减法不交换
12 generate_problems(200, 15) 无重复 批量去重
13 100 道随机题 每题运算符不超过 3 个 生成上限
14 200 道随机题 所有答案 >= 0 过程/结果非负
15 三题判题:答案 2、5、1 对应 2、6、1 正确 [1,3],错误 [2] Grade.txt 统计

测试不仅检查最终答案,也检查格式化的往返一致性、AST 规范化、括号渲染和随机生成约束,因此能够覆盖“生成正确但输出不可解析”这类问题。另用 python3 main.py -n 10000 -r 100 实测成功生成 10000 道题,验证了题目要求的规模。

8. 项目运行与目录结构

four_arithmetic_operations/
├── main.py
├── tests/test_main.py
├── examples/StudentAnswers.txt
├── README.md
├── requirements.txt
├── flow.dot
├── flow.png
├── flow.svg
├── generation_flow.dot
├── generation_flow.svg
├── generation_flow.png
├── grading_flow.dot
├── grading_flow.svg
├── grading_flow.png
├── 性能分析图.png
└── blog.md

项目只使用 Python 标准库,不需要安装第三方包。最小运行示例:

python3 main.py -n 10 -r 10
python3 main.py -e Exercises.txt -a StudentAnswers.txt

如果只输入 -n 而遗漏 -r,程序会打印错误和帮助信息;如果题目文件或答案文件不存在,判题模式也会给出明确错误并返回非零状态码。

9. 项目小结与结对反思

做得比较好的地方

  • 用 AST 统一承载计算、渲染和去重逻辑,减少了字符串规则之间的不一致。
  • 用 Fraction 解决了真分数和带分数的精确计算问题。
  • 测试从单个运算扩展到随机生成、文件判题和 10000 题规模,能够较早暴露边界问题。
  • 命令行错误信息和输出文件格式与需求保持一致,便于批量使用。

不足与改进计划

  • 当前文件只有一个核心模块,后续可以拆分为 model.py、generator.py、parser.py 和 cli.py,降低维护成本。
  • 生成器对极小 r 的可生成题目数量有限,未来可以先估计搜索空间,再在参数不足时提前提示。
  • 除法目前保证除数非零且结果精确表示;若评分严格解释“结果必须是真分数”,应增加专门的非整数结果约束和测试。
  • 可以引入属性测试,随机验证“渲染后再解析”的值保持不变,以及规范化键对交换变换的不变性。

结对感受

这次作业最有价值的地方是把“随机生成”变成一组可验证的约束,而不是只追求能打印出题目。结对过程中,一位同学适合先梳理需求边界和测试矩阵,另一位同学集中处理 AST、解析器和异常路径;交叉复审时,双方都能发现自己单独编码时容易忽略的结合性和格式问题。

posted @ 2026-09-22 22:58  it-cc  阅读(10)  评论(0)    收藏  举报