结对项目(程飞扬,冯柏森)

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

姓名一:冯柏森 学号: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 关键边界约定

  1. -r 必须给定且为正整数。-n 不给出时取 10。
  2. -e 与 -a 必须同时给出;如果只给出一个,程序报错并显示帮助。
  3. 除法结果要求为真分数,所以生成除法时严格满足 0 < e1 < e2。这样同时避免了除数为 0、结果为 0 和结果大于等于 1 的情况。
  4. 判重只允许交换某个 + 或 × 节点的左右子树,不使用结合律把整棵树展平。例如 3 + (2 + 1) 与 1 + 2 + 3 的规范签名相同,而 1 + 2 + 3 与 3 + 2 + 1 的树结构不同,签名也不同。
  5. 分数始终约分保存。输入输出使用普通分数 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 生成流程

flowchart TD A[读取 -n、-r 和随机种子] --> B[随机选择 1 到 3 个运算符] B --> C[递归生成左右子树] C --> D{选择父节点运算符} D -->|加或乘| E[建立父节点] D -->|减| F{左值是否大于等于右值} F -->|是| E F -->|否| D D -->|除| G{是否满足 0 小于左值小于右值} G -->|是| E G -->|否| D E --> H[递归计算规范签名] H --> I{签名是否已存在} I -->|是| B I -->|否| J[保存题目和答案] J --> K{数量是否达到 n} K -->|否| B K -->|是| L[写入 Exercises.txt 和 Answers.txt]

4.4 判卷流程

flowchart TD A[读取题目文件和答案文件] --> B[按题号保存答案] B --> C[逐题解析表达式] C --> D[使用 Fraction 计算标准答案] D --> E{学生答案能否解析且数值相等} E -->|是| F[题号加入 Correct] E -->|否| G[题号加入 Wrong] F --> H{是否还有题目} G --> H H -->|是| C H -->|否| I[写入 Grade.txt]

五、代码说明

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 优化思路

性能分析分为四步:

  1. 使用相同范围、相同随机种子和相同题目数量,分别测量两版程序。
  2. 使用 gprof 对优化前版本进行函数采样,定位顺序扫描热点。
  3. 将签名容器从 vector<string> 替换为 unordered_set<string>,使用平均 O(1) 的查找和插入。
  4. 在 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×

benchmark测试

performance

热点函数

小规模时进程启动和文件写入仍有影响,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 时间记录和性能原始数据从开发第一天开始维护,并在每次重要修改后立即运行完整回归测试。

posted @ 2026-09-21 19:06  FY_C  阅读(15)  评论(0)    收藏  举报