结对项目:小学四则运算题目生成器(C++)

结对项目:小学四则运算题目生成器(C++)

0. 成员与仓库信息

项目 内容
同学 A 姓名:陈栖羽 学号:3124004124
编程语言 / 平台 C++17,Windows 为主,跨平台(Linux/macOS/MinGW/MSVC 均可编译)
GitHub 项目地址 https://github.com/ch998244353/ArithmeticGenerator
运行方式 见仓库根目录 README.md

1. PSP 表格(预估)

在开始动手之前,我们先把任务拆开并估计了各阶段耗时(单位:分钟):

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

说明:预估与实际见下方第 7 节的对比与差异分析。


2. 需求分析

题目要求实现一个命令行程序,完成两件事:批量生成题目判分。核心需求归纳如下:

  1. -n 控制题目个数;-r 控制数值范围(自然数、真分数及其分母均小于 r),-r 必须给定否则报错并给出帮助。
  2. 计算过程不产生负数:任意子表达式 e1 − e2 需满足 e1 ≥ e2
  3. 除法结果必须是真分数:子表达式 e1 ÷ e2 的商须严格介于 0 与 1 之间(即 0 < e1 < e2)。
  4. 每道题运算符不超过 3 个
  5. 一次运行生成的题目不能重复;判定“重复”的口径是“能否通过有限次交换 + / × 的左右操作数变成同一道题”。
  6. 题目写入 Exercises.txt,答案写入 Answers.txt;真分数写作 3/5,带分数写作 2'3/8
  7. 支持一万道题目
  8. 支持 -e <题目文件> -a <答案文件> 判分,结果写入 Grade.txt(附加分项)。

其中“重复”的定义“除法结果为真分数”是最容易被想当然、也最容易做错的两点,我们在设计阶段专门花时间琢磨了它们(详见第 4 节)。


3. 效能分析

改进性能上我们一共投入约 90 分钟(约 1.5 小时)。

3.1 改进思路

程序的耗时几乎全部集中在生成题目这一个环节:随机建表达式树、校验约束、判重,不满足就重来。围绕它我们做了三处优化:

  1. 判重键:字符串 → 64 位结构指纹。
    初版把表达式规约成“规范形式字符串”(如 ((1+2)×3))再放进 std::set<string>。字符串拼接与分配开销大。改进后用一个 uint64_t 结构指纹(叶子 mix64(num,den),内部结点把两个子指纹与运算符标签混合;对 +/× 先排序以满足交换律)。语义完全等价,但省掉了全部字符串分配。

  2. 两次遍历 → 一次遍历。
    初版先 evaluateChecked(求值 + 校验约束),再 canonical(生成判重键),对同一棵树走了两遍。改进后合并成一个 checkFp() 递归:一次递归同时完成求值、约束校验、指纹计算

  3. 把“非法”变“合法”,降低拒绝率。
    初版是纯粹的“拒绝采样”:减法 e1 < e2 就丢弃整棵树。实际上只要交换左右子树(变成 e2 − e1)它就合法了!改成“遇到 e1 < e2 就交换”后,减法几乎不再产生废样本,整体接受率明显提高。

3.2 性能分析图

优化前后在同一台机器上的对比如下(取多次运行的最优值):

优化前后性能对比

三组配置分别提速 1.70× / 1.55× / 1.68×。优化前后 gprof 采样到的总 CPU 时间也从约 2.5s 降到约 1.5s。

gprof -p 抓出的耗时最多的函数(优化后,100 万题 r=10):

gprof 时间分布

结论:消耗最大的函数是 Generator::generate(),约占 45.6%。 它内含随机建树(buildTree,已内联)与整个拒绝采样循环——这正是生成器的主要工作,符合预期。其次是随机数生成(Mersenne Twister)约 19.1%,因为每个结点都要多次取随机数。

由于需求上限只有 1 万题,实际耗时随题量几乎线性增长,且远低于可接受范围:

耗时随题量增长

需求上限 1 万题实测只需约 0.01 秒,即使用户把题量加到 100 万题也仅约 2.4 秒。程序性能对本题需求而言有巨大余量。


4. 设计实现过程

4.1 总体结构

程序采用面向过程与面向对象结合的方式,划分为 6 个模块(类),职责单一、低耦合:

类图

类 / 模块 职责
Fraction 有理数:约分、四则运算、比较、整数/真分数/带分数 三种显示形式、解析
Expression 表达式二叉树:叶子存数、内部结点存运算符;提供求值、中缀输出(最小括号)、规范形式(判重)
Generator 题目生成器:随机建树、约束校验、去重;对外只需 generate(n)
Parser 递归下降解析器:把 Exercises.txt 中的表达式字符串还原成表达式树;解析答案数值
Grader 判分:读题目与答案文件,逐题重算比对,写 Grade.txt
main 命令行入口:解析参数,调度“生成”或“判分”

4.2 关键流程:生成一道题

生成流程图

生成一道题的过程是:随机决定运算符个数(1~3)→ 递归随机建树(叶子随机取自然数或真分数)→ 一次遍历完成求值 / 校验 / 算指纹 → 约束不满足或指纹重复就重试,否则收下。

4.3 如何判断“两道题重复”?

题目的原话是:两题若能通过有限次交换 +× 的左右操作数变成同一道题,就算重复。注意它只允许交换(交换律),不允许重新结合(结合律)。原文举了两个例子:

  • 3+(2+1)1+2+3 重复(因为 1+2+3 = (1+2)+3,把内层 1+2 交换得 2+1,再把整棵树的左右孩子交换即可得到 3+(2+1));
  • 1+2+33+2+1 不重复(两者树形结构不同,无论怎么交换都变不成对方)。

因此我们把题目建模成二叉树,并定义“规范形式”:递归地,对 +/× 结点把两个孩子的规范串按字典序排好(体现交换律);对 /÷ 结点保持左右顺序(不满足交换律)。两题规范形式相同即判定为重复。这个做法能精确复现原文的两个例子(见第 6 节测试用例 T2)。

一个表达式树的例子((1/2 − 1/6) × (0 + 1/3),答案是 1/9):

表达式树示例

4.4 关于“除法结果必须是真分数”

原文:“生成的题目中如果存在形如 e1 ÷ e2 的子表达式,那么其结果应是真分数。”
我们按字面理解为:任何除法子表达式的商都严格介于 0 与 1 之间,因此在生成时对每个 ÷ 结点强制校验 0 < e1 < e2。这是本题里最“刁”的一条约束,直接决定了 ÷ 结点能否成立。我们把这个判断写进了统一校验函数 checkFp,保证不会漏掉。


5. 代码说明

5.1 数值表示:Fraction(约分与带分数)

long long 存分子分母,构造时即约分、保证分母为正;中间运算用 __int128 做乘除,避免交叉相乘溢出。

void Fraction::normalize() {
    if (den == 0) throw std::invalid_argument("denominator must not be zero");
    if (den < 0) { num = -num; den = -den; }   // 分母恒正
    if (num == 0) { den = 1; return; }
    long long g = gcdll(num, den);
    num /= g; den /= g;                          // 始终保持最简
}

// 整数 -> "6";真分数 -> "3/5";假分数 -> 带分数 "2'3/8"
std::string Fraction::toString() const {
    if (den == 1) return std::to_string(num);
    if (num < den) return std::to_string(num) + "/" + std::to_string(den);
    long long q = num / den, r = num % den;
    if (r == 0) return std::to_string(q);
    return std::to_string(q) + "'" + std::to_string(r) + "/" + std::to_string(den);
}

5.2 中缀输出:按优先级与左结合补最少括号

表达式输出既要正确(还原树结构),又要尽量简洁。规则是:左孩子优先级低于当前运算符时加括号;右孩子优先级不高于当前运算符时加括号(因为左结合)。这样 3+(2+1) 会输出 3 + (2 + 1),而 (3+2)+1 会输出 3 + 2 + 1

std::string Expression::toString() const {
    if (leaf_) return value_.toString();
    std::string l = left_->toString(), r = right_->toString();
    const int p = opPrecedence(op_);                  // + - =1, × ÷ =2
    if (!left_->isLeaf()  && opPrecedence(left_->op_)  <  p) l = "(" + l + ")";
    if (!right_->isLeaf() && opPrecedence(right_->op_) <= p) r = "(" + r + ")";
    return l + " " + opSymbol(op_) + " " + r;
}

5.3 判重:规范形式

std::string Expression::canonical() const {
    if (leaf_) return value_.toString();
    std::string l = left_->canonical(), r = right_->canonical();
    if (op_ == Op::ADD || op_ == Op::MUL)        // 交换律:忽略左右顺序
        if (r < l) std::swap(l, r);
    const char* sym = (op_==Op::ADD)?"+":(op_==Op::SUB)?"-":(op_==Op::MUL)?"×":"÷";
    return "(" + l + sym + r + ")";
}

5.4 生成器核心:一次遍历完成“求值 + 约束 + 指纹”

这是性能优化的核心函数(Generator.cpp 内部)。它递归地完成三件事,并对减法做“交换修正”:

CheckResult checkFp(Expression* e) {
    if (e->isLeaf()) {                                  // 叶子
        const Fraction& v = e->value();
        std::uint64_t h = mix64((std::uint64_t)v.num ^ mix64((std::uint64_t)v.den + 1));
        return {true, v, h};
    }
    CheckResult l = checkFp(e->left()), r = checkFp(e->right());
    if (!l.ok || !r.ok) return {false, {}, 0};

    Fraction out;
    switch (e->op()) {
        case Op::SUB:                                   // 不产生负数
            if (l.value < r.value) { e->swapChildren(); std::swap(l, r); }  // e1<e2 则交换
            out = l.value - r.value; break;
        case Op::DIV:                                   // 结果须为真分数
            if (!(l.value > Fraction(0) && l.value < r.value)) return {false, {}, 0};
            out = l.value / r.value; break;
        case Op::ADD: out = l.value + r.value; break;
        case Op::MUL: out = l.value * r.value; break;
    }
    return {true, out, combineFp(e->op(), l.fp, r.fp)};  // 合并出结构指纹
}

生成主循环则简单直接:

for (int i = 0; i < count; ++i) {
    for (int t = 0; t < kMaxTryPerProblem; ++t) {
        int ops = 1 + rng_() % kMaxOperators;      // 1..3 个运算符
        auto cand = buildTree(ops);                 // 随机建树
        CheckResult res = checkFp(cand.get());      // 求值 + 约束 + 指纹
        if (!res.ok) continue;                      // 约束不满足,重试
        if (seen_.insert(res.fp).second) {          // 指纹判重
            result.push_back(std::move(cand)); break;
        }
    }
}

5.5 判分:Grader(附加分项)

判分不需要相信任何一方的答案——它自己把每道题重算一遍,再与答案文件逐题比对:

Fraction truth = Parser::parseExpression(ex)->eval();   // 重算标准答案
Fraction got;  bool ok = Parser::parseValue(ans, got);  // 读取学生答案
if (ok && got == truth) correct.push_back(idx); else wrong.push_back(idx);

6. 测试运行

测试采用“三层验证”:

  • 单元测试 tests/unit_test.cpp:28 项,覆盖分数运算、数值解析、去重等价、生成器不变量;
  • 端到端测试 tests/run_tests.sh:14 项,覆盖命令行、文件格式、判分、一万题;
  • 独立校验器 tests/verify_exercises.py:用 Python(完全独立于 C++ 实现) 重新解析题目、重算答案、检查全部约束与重复——是对正确性最有力的旁证。

6.1 测试用例(≥10 个)

编号 输入 / 操作 期望结果 实际结果
T1 Myapp -n 5(缺少 -r 报错并打印帮助,非零退出码 ✅ 退出码 1,打印“必须使用 -r …”与帮助
T2 1+2+3 / (1+2)+3 / 3+(2+1) / 3+2+1 判等 前三个等价、与 3+2+1 不等价 ✅ 单元测试通过(符合原文例子)
T3 Fraction(1,6)+Fraction(1,8) 7/24 7/24
T4 Fraction(19,8).toString() 带分数 2'3/8 2'3/8
T5 Myapp -n 10 -r 10 生成 10 行题目与 10 行答案,每行以 expr = 结尾 ✅ 格式正确
T6 Myapp -r 1(极值,只有 0/1) 正常运行 ✅ 正常生成
T7 Myapp -n 10000 -r 10 生成 10000 道且两两不重复 ✅ 10000 行,去重后仍 10000
T8 独立校验器验证 10000 题 答案全对、无负数、除法均真分数、运算符≤3、不重复 ✅ 全部通过
T9 判分:答案全对 Correct: 4 (1, 2, 3, 4) / Wrong: 0 () ✅ 一致
T10 判分:故意错第 2、4 题 Correct: 2 (1, 3) / Wrong: 2 (2, 4) ✅ 一致
T11 Myapp -e A -a B 中含带分数答案 2'3/8 - 1/2 = 1'7/8 判定正确 ✅ 正确识别
T12 Myapp -n 100000 -r 2(范围根本不够) 告警提示无法凑足,退出码 3 ✅ 输出告警
T13 未知参数 -x 报错 ✅ “无法识别参数”
T14 生成 20 万题后独立校验 全部满足约束且不重复 ✅ 通过

6.2 一次真实运行的样例

Myapp -n 10 -r 10 生成的 Exercises.txt

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

对应的 Answers.txt2 / 1/9 / 5/18 / 1 / 5'11/12 / 7'1/3 / 15/49 / 18 / 1 / 1

我们来人工核对几题:

  • (1/2 - 1/6) × (0 + 1/3) = 1/3 × 1/3 = 1/9
  • 7 - 1/3 - 2/3 ÷ 8/9 = 7 - 1/3 - 3/4 = 5'11/12(除法的 2/3 < 8/9,结果为真分数 3/4)✅
  • 5/7 ÷ 7 × (8 - 5) = 5/49 × 3 = 15/49(除法的 5/7 < 7)✅

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

  1. 约束是“构造性”满足的:题目不是“先生成再过滤掉不合法的”,而是在 checkFp 里对每个结点当场校验——除法不满足 0<e1<e2 直接判废,减法不满足就交换左右。因此生成的题目在定义上不会出现负数或非真分数的除法。
  2. 答案用精确有理数计算:全程 Fraction 约分运算,不存在浮点误差。
  3. 有独立的第三方实现交叉验证verify_exercises.py 用 Python 的 fractions.Fraction 独立地重新解析与计算每一道题,检查答案、负数、真分数除法、运算符个数与重复性。它通过,说明 C++ 端的结果不依赖于某个实现细节的巧合。
  4. 去重逻辑有原文例子背书1+2+33+2+1 不重复、3+(2+1)1+2+3 重复,都在单元测试里覆盖到了。

6.3 一万题实测

$ Myapp -n 10000 -r 10
已生成 10000 道题目 -> Exercises.txt
答案已写入 -> Answers.txt
$ python3 tests/verify_exercises.py Exercises.txt Answers.txt
校验通过:10000 道题,答案全部正确、无负数、除法均为真分数、运算符≤3 且两两不重复。

7. PSP 表格(实际)

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

差异分析: 需求分析比预估更久,因为“重复”的定义与“除法结果必须是真分数”两条反复讨论才达成一致;具体编码反而比预估快,原因是前期把类结构定得比较清楚;测试时间比预估少,是因为我们另写了一版 Python 独立校验器,把大量人工核对自动化了。总体实际耗时(360)略低于预估(385),偏差约 6.5%,在可接受范围内。


8. 项目小结

8.1 成败得失

做得好的地方:

  • 数据结构选得准。 从一开始就把题目建模成二叉树,让“求值 / 输出括号 / 判重”三件事都变成对树的递归,代码量小、逻辑集中,也精确匹配了“交换律判重”的题面口径。
  • “构造性满足约束” 让正确性变得显然:与其生成一堆再筛,不如在构造时就保证 不出负数、÷ 是真分数。
  • 独立校验器 是我们最满意的一步:用另一种语言、另一套实现再算一遍,把“我是不是自己骗自己”这个风险消掉了。

踩过的坑:

  • Makefile 漏了头文件依赖,改了 Expression.hmake 没有重编依赖它的 .o,导致新旧目标文件混链、程序段错误。一度以为是优化引入的 bug,排查后才发现是构建脚本的问题。教训:-MMD -MP 生成依赖文件是标配。
  • 对“重复”的口径一开始理解偏了,直觉以为“加减乘除都可交换”,差点把 1+2+33+2+1 也判成重复。后来逐字抠题面才纠正过来。

8.2 经验与教训

  1. 题面要逐字读。 本题两条最关键的约束(判重口径、除法真分数)都藏在措辞里,想当然就会做错。
  2. 性能优化要基于数据。 我们先跑了 gprof,看到瓶颈在生成循环与随机数,才决定优化方向;如果凭感觉去优化字符串输出,多半是做无用功。
  3. 测试要多层次。 单元测试管函数对错、端到端管交互、独立校验器管整体正确性,三者缺一不可。
  4. 提交要小步快跑。 我们按“模块 + 优化 + 文档”分成多次提交,每次改动聚焦、注释清晰,回看历史时能一眼看懂每一步做了什么。

8.3 结对感受

同学 A: 我主要负责生成器与性能优化,搭档负责判分、解析与测试。结对最大的好处是“有人盯着你写”——我把判重从字符串改成指纹的那次改动,是搭档提醒我“先确认语义等价再谈性能”,才没在没测试保护的情况下贸然改。缺点是初期我们对“重复”的理解不一致,白写了一点代码;后来约定“拿题面例子当唯一标准”才顺畅起来。

同学 B: 我主要负责解析器、判分器和测试。搭档在性能上的敏感度很强,gprof 一出图他立刻定位到热点函数,这是我平时容易忽略的视角。我则比较较真于边界情况(-r 1、范围不够、带分数答案),这部分被证明很有价值。遗憾是接口约定得稍晚,如果一开始就用一页纸写清 Expression 的对外方法,能少一次返工。

彼此的闪光点:

  • 给对方点赞:A 的逻辑抽象能力与性能意识(把 tree 抽象贯彻到底);B 的严谨与测试意识(坚持要写独立校验器)。
  • 给对方的建议:A 可以多写一点注释“为什么这么做”,B 可以适当在探索期先跑通、再求完美。

8.4 一起学到的

结对不是“一人写一半”,而是用两个人的差异去覆盖彼此的盲区:一个盯性能与结构,一个盯边界与正确性,最后合起来才是一个既快又不容易出错、还能被第三个人(判分器 / Python 校验器)验证通过的成果。


附录:关键提交记录

fix(build): Makefile 增加 -MMD 头文件依赖
perf(generator): 约 1.6x 提速
test: 单元测试、端到端脚本与独立校验器
feat(cli): 命令行入口
feat(grader): 判题并输出 Grade.txt
feat(parser): 表达式与数值解析(递归下降)
feat(generator): 题目生成器
feat(expression): 表达式二叉树
feat(fraction): 有理数类
chore: 初始化项目结构与构建脚本

(完)

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