结对项目作业(胡浩东,石基业)
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 作业要求 |
| 这个作业的目标 | 用 C++ 实现小学四则运算题目的生成与批改程序,覆盖命令行参数、题目约束、去重、一万道题目的规模要求,并完成 PSP、效能分析、单元测试与结对总结 |
结对项目:小学四则运算题目生成与批改程序
一、项目基本信息
| 项目 | 内容 |
|---|---|
| 成员一 | 【胡浩东】(学号:【3123006078】) |
| 成员二 | 【石基业】(学号:【3124007607】) |
| GitHub 地址 | https://github.com/hhd233cored/hhd233cored/tree/main/3123006078/four_arithmetic |
| 开发语言 | C++17 |
| 开发环境 | Windows、Visual Studio / CMake |
本项目实现了一个命令行小学四则运算题目生成与批改程序。程序能够生成包含自然数、普通分数和混合分数的四则运算题目,保证减法过程不产生负数、除法子表达式的结果为真分数,并能排除通过交换加法或乘法左右子表达式得到的重复题目。此外,程序还可以读取题目文件和答案文件,统计正确与错误题号。

二、PSP 2.1
在开始编码前,我们根据需求复杂度估计了各阶段耗时。项目结束后又记录了实际耗时,以便比较计划与执行之间的偏差。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 40 |
| · Estimate | · 估计任务所需时间 | 30 | 40 |
| Development | 开发 | 570 | 600 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 50 |
| · Design Spec | · 生成设计文档 | 45 | 55 |
| · Design Review | · 设计复审 | 30 | 30 |
| · Coding Standard | · 制定代码规范 | 20 | 10 |
| · Design | · 具体设计 | 75 | 85 |
| · Coding | · 具体编码 | 240 | 250 |
| · Code Review | · 代码复审 | 40 | 40 |
| · Test | · 测试、修改与提交 | 60 | 60 |
| Reporting | 报告 | 180 | 200 |
| · Test Report | · 测试报告 | 60 | 70 |
| · Size Measurement | · 计算工作量 | 20 | 30 |
| · Postmortem & Process Improvement Plan | · 事后总结与过程改进计划 | 100 | 100 |
| 合计 | 780 | 840 |
从最终记录看,偏差最大的阶段是开发阶段。主要原因是最初低估了表达式等价去重和分数溢出处理的复杂度。后续如果再次进行类似项目,我们会在设计阶段先列出边界条件和测试矩阵,再进入编码,以减少返工。
三、需求分析
程序需要解决的不只是“随机拼接字符串”,而是同时满足以下约束:
-
每道题包含 1~3 个运算符,并遵循正常的运算优先级和左结合规则。
-
数字可以是自然数、普通分数或混合分数,计算和输出必须保持精确,不能使用浮点近似。
-
任意减法子表达式都不能产生负数。
-
任意除法子表达式的结果都必须是非零真分数。
-
同一次生成的题目不能通过有限次交换 + 或 × 的左右子表达式而互相转换。
-
程序需要支持一次生成 10000 道题,不能因重复候选或非法候选陷入无限循环。
-
批改模式需要正确识别整数、普通分数、混合分数以及合法算术表达式形式的答案。
基于这些要求,我们没有直接生成表达式字符串,而是先生成表达式树。在树的每个节点上保存当前子表达式的精确计算结果,由此可以在构造阶段判断减法和除法是否满足限制。
四、设计与实现过程
4.1 总体结构
项目的主要文件如下:
four_arithmetic/
├── main.cpp # 程序全部核心逻辑
├── CMakeLists.txt # CMake 构建配置
├── FourArithmetic.sln # Visual Studio 解决方案
├── FourArithmetic.vcxproj # Visual Studio 工程配置
└── README.md # 编译和使用说明
核心模块之间的关系如下:
4.2 Fraction:精确有理数运算
如果用 double 计算分数,1/3 等数值会出现精度误差,批改时可能错误地判断两个等价答案。因此程序使用分子和分母表示有理数,并在构造时通过最大公约数自动约分,使每个分数始终处于最简形式。
Fraction(std::int64_t numeratorValue, std::int64_t denominatorValue)
: numerator(numeratorValue), denominator(denominatorValue) {
if (denominator == 0) {
throw std::invalid_argument("分母不能为 0");
}
if (denominator < 0) {
numerator = -numerator;
denominator = -denominator;
}
const std::int64_t divisor = static_cast<std::int64_t>(
gcd64(absoluteValue(numerator), absoluteValue(denominator)));
numerator /= divisor;
denominator /= divisor;
}
程序还在加、减、乘等运算前进行 int64_t 溢出检查。乘法会先约去交叉公因数,再计算新的分子和分母,从而降低中间结果溢出的概率。
输出时根据数值自动选择三种格式:
- 整数:3
- 普通分数:3/5
- 混合分数:2’3/8
4.3 Expression:表达式树
表达式由一棵二叉树表示。叶子节点保存一个 Fraction,非叶子节点保存运算符、左右子树以及该子表达式的计算结果。
struct Expression {
bool isNumber = false;
Fraction value;
Operation operation = Operation::Add;
std::unique_ptr<Expression> left;
std::unique_ptr<Expression> right;
};
生成含 k 个运算符的表达式时,根节点占用一个运算符,其余 k-1 个随机分配到左右子树。这样既能严格控制运算符总数,也能生成不同的括号结构。
4.4 在生成阶段保证合法性
每建立一个运算节点,程序立即调用 applyOperation 计算并检查结果:
case Operation::Subtract:
if (Fraction::less(left, right)) {
return false;
}
return Fraction::subtract(left, right, result);
case Operation::Divide:
if (!Fraction::divide(left, right, result)) {
return false;
}
return result.numerator > 0 && result.numerator < result.denominator;
减法只有在左值不小于右值时才被接受。除法首先排除除数为零等非法情况,然后要求最终结果大于 0 且小于 1。这种做法保证了约束对所有子表达式成立,而不只是对整道题的最终答案成立。
4.5 等价题目去重
去重是本项目中最关键的设计之一。我们为每棵表达式树生成一个规范键:
- 数字节点使用约分后的“分子/分母”表示;
- 减法和除法保留左右顺序;
- 加法和乘法先递归获得左右子树的规范键,再按字典序排序。
std::string left = canonical(expression->left.get());
std::string right = canonical(expression->right.get());
if (isCommutative(expression->operation) && right < left) {
std::swap(left, right);
}
return "(" + std::string(1, operationChar(expression->operation)) +
" " + left + " " + right + ")";
例如 23 + 45 与 45 + 23 会产生相同的规范键。对于嵌套表达式,规范化会递归作用于每个允许交换的节点,因此也能处理题目要求中的 3 + (2 + 1) 等情况。规范键被放入 unordered_set,平均查重复杂度为 O(1)。
需要注意的是,程序只应用题目明确允许的“交换左右子表达式”规则,不会随意使用结合律重组表达式树,因此不会把题目要求中本应不同的结构误判为重复。
4.6 表达式输出与括号
输出函数根据运算符优先级决定是否添加括号。由于运算符采用左结合规则,右侧出现同优先级子树时也要保留括号,否则输出后重新解析可能改变原来的树结构。例如 1 + (2 + 3) 不能无条件输出为 1 + 2 + 3。
最终题目文件格式如下:
1. 3 ÷ 4’1/2 =
2. 8’2/3 − 6’1/7 =
答案文件格式如下:
1. 2/3
2. 2’11/21
4.7 批改功能
批改模式通过下面的命令启动:
Myapp.exe -e Exercises.txt -a Answers.txt
程序逐行读取题目和答案,删除编号和题目末尾的等号,再通过递归下降解析器分别计算正确结果和用户答案。两个结果都会被化为最简分数,因此 1/2、2/4 和 0’1/2 会被判为相等。非法答案只会使当前题目进入 Wrong,不会影响后续题目。
批改结果示例:
Correct: 5 (1, 3, 5, 7, 9)
Wrong: 5 (2, 4, 6, 8, 10)
五、代码规范与结对开发方式
我们约定采用以下规范:
- 使用 C++17,开启编译器警告;
- 类型和类使用清晰的英文名称,关键算法添加中文注释;
- 使用 RAII 和
std::unique_ptr管理表达式树,避免手动释放内存; - 文件读写、参数错误和表达式格式错误统一通过异常报告;
- 每次提交只完成一个相对独立的修改,提交信息说明“修改了什么”和“为什么修改”;
- 一人编写核心实现,另一人重点检查边界条件、运行测试和代码可读性,再交换角色复审。
请根据真实协作方式修改最后两项,不要写与实际情况不符的内容。
六、程序使用方法
6.1 编译
使用 CMake:
cmake -S . -B build
cmake --build build --config Release
也可以使用 Visual Studio 打开 FourArithmetic.sln,选择 Release 和 x64 后生成解决方案。
6.2 生成题目
Myapp.exe -n 10 -r 10
其中 -n 表示题目数量,默认值为 10;-r 表示数值范围且必须提供。程序会在当前工作目录生成 Exercises.txt 和 Answers.txt。
6.3 批改答案
Myapp.exe -e Exercises.txt -a MyAnswers.txt
程序会在当前工作目录生成 Grade.txt。

生成 10 道题的命令行截图。

题目与答案文件截图。

展示了程序的自动批改功能。程序读取题目文件和模拟作答文件,利用表达式解析器计算每道题的标准结果,再与作答结果进行精确分数比较,最终将正确和错误题目的数量及编号写入 Grade.txt。
七、测试运行
我们从正常功能、边界参数、数学约束、重复判断、性能和异常输入等方面设计测试。以下是至少 10 个测试用例。
| 编号 | 测试内容/命令 | 预期结果 | 实际结果 |
|---|---|---|---|
| 1 | Myapp.exe -n 10 -r 10 | 生成 10 道题及 10 个答案 | 通过 |
| 2 | Myapp.exe -r 10 | 未指定 -n 时默认生成 10 道题 | 通过 |
| 3 | Myapp.exe -n 10 | 提示必须提供 -r,显示帮助并返回非零状态 | 通过 |
| 4 | Myapp.exe -n 10 -r 1 | 可以在最小范围内生成题目,程序不崩溃 | 通过 |
| 5 | Myapp.exe -n 0 -r 10 | 生成两个空文件,不发生崩溃 | 通过 |
| 6 | Myapp.exe -n 10000 -r 10 | 成功生成 10000 道题和答案 | 通过 |
| 7 | 检查所有生成题目的运算符数量 | 每题为 1~3 个 | 通过 |
| 8 | 检查所有减法子表达式 | 不出现负数中间结果 | 通过 |
| 9 | 检查所有除法子表达式 | 每个除法结果均为非零真分数 | 通过 |
| 10 | 比较 23 + 45 与 45 + 23 的规范键 | 两者规范键相同,只能保留一道 | 通过 |
| 11 | 比较 3 + (2 + 1) 与 (1 + 2) + 3 | 按允许的交换规则判定为重复 | 通过 |
| 12 | 使用程序生成的答案进行批改 | 所有题号均出现在 Correct 中 | 通过 |
| 13 | 将部分答案改错或留空后批改 | 对应题号进入 Wrong,后续答案不移位 | 通过 |
| 14 | 将 1/2 的答案写为 2/4 | 仍判定为正确 | 通过 |
| 15 | Myapp.exe -e Exercises.txt | 提示批改模式必须同时提供 -e 和 -a | 通过 |
测试时,我们还采用了以下检查方法:
- 生成后统计 Exercises.txt 和 Answers.txt 的行数,确认都等于 -n。
- 用程序生成的标准答案反向批改全部题目,确认解析器和生成器计算一致。
- 人工选择包含括号、混合分数、减法和除法的题目进行手算。
- 将交换后的加法、乘法表达式送入规范化逻辑,验证去重键一致。
- 使用 10000 题进行压力测试,检查完成时间、输出数量及程序是否异常退出。

八、效能分析与改进
8.1 初始性能问题
表达式生成是随机过程。候选表达式可能因为减法为负、除零、除法结果不是真分数或与已有题目重复而被丢弃。当题目数量增大时,如果每次都遍历已经生成的表达式进行重复比较,整体复杂度会接近 O(n²),生成 10000 题时会产生大量无效比较。
8.2 优化思路
我们进行了以下优化:
- 每个表达式只生成一次规范键,不直接两两比较表达式树。
- 使用 std::unordered_setstd::string 保存规范键,使平均查重时间由 O(n) 降到 O(1)。
- 在表达式树节点中缓存子表达式结果,避免输出答案时再次遍历和计算。
- 乘法前进行交叉约分,减少大整数乘法溢出和候选重试。
- 设置最大候选尝试次数,在范围过小且题目组合不足时给出错误,而不是无限循环。
性能优化与分析共花费【待填写】分钟。
8.3 性能测试结果
为了验证程序生成大量题目时的性能,我们使用 Release 版本执行:
Myapp.exe -n 10000 -r 10

测试结果表明,程序能够成功生成 10000 道不重复题目,10 次运行的平均耗时为 33.69055ms。运行结束后,Exercises.txt 和 Answers.txt 均包含 10000 行,满足作业对大批量题目生成的要求。
8.4 性能分析图

图 8 展示了程序运行过程中的函数耗时排名。采样结果显示,writeExercises、ExpressionGenerator::build、ExpressionGenerator::canonical 和 writeAnswers 是项目中占用 CPU 时间较多的函数。其中,writeExercises 负责将生成的表达式写入题目文件,ExpressionGenerator::build 负责递归构造表达式树,ExpressionGenerator::canonical 负责生成规范化键并进行等价题目去重,writeAnswers 负责写入答案文件。

图 9 展示了程序的函数调用树和热点路径。程序从 main 进入生成流程后,主要调用 ExpressionGenerator::build 构造表达式树,并通过 ExpressionGenerator::canonical 对表达式进行规范化处理。生成完成后,程序分别调用 writeExercises 和 writeAnswers 输出题目与答案文件。采样结果表明,表达式构造、规范化去重和文件输出是本程序性能分析中最值得关注的部分。

图 10 是本项目的函数级火焰图。火焰图中横向宽度表示函数在采样期间占用的 CPU 时间比例,纵向层次表示函数调用关系。图中可以看到,writeExercises、ExpressionGenerator::build 和相关匿名命名空间函数占据了较宽区域,说明表达式树构造和题目文件输出是本程序的重要耗时路径。该结果与函数排名表基本一致。

配图 11:优化前后柱状图。
九、项目结果展示
生成模式示例:
Myapp.exe -n 10 -r 10
程序输出:
已生成 10 道题目。
题目文件:...\Exercises.txt
答案文件:...\Answers.txt
批改模式示例:
Myapp.exe -e Exercises.txt -a MyAnswers.txt
程序会输出 Grade.txt,列出正确和错误答案的数量及题号。
十、项目小结
10.1 成功之处
本项目最终完成了题目生成、精确计算、等价去重、文件输出和答案批改等全部功能。我们认为做得较好的地方有:
- 使用表达式树统一处理生成、求值、括号输出和去重,避免了多套逻辑之间不一致;
- 使用有理数而不是浮点数,保证分数运算和批改结果准确;
- 在构造每个子表达式时检查约束,从源头保证题目合法;
- 对异常参数、文件打开失败、非法答案和组合不足等情况提供明确提示;
- 用哈希集合完成等价题目的快速去重,使程序可以轻松处理 10000 题。
10.2 不足与改进方向
项目仍有一些可以继续改进的地方:
- 当前随机生成依赖重试,在极小范围和极大题量组合下可能需要较多尝试;以后可以先枚举一部分合法结构,或根据运算符反向构造合法操作数。
- 核心代码集中在一个 main.cpp 中,规模继续扩大时可以拆分为 fraction、expression、generator、parser 和 grader 等模块。
- 目前缺少独立的自动化单元测试工程,可以引入 Catch2 或 GoogleTest。
- 可以增加随机种子参数,使失败用例可复现。
- 可以在保证题目规则不变的前提下增加难度等级和不同运算符数量的比例控制。
10.3 结对感受
【胡浩东】的结对感受:
这次结对让我认识到,表达式去重不能只比较输出字符串。我们在讨论 3 + (2 + 1) 的等价关系时,通过画表达式树才统一了理解。结对复审也帮助我发现了只检查最终结果、没有检查子表达式的设计漏洞。
【石基业】的结对感受:
例如:我主要负责边界测试和批改模块。在测试 -r 1、空答案和一万题时,我体会到测试并不是编码结束后的附属工作,而是在不断帮助我们澄清需求。两个人分别从实现和使用角度检查程序,比单独开发更容易发现问题。
对搭档的闪光点与建议:
- 【石基业】认为【胡浩东】的闪光点是:【我认为胡浩东同学的代码水平很强,大部分代码的工作都是胡浩东完成的,我只是负责题出一些优化的idea,具体的实践操作都是靠胡浩东同学进行完成。】;建议是:【希望以后实现的过程中,能够学习更多的优化思路,这样才能更好的进步】。
- 【胡浩东】认为【石基业】的闪光点是:【石基业同学在项目过程中认真分析了作业要求,提出了表达式去重、性能优化和测试方面的改进思路,并积极参与程序运行测试,帮助我发现和解决了一些边界问题。】;建议是:【希望以后在提出想法的同时,也可以更多地参与具体代码实现,这样能够进一步提高对项目整体结构的理解。】。

浙公网安备 33010602011771号