小学四则运算题目生成器

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

一、项目信息

  • 同学 A:马文青,学号 【3124004255】
  • 同学 B:黎伟骞,学号 【3124004249】
  • Github 项目地址:https://github.com/MWQ111/FourTest
  • 运行环境:Python 3.8+(实测 3.13.2)

二、PSP 表格(预估)

PSP2.1 阶段 预估耗时(分钟)
Planning 计划 30
· Estimate 估计任务时间 30
Development 开发 650
· Analysis 需求分析 60
· Design Spec 设计文档 40
· Design Review 设计复审 30
· Coding Standard 代码规范 20
· Design 具体设计 60
· Coding 编码 280
· Code Review 代码复审 60
· Test 测试 100
Reporting 报告 120
· Test Report 测试报告 40
· Size Measurement 工作量统计 20
· Postmortem 事后总结 60
合计 800

三、效能分析

3.1 改进思路

瓶颈定位:初版实现使用字符串直接比较去重,10000 道题生成耗时约 18 秒。

改进方案:

  1. AST + 规范化键去重:将表达式解析为语法树,对满足交换律的 +、× 节点,将其子树的规范字符串按字典序排序后拼接,把 1+2 与 2+1 映射到同一 key,用 set 去重。复杂度从 O(n²) 降至 O(n)。
  2. 生成期校验:在递归构建 AST 时即校验约束(中间结果非负、除法结果为真分数),减少"生成—丢弃"的循环次数。
  3. fractions.Fraction + math.gcd:使用 C 实现的标准库,分数运算速度明显提升。

改进后:-r 100 下 10000 道题生成耗时约 1 秒。

3.2 性能分析图

运行 python -m cProfile -s tottime myapp.py -n 10000 -r 100:

性能分析

总耗时 0.997 秒,378 万次函数调用。热点集中在 random 模块(随机数生成)和 _random_number/_build_tree(题目构建),符合预期——生成阶段的主要开销是随机采样和 AST 构造,而非分数运算。


四、设计实现过程

4.1 代码组织结构

FourTest/
├── myapp.py            # 主程序:命令行入口 + 全部核心逻辑
├── test_basic.py       # 单元测试
├── README.md           # 运行说明
├── .gitignore
└── images/             # 博客配图

4.2 类的职责与关系

类 / 函数 职责
Fraction(标准库) 分数表示与精确运算
Node AST 节点,含 evaluate() / normalize() / __str__()
ProblemGenerator 随机生成 + 去重 + 合法性校验
parse_expression 题目字符串 → AST(判题用)
grade 判题,输出 Grade.txt
main 命令行调度

关系:ProblemGenerator 构造 Node 树;Node.evaluate 使用 Fraction 完成精确运算;grade 调用 parse_expression 构造 Node 树再求值;main 根据命令行参数调度生成或判题流程。

4.3 关键函数流程图(题目生成)

        ┌──────────────────────┐
        │ generate_one() 开始   │
        └──────────┬───────────┘
                   ▼
        ┌──────────────────────┐
        │随机确定运算符个数 k∈{1,2,3}│
        └──────────┬───────────┘
                   ▼
        ┌──────────────────────┐
        │ 递归 build_tree(k)    │
        │ 叶子:随机数或真分数    │
        └──────────┬───────────┘
                   ▼
        ┌──────────────────────┐
        │  evaluate(node)      │
        │  负数 / 假分数 → 异常 │
        └──────────┬───────────┘
                   ▼
            ┌────────────┐
            │  合法?     │
            └──┬─────┬───┘
            否 │     │ 是
               ▼     ▼
          重新生成  ┌─────────────────┐
                   │ key = normalize │
                   └────────┬────────┘
                            ▼
                   ┌─────────────────┐
                   │ key 已在 set 中?│
                   └──┬───────────┬──┘
                    是│           │否
                      ▼           ▼
                  重新生成   加入 set,返回题目

五、代码说明

5.1 分数格式化与解析

MINUS  = "\u2212"   # −
TIMES  = "\u00d7"   # ×
DIVIDE = "\u00f7"   # ÷

def fraction_to_str(f: Fraction) -> str:
    """整数 '5';真分数 '3/5';带分数 "2'3/8"。"""
    if f.denominator == 1:
        return str(f.numerator)
    if f.numerator < f.denominator:
        return f"{f.numerator}/{f.denominator}"
    whole = f.numerator // f.denominator
    rem   = f.numerator %  f.denominator
    return f"{whole}'{rem}/{f.denominator}"

def parse_fraction(s: str) -> Fraction:
    s = s.strip()
    if "'" in s:
        whole, frac = s.split("'")
        num, den = frac.split("/")
        return Fraction(int(whole) * int(den) + int(num), int(den))
    if "/" in s:
        num, den = s.split("/")
        return Fraction(int(num), int(den))
    return Fraction(int(s), 1)

说明:输出格式符合规格 3/5、2'3/8;内部使用 fractions.Fraction 保证精确运算;三个 Unicode 运算符用转义写法,源文件保持纯 ASCII。

5.2 AST 节点

class Node:
    __slots__ = ("value", "op", "left", "right")

    def __init__(self, value=None, op=None, left=None, right=None):
        self.value = value
        self.op    = op
        self.left  = left
        self.right = right

    def evaluate(self) -> Fraction:
        if self.value is not None:
            return self.value
        l = self.left.evaluate()
        r = self.right.evaluate()
        if self.op == '+':
            return l + r
        if self.op == '-':
            res = l - r
            if res < 0:
                raise ArithmeticError("减法产生负数")
            return res
        if self.op == '*':
            return l * r
        if self.op == '/':
            if r == 0:
                raise ArithmeticError("除数为零")
            res = l / r
            # 规格:除法结果必须是真分数。
            # 本实现采用宽解读:0 <= res < 1,即 0/n = 0 也放行。
            if res < 0 or res >= 1:
                raise ArithmeticError("除法结果不是真分数")
            return res
        # 防御:op 不属于四种合法运算符时立即失败,避免穿透返回 None
        raise ValueError(f"未知运算符: {self.op}")

    def normalize(self) -> str:
        """交换律规范化键(只交换 +/× 的左右子树,不做结合律拍平)。"""
        if self.value is not None:
            return fraction_to_str(self.value)
        ls, rs = self.left.normalize(), self.right.normalize()
        if self.op in ('+', '*'):
            a, b = sorted([ls, rs])
            return f"({self.op} {a} {b})"
        return f"({self.op} {ls} {rs})"

    def __str__(self) -> str:
        if self.value is not None:
            return fraction_to_str(self.value)
        sym = {'*': TIMES, '/': DIVIDE, '-': MINUS}.get(self.op, self.op)
        return f"({self.left} {sym} {self.right})"

说明:evaluate 递归求值并校验约束;normalize 实现交换律去重键,只交换不做结合律拍平——这是规格明确要求的行为(1+2+3 与 3+2+1 视为不同题);__str__ 输出带括号的题目字符串。

5.3 题目生成器

class ProblemGenerator:
    def __init__(self, rng: int):
        if rng < 1:
            raise ValueError("-r 必须 ≥ 1")
        self.rng  = rng
        self.seen = set()

    def _random_number(self) -> Fraction:
        """随机返回一个数值:0 约 5%,其余为自然数/真分数/带分数等权。"""
        r = self.rng
        # 约 5% 概率直接返回 0(0 是规格明确允许的自然数,但不应泛滥)
        if random.random() < 0.05:
            return Fraction(0, 1)
        candidates = ['int', 'frac']
        if r >= 3:
            candidates.append('mixed')
        kind = random.choice(candidates)
        if kind == 'int':
            return Fraction(random.randint(1, max(1, r - 1)), 1)
        if kind == 'frac':
            den = random.randint(2, max(2, r - 1))
            num = random.randint(1, den - 1)
            return Fraction(num, den)
        # mixed:整数部分 + 真分数
        whole = random.randint(1, max(1, r - 2))
        den   = random.randint(2, max(2, r - 1))
        num   = random.randint(1, den - 1)
        return Fraction(whole * den + num, den)

    def _build_tree(self, op_count: int) -> Node:
        if op_count == 0:
            return Node(value=self._random_number())
        left_ops  = random.randint(0, op_count - 1)
        right_ops = op_count - 1 - left_ops
        left  = self._build_tree(left_ops)
        right = self._build_tree(right_ops)
        op    = random.choice('+-*/')
        return Node(op=op, left=left, right=right)

    def _has_trivial_subtraction(self, node: Node) -> bool:
        """检查树里是否存在 left ≡ right 的减法节点(如 1/2 − 1/2)。"""
        if node.value is not None:
            return False
        if node.op == '-' and node.left.normalize() == node.right.normalize():
            return True
        return (self._has_trivial_subtraction(node.left)
                or self._has_trivial_subtraction(node.right))

    def generate_one(self):
        for _ in range(2000):
            op_count = random.randint(1, 3)
            tree = self._build_tree(op_count)
            try:
                answer = tree.evaluate()
            except (ArithmeticError, ZeroDivisionError):
                continue
            if self._has_trivial_subtraction(tree):
                continue
            key = tree.normalize()
            if key in self.seen:
                continue
            self.seen.add(key)
            return f"{tree} = ", answer
        raise RuntimeError(
            f"在 -r {self.rng} 范围内无法生成合法题目,请增大范围。"
        )

    def generate(self, n: int):
        return [self.generate_one() for _ in range(n)]

说明:_random_number 生成 0/自然数/真分数/带分数,0 权重约 5% 以避免退化题泛滥;generate_one 循环重试至找到合法且未重复的题目;_has_trivial_subtraction 拦掉 x − x 型退化题(结果恒为 0,无训练价值)。

5.4 判题模块

def parse_expression(s: str) -> Node:
    """把题目字符串(如 '(1 + 2) × 3')解析为 AST,用于判题。"""
    s = s.replace(TIMES, '*').replace(DIVIDE, '/').replace(MINUS, '-')
    tokens = s.replace('(', ' ( ').replace(')', ' ) ').split()
    pos = 0

    def peek():
        return tokens[pos] if pos < len(tokens) else None

    def consume(t=None):
        nonlocal pos
        tok = tokens[pos]
        if t and tok != t:
            raise SyntaxError(f"期望 {t},实际 {tok}")
        pos += 1
        return tok

    def parse_primary():
        tok = peek()
        if tok == '(':
            consume('(')
            node = parse_add()
            consume(')')
            return node
        consume()
        return Node(value=parse_fraction(tok))

    def parse_mul():
        node = parse_primary()
        while peek() in ('*', '/'):
            op = consume()
            rhs = parse_primary()
            node = Node(op=op, left=node, right=rhs)
        return node

    def parse_add():
        node = parse_mul()
        while peek() in ('+', '-'):
            op = consume()
            rhs = parse_mul()
            node = Node(op=op, left=node, right=rhs)
        return node

    return parse_add()


def grade(exercise_file, answer_file, out_file="Grade.txt"):
    with open(exercise_file, encoding='utf-8') as f:
        exercises = [ln.rstrip() for ln in f if ln.strip()]
    with open(answer_file, encoding='utf-8') as f:
        answers = [ln.strip() for ln in f if ln.strip()]

    if len(exercises) != len(answers):
        sys.exit(f"错误:题目文件 {len(exercises)} 行,"
                 f"答案文件 {len(answers)} 行,行数不一致")

    correct, wrong = [], []
    for i, (ex, ans) in enumerate(zip(exercises, answers), start=1):
        try:
            expected = parse_expression(ex.replace('=', '')).evaluate()
            actual   = parse_fraction(ans)
            (correct if expected == actual else wrong).append(i)
        except Exception:
            wrong.append(i)

    with open(out_file, '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")
    print(f"判题完成,结果写入 {out_file}")

说明:parse_expression 用递归下降解析题目字符串;grade 逐行对比,行数不一致时直接报错(避免静默丢题),无法解析的行记为 Wrong 而不是崩溃。


六、测试运行

6.1 测试用例

# 命令 预期结果 实测
1 python myapp.py -n 10 -r 10 生成 10 道题,两文件正常 ✅
2 python myapp.py -n 10000 -r 100 10000 道题 < 5 秒 ✅ 约 1 秒
3 python myapp.py -n 10 报错 + 帮助 ✅
4 python myapp.py -r 10 报错 + 帮助 ✅
5 python myapp.py 打印帮助 ✅
6 python myapp.py -n 5 -r 10 后 python myapp.py -e Exercises.txt -a Answers.txt Grade.txt 全对 ✅ Correct: 5 (1, 2, 3, 4, 5)
7 手动改 Answers.txt 一行 判题结果对应变化 ✅ Wrong: 1 (2)
8 删除 Answers.txt 若干行 报"行数不一致" ✅
9 python test_basic.py 三步全部通过 ✅
10 构造 1/6 + 1/8 答案 7/24 ✅
11 构造 2'3/8 = 解析为 19/8 ✅
12 python myapp.py -n 10000 -r 1 抛 RuntimeError(去重耗尽) ✅ 合理行为

6.2 运行截图

生成命令:

生成命令

Exercises.txt(题目):

题目

Answers.txt(答案):

答案

判题命令:

判题命令

Grade.txt(判题结果):

判题结果

单元测试:

单元测试

6.3 正确性保证

  1. 代数正确性:Fraction 精确运算,无浮点误差,1/6 + 1/8 = 7/24 验证通过。
  2. 约束完备:生成阶段 evaluate 校验,任何负数/假分数都会丢弃重建。
  3. 去重有效:normalize 递归处理交换律等价,set 判重,10000 题无重复。
  4. 退化题处理:x − x 型减法被拦,0 权重降到 5% 避免退化题泛滥。
  5. 边界测试:-r 1、-r 2、-n 10000 均已运行通过。
  6. 静默错误防护:判题行数不一致时报错,不静默丢题。

七、PSP 表格(实际回填)

PSP2.1 阶段 预估(分钟) 实际(分钟) 误差
Planning 计划 30 28 −2
· Estimate 估计时间 30 28 −2
Development 开发 650 658 +8
· Analysis 需求分析 60 66 +6
· Design Spec 设计文档 40 34 −6
· Design Review 设计复审 30 37 +7
· Coding Standard 代码规范 20 15 −5
· Design 具体设计 60 54 −6
· Coding 编码 280 289 +9
· Code Review 代码复审 60 68 +8
· Test 测试 100 95 −5
Reporting 报告 120 114 −6
· Test Report 测试报告 40 35 −5
· Size Measurement 工作量统计 20 23 +3
· Postmortem 事后总结 60 56 −4
合计 800 800 0

八、项目小结

8.1 成败得失

成功之处:

  • 采用 AST + 规范化键去重方案,兼顾效率与正确性,10000 道题生成耗时约 1 秒
  • 使用标准库 fractions.Fraction 保证分数精确运算,1/6 + 1/8 = 7/24 验证通过
  • 生成期即校验约束(非负、真分数除法),避免非法题目入库
  • 判题模块对行数不一致直接报错,避免静默丢题的隐患
  • 15+ 个 commit 记录了完整的开发历程,每步可回溯

不足之处:

  • 初版实现 0 泛滥、x − x 型退化题,是后期实测才发现的
  • 参数校验对非数字输入不够健壮(如 -r abc 会抛异常而非友好提示)
  • 所有逻辑集中在单文件 myapp.py 中,随功能增长可维护性下降,未来可拆分为 fraction.py / ast.py / generator.py / grader.py

8.2 经验与教训

  1. 先设计后编码:AST 结构若一开始就规划好,去重逻辑不用反复重构。初期因为急于编码,去重逻辑重构了两次,浪费约 1 小时。
  2. 约束校验前置:合法性检查放在生成阶段而非生成后过滤,效率显著提升。若在生成后过滤,10000 道题需要生成大量废题再丢弃,性能开销巨大。
  3. 测试要覆盖边界:-r 1、-n 10000、行数不一致这些都是后期才发现的问题。若早期就写全边界测试,可以更早发现。

8.3 结对感受

马文青:

本项目我主要负责整体架构设计、AST 节点的实现和题目生成器的开发。在实现过程中,最深的体会是"设计阶段的投入会在编码阶段以倍数回报"——AST 结构和 normalize 去重键的设计花了大约 1 小时讨论,但后续所有去重逻辑都直接复用,没有返工。

黎伟骞在判题模块和命令行参数处理上做了大量细致的工作,尤其是对错误输入的防御性处理(如行数不一致直接报错),这些细节在功能测试时体现出了价值。两人在 Git 分支上并行开发(一人负责生成器、一人负责判题),最后统一合并到 main,协作过程没有出现代码冲突。

黎伟骞:

本项目我主要负责判题模块、命令行参数处理以及 README 文档的编写。在做判题模块时,我意识到"用户输入不可信"——题目文件和答案文件的行数未必一致,若不检查会导致静默丢题,这是一个很容易被忽略的坑。通过加入行数检查,避免了统计结果的失真。

马文青在 AST 结构的设计上给了我很大启发,尤其是 normalize 函数用 8 行代码解决了交换律去重的难题,比我最初设想的方案简洁得多。整个项目中我们在架构上的讨论较多,这种先想清楚再动手的方式提高了整体效率。

对彼此的建议:

  • 马文青 → 黎伟骞:建议增加代码注释密度,尤其是复杂分支的判断逻辑,方便后续维护。
  • 黎伟骞 → 马文青:建议在编码前先写测试用例(TDD 思路),可以进一步减少后期返工。

闪光点:

  • 遇到 bug 时双方都会主动沟通,没有出现"各自埋头改同一份代码"的冲突
  • 使用 Git 分支开发,每完成一个模块就提交一次,commit 信息清晰规范
  • 对非规格改动(如负数处理、额外断言)都会先讨论再执行,避免了风格漂移
  • 双方对代码质量的关注度都较高,判题模块的"行数不一致报错"就是黎伟骞主动发现并提出的

附录:完整 commit 记录

da6c0c4 docs: 添加博客配图截图
edf0796 chore: 创建 images/ 目录用于博客配图
5416480 docs: 补充已验证规模说明,平衡已知限制章节
f0e6eaf docs: README 已知限制改为定性描述,避免硬编码失效
7c40e79 docs: 添加 README(运行说明与已知限制)
5d95b44 fix: 题目与答案行数不一致时报错,避免静默丢题
122263a feat: 判题模块与 Grade.txt 输出
dfdbea2 refactor: 删除死代码 parse_args,统一错误帮助到 stderr
83eff30 feat: 命令行接入生成模式
62f8004 fix: 丢弃 left ≡ right 的退化减法题(如 1/2 − 1/2)
6d5b128 fix: 降低 0 在题目中的出现频率,避免退化题目泛滥
98b735a feat: 题目生成器(含带分数/去重/约束)
c03bcb6 docs: 为 Node.evaluate 的除法宽解读与防御分支添加注释
d550d4f feat: AST 节点(求值/规范化/打印)
7e0958d fix: 强制 stdout/stderr 使用 UTF-8,兼容 Windows GBK 控制台
5c60f30 feat: 分数格式化与解析
96f840d feat: 命令行骨架与参数定义

commit 记录


运行验证

# 生成题目
python myapp.py -n 10 -r 10

# 判题
python myapp.py -e Exercises.txt -a Answers.txt

# 单元测试
python test_basic.py
posted @ 2026-09-22 17:57  mwq111  阅读(6)  评论(0)    收藏  举报