结对项目(程飞扬,冯柏森)
结对项目:小学四则运算题目生成器
姓名一:
冯柏森学号:3124004242
姓名二:程飞扬学号:3124004240
GitHub 项目地址:https://github.com/Masterfred06/exercise_generate/tree/main
一、项目概述
本项目实现了一个 C++17 命令行小学四则运算题目生成器,支持题目生成、答案生成和答案批改。程序将表达式保存为二叉树,使用精确分数完成计算,避免浮点数误差;生成题目时通过规范签名处理加法和乘法的交换等价关系,并保证减法、除法和运算符个数满足作业要求。
项目包含两个可以独立编译的版本:
baseline:优化前版本,使用vector<string>顺序扫描题目签名。optimized:优化后版本,使用unordered_set<string>哈希判重,并缓存表达式签名。
核心命令如下:
# 在项目根目录执行
.\build.ps1
.\test.ps1
.\scripts\benchmark.ps1 -Repeats 10 -Range 10
二、PSP2.1
下面的“预估耗时”在开始编码前填写,“实际耗时”在开发完成后依据两位同学的记录填写。单位为分钟。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 25 | 20 |
| · Estimate | · 估计任务需要的时间 | 25 | 20 |
| Development | 开发 | 460 | 485 |
| · Analysis | · 需求分析(包括学习新技术) | 55 | 50 |
| · Design Spec | · 生成设计文档 | 35 | 40 |
| · Design Review | · 设计复审 | 20 | 20 |
| · Coding Standard | · 制定代码规范 | 15 | 10 |
| · Design | · 具体设计 | 55 | 55 |
| · Coding | · 具体编码 | 190 | 205 |
| · Code Review | · 代码复审 | 35 | 35 |
| · Test | · 测试、修改和提交 | 55 | 70 |
| Reporting | 报告 | 90 | 95 |
| · Test Report | · 测试报告 | 35 | 35 |
| · Size Measurement | · 计算工作量 | 15 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结与过程改进计划 | 40 | 50 |
| 合计 | 575 | 600 |
本次性能改进单独记录为 35 分钟:建立可重复基准约 10 分钟,定位热点约 10 分钟,修改判重容器约 10 分钟,复测和整理约 5 分钟。
三、需求分析与边界约定
3.1 功能需求映射
| 作业要求 | 项目实现 |
|---|---|
-n 控制题目数量 |
-n 为正整数,缺省为 10,最多接受 100000 |
-r 控制自然数、真分数和分母范围 |
生成模式必须提供正整数 -r,操作数和分母均小于 r |
| 减法不能产生负数 | 建立减法节点前检查 left >= right |
| 除法结果为真分数 | 建立除法节点前检查 0 < left < right |
| 每题最多 3 个运算符 | 随机选择 1、2、3 个运算符并递归分配到左右子树 |
| 一次运行题目不重复 | 对表达式树生成规范签名,+ 和 × 节点按签名字典序交换归一化 |
| 输出题目和答案 | 当前目录生成 Exercises.txt 和 Answers.txt |
| 批改并统计 | -e、-a 同时提供时生成当前目录下的 Grade.txt |
| 支持一万道题 | 通过固定种子和性能脚本实测 -n 10000 |
3.2 关键边界约定
-r必须给定且为正整数。-n不给出时取 10。-e与-a必须同时给出;如果只给出一个,程序报错并显示帮助。- 除法结果要求为真分数,所以生成除法时严格满足
0 < e1 < e2。这样同时避免了除数为 0、结果为 0 和结果大于等于 1 的情况。 - 判重只允许交换某个
+或×节点的左右子树,不使用结合律把整棵树展平。例如3 + (2 + 1)与1 + 2 + 3的规范签名相同,而1 + 2 + 3与3 + 2 + 1的树结构不同,签名也不同。 - 分数始终约分保存。输入输出使用普通分数
3/5和带分数2’3/8,整数直接输出为整数。
四、设计实现过程
4.1 技术方案
程序使用 C++17 实现,不依赖第三方库。表达式采用二叉树表示:叶子节点是自然数或分数,内部节点是 +、-、×、÷。每个节点保存自己的精确计算结果,因此生成、格式化、判重和判卷都可以复用同一棵树。
4.2 模块划分
| 文件或模块 | 主要职责 |
|---|---|
ArithmeticCore.h |
声明 Fraction、Expression、Parser、生成结果和判卷结果 |
ArithmeticCore.cpp |
实现分数四则运算、表达式树计算、格式化、规范签名、解析和判卷 |
baseline/src/Myapp.cpp |
参数处理、随机生成和顺序扫描判重 |
optimized/src/Myapp.cpp |
参数处理、随机生成和哈希集合判重 |
Generator |
随机生成 1~3 个运算符的表达式,并执行约束检查 |
Fraction |
约分、比较、四则运算以及普通分数和带分数输出 |
Expression |
保存二叉树、节点值和已经计算的签名 |
Parser |
解析题目、括号、运算符、普通分数、带分数和答案 |
优化前后版本共用相同的算术核心和生成规则,差异只集中在题目签名的存储与查找方式,便于进行公平性能比较。
4.3 生成流程
4.4 判卷流程
五、代码说明
5.1 精确分数运算
Fraction 构造时立即约分,并把分母统一为正数。加减法使用分母最大公约数减少中间数;乘除法先交叉约分,再使用 __int128 检查乘法结果是否超出 long long 范围。这样 1/6 + 1/8 的结果是精确的 7/24,不会经过浮点数。
Fraction operator+(const Fraction& a, const Fraction& b) {
long long divisor = gcd(a.denominator, b.denominator);
long long leftScale = b.denominator / divisor;
long long rightScale = a.denominator / divisor;
return Fraction(checked((__int128)a.numerator * leftScale +
(__int128)b.numerator * rightScale),
checked((__int128)a.denominator * leftScale));
}
5.2 生成约束
生成减法和除法时,先根据两个子表达式的值决定可选运算符,再构造父节点。加法和乘法始终可选,因此不会因为某次随机结果不满足减法或除法条件而产生非法题目。
vector<Operator> choices = {Add, Multiply};
if (left->value >= right->value) {
choices.push_back(Subtract);
}
if (left->value > Fraction(0) && right->value > Fraction(0) &&
left->value < right->value) {
choices.push_back(Divide);
}
return makeOperation(choices[random(0, (int)choices.size() - 1)], left, right);
5.3 规范签名与判重
签名保留表达式树结构。对于 + 和 × 节点,只将左右子树的签名按字典序排序;对于 - 和 ÷ 节点保留左右顺序。因此交换律能够被识别,结合律不会被错误地加入判重规则。
string left = signature(node->left);
string right = signature(node->right);
if ((node->op == Add || node->op == Multiply) && right < left) {
swap(left, right);
}
node->savedSignature = string(1, symbol) +
"(" + left + "," + right + ")";
optimized 版本用一次 insert 同时完成查找和插入:
unordered_set<string> usedSignatures;
usedSignatures.reserve(count * 2);
if (!usedSignatures.insert(key).second) continue;
5.4 表达式解析与输出
解析器分为 parseFactor、parseTerm 和 parseExpression 三层,分别处理括号、乘除和加减,从而保证运算优先级。输出时,如果右子树与父节点优先级相同,程序保留括号,确保输出文本重新解析后仍具有原来的二叉树结构。
六、效能分析
6.1 优化前的问题
优化前版本把所有签名放在 vector<string> 中,每次产生新题都从头扫描:
bool scanDuplicate(const vector<string>& values, const string& target) {
return find(values.begin(), values.end(), target) != values.end();
}
生成 n 道题时,判重部分需要进行近似 1 + 2 + ... + n 次比较,时间复杂度约为 O(n²)。
6.2 优化思路
性能分析分为四步:
- 使用相同范围、相同随机种子和相同题目数量,分别测量两版程序。
- 使用
gprof对优化前版本进行函数采样,定位顺序扫描热点。 - 将签名容器从
vector<string>替换为unordered_set<string>,使用平均O(1)的查找和插入。 - 在
Expression节点内缓存已经计算的签名,避免同一节点重复递归拼接字符串。
一次 gprof 采样中,std::__find_if 占有效采样的 66.67%,对应优化前的顺序扫描。由于程序单次运行时间很短,gprof 的采样百分比只用于定位热点,不能当作精确函数耗时;完整记录保存在 docs/profile-summary.txt。
6.3 实测环境与结果
测试环境为 Windows、MinGW-w64 g++ 15.2.0、-O2,范围固定为 10,每组执行 10 次取平均值。脚本直接生成 docs/performance-results.md 和 docs/images/performance.svg;前者是唯一的长期性能数据记录,后者是博客使用的性能分析图。测试中间文件写入系统临时目录,并在结束时自动清理。
| 题目数 | 优化前 / ms | 优化后 / ms | 加速比 |
|---|---|---|---|
| 1,000 | 38.74 | 19.59 | 1.98× |
| 3,000 | 23.50 | 20.31 | 1.16× |
| 5,000 | 40.11 | 24.76 | 1.62× |
| 10,000 | 100.26 | 35.43 | 2.83× |


小规模时进程启动和文件写入仍有影响,3,000 题加速比较小;题量增大后顺序扫描的二次增长更明显。10,000 题时哈希判重平均节省 64.83 ms,耗时降低约 64.66%。完整记录见 docs/performance-results.md。gprof 显示优化前最热点函数是 std::__find_if,占有效采样的 66.67%。
七、测试运行
7.1 自动化测试
在项目根目录只需执行一次:
.\test.ps1
该脚本自动完成编译、16 项 C++ 测试、命令行参数检查、作业要求的固定判卷测试和一万题回批。所有中间文件位于系统临时目录,成功或失败都会自动清理。本次实际运行结果为 16 项通过,0 项失败,全部集成检查通过。测试用例如下:
| 编号 | 测试内容 | 预期结果 |
|---|---|---|
| 1 | 2/4 与 1/2 比较 |
自动约分后相等 |
| 2 | 1/6 + 1/8 |
7/24 |
| 3 | 19/8 输出 |
2’3/8 |
| 4 | 3/4 ÷ 3/2 |
1/2 |
| 5 | 1 + 2 × 3 |
7,乘法优先 |
| 6 | (1 + 2) × 3 |
9,括号优先 |
| 7 | 2’3/8 + 5/8 |
3 |
| 8 | Unicode 除号 1 ÷ 2 |
1/2 |
| 9 | 3+(2+1) 与 1+2+3 |
判为重复 |
| 10 | 1+2+3 与 3+2+1 |
判为不重复 |
| 11 | 固定种子生成 2,000 题 | 数量为 2,000 |
| 12 | 遍历所有减法、除法子树 | 减法非负,除法结果为真分数 |
| 13 | 检查 2,000 个规范签名 | 全部唯一 |
| 14 | 输出后重新解析 | 表达式值和签名不变 |
| 15 | -r 1 生成 10 题 |
成功完成且不死循环 |
| 16 | 读取 10 道长期判卷夹具 | Correct: 5 (1, 3, 5, 7, 9),Wrong: 5 (2, 4, 6, 8, 10) |
7.2 命令行集成测试
test.ps1 还执行了以下集成测试:
| 编号 | 测试场景 | 实际结果 |
|---|---|---|
| A | 固定种子生成 10 道题 | Exercises.txt 和 Answers.txt 均为 10 行 |
| B | 使用 tests/fixtures/ 中的固定题目和错误答案判卷 |
5 题正确、5 题错误,完整内容与 expected-grade.txt 相同 |
| C | 生成模式缺少 -r |
退出码 1,并输出帮助信息 |
| D | 2/4 作为 1/2 的答案 |
可以识别等值分数 |
| E | 生成并回批 10,000 道题 | Correct: 10000,Wrong: 0 |
| F | 脚本结束后的临时文件检查 | 系统临时测试目录已删除 |
固定题目、错误答案和预期判卷结果长期保存在 tests/fixtures/,而不是在项目根目录临时生成。判断正确性的依据包括分数运算、解析和括号、题目给出的判重反例、生成树的所有子节点约束、完整 Grade 文本比较和一万题回批。
运行完整测试脚本后,16 项单元测试全部通过,作业要求的判卷示例为 5 题正确、5 题错误,一万题回批全部正确:

固定种子生成 10 道题,题目与答案文件如下。表达式包含自然数、真分数、带分数、乘除和括号,答案使用约分后的分数或带分数:

使用长期夹具批改后,Grade.txt 与作业要求的格式一致:

八、运行说明
8.1 环境要求
- Windows PowerShell
- MinGW-w64 g++,支持 C++17
- 当前仓库提供已经编译好的
optimized/Myapp.exe、baseline/MyappBaseline.exe和tests/ArithmeticTests.exe
8.2 编译
在项目根目录执行:
.\build.ps1
构建脚本使用项目根目录内的相对路径,避免中文工程路径导致 MinGW 链接器创建输出文件失败。生成文件为:
baseline/MyappBaseline.exe
optimized/Myapp.exe
tests/ArithmeticTests.exe
8.3 生成题目
# 生成 10 道题,范围为 10,操作数和分母均小于 10
.\optimized\Myapp.exe -n 10 -r 10
# 不写 -n 时默认生成 10 道题
.\optimized\Myapp.exe -r 10
# 固定随机种子,便于复现实验
.\optimized\Myapp.exe -n 100 -r 10 --seed 20260921
程序在当前工作目录生成:
Exercises.txt
Answers.txt
8.4 完整测试与批改示例
.\test.ps1
脚本内嵌了完整操作流程,直接使用 tests/fixtures/grading-exercises.txt 和 tests/fixtures/grading-answers.txt 批改,不需要手工复制或修改答案。控制台会输出:
Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)
8.5 性能测试
.\scripts\benchmark.ps1 -Repeats 10 -Range 10
默认测试题目数为 1,000、3,000、5,000、10,000。脚本更新唯一的性能记录 docs/performance-results.md 和性能图 docs/images/performance.svg;中间题目文件写入系统临时目录并自动清理,不创建持久的 build/。
九、项目小结与结对感受
这次项目中最容易出错的部分是“重复题目”的定义。只比较答案会把不同表达式错误地合并;把所有加法和乘法完全展开又会错误使用结合律。我们最终保留表达式树,只在交换律允许的节点上排序子签名,使题目示例中的重复和不重复情况都能得到符合要求的结果。
第二个难点是分数处理。使用浮点数会在约分和判卷时引入误差,因此我们把分子、分母作为整数保存,在每次构造和运算后约分,并为中间乘法增加溢出检查。这样既能输出普通分数和带分数,也能正确判断 2/4 与 1/2 相等。
性能优化的经验是先建立可重复的基准,再修改算法。优化前的顺序扫描在小规模数据上不明显,但在一万道题时成为主要开销。将签名容器换成哈希集合后,生成逻辑、表达式计算和判卷逻辑都不变,测试结果仍然稳定。
结对过程中,程飞扬主要负责需求拆分、边界条件和测试设计,冯柏森主要负责表达式树、解析器和性能改进;双方通过代码复审共同确认了括号、除法真分数和判重签名的实现。后续改进方向是把 PSP 时间记录和性能原始数据从开发第一天开始维护,并在每次重要修改后立即运行完整回归测试。
浙公网安备 33010602011771号