结对项目:小学四则运算题目生成器(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. 需求分析
题目要求实现一个命令行程序,完成两件事:批量生成题目 与 判分。核心需求归纳如下:
-n控制题目个数;-r控制数值范围(自然数、真分数及其分母均小于r),-r必须给定否则报错并给出帮助。- 计算过程不产生负数:任意子表达式
e1 − e2需满足e1 ≥ e2。 - 除法结果必须是真分数:子表达式
e1 ÷ e2的商须严格介于 0 与 1 之间(即0 < e1 < e2)。 - 每道题运算符不超过 3 个。
- 一次运行生成的题目不能重复;判定“重复”的口径是“能否通过有限次交换
+/×的左右操作数变成同一道题”。 - 题目写入
Exercises.txt,答案写入Answers.txt;真分数写作3/5,带分数写作2'3/8。 - 支持一万道题目。
- 支持
-e <题目文件> -a <答案文件>判分,结果写入Grade.txt(附加分项)。
其中“重复”的定义和“除法结果为真分数”是最容易被想当然、也最容易做错的两点,我们在设计阶段专门花时间琢磨了它们(详见第 4 节)。
3. 效能分析
改进性能上我们一共投入约 90 分钟(约 1.5 小时)。
3.1 改进思路
程序的耗时几乎全部集中在生成题目这一个环节:随机建表达式树、校验约束、判重,不满足就重来。围绕它我们做了三处优化:
-
判重键:字符串 → 64 位结构指纹。
初版把表达式规约成“规范形式字符串”(如((1+2)×3))再放进std::set<string>。字符串拼接与分配开销大。改进后用一个uint64_t结构指纹(叶子mix64(num,den),内部结点把两个子指纹与运算符标签混合;对+/×先排序以满足交换律)。语义完全等价,但省掉了全部字符串分配。 -
两次遍历 → 一次遍历。
初版先evaluateChecked(求值 + 校验约束),再canonical(生成判重键),对同一棵树走了两遍。改进后合并成一个checkFp()递归:一次递归同时完成求值、约束校验、指纹计算。 -
把“非法”变“合法”,降低拒绝率。
初版是纯粹的“拒绝采样”:减法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):

结论:消耗最大的函数是 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+3与3+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.txt:2 / 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)✅
为什么我们能确定程序是正确的?
- 约束是“构造性”满足的:题目不是“先生成再过滤掉不合法的”,而是在
checkFp里对每个结点当场校验——除法不满足0<e1<e2直接判废,减法不满足就交换左右。因此生成的题目在定义上不会出现负数或非真分数的除法。 - 答案用精确有理数计算:全程
Fraction约分运算,不存在浮点误差。 - 有独立的第三方实现交叉验证:
verify_exercises.py用 Python 的fractions.Fraction独立地重新解析与计算每一道题,检查答案、负数、真分数除法、运算符个数与重复性。它通过,说明 C++ 端的结果不依赖于某个实现细节的巧合。 - 去重逻辑有原文例子背书:
1+2+3与3+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.h后make没有重编依赖它的.o,导致新旧目标文件混链、程序段错误。一度以为是优化引入的 bug,排查后才发现是构建脚本的问题。教训:-MMD -MP生成依赖文件是标配。 - 对“重复”的口径一开始理解偏了,直觉以为“加减乘除都可交换”,差点把
1+2+3和3+2+1也判成重复。后来逐字抠题面才纠正过来。
8.2 经验与教训
- 题面要逐字读。 本题两条最关键的约束(判重口径、除法真分数)都藏在措辞里,想当然就会做错。
- 性能优化要基于数据。 我们先跑了 gprof,看到瓶颈在生成循环与随机数,才决定优化方向;如果凭感觉去优化字符串输出,多半是做无用功。
- 测试要多层次。 单元测试管函数对错、端到端管交互、独立校验器管整体正确性,三者缺一不可。
- 提交要小步快跑。 我们按“模块 + 优化 + 文档”分成多次提交,每次改动聚焦、注释清晰,回看历史时能一眼看懂每一步做了什么。
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: 初始化项目结构与构建脚本
(完)

浙公网安备 33010602011771号