结对项目
结对项目 —— 小学四则运算自动生成程序 结对项目——
| 这个作业属于哪个课程 | 课程归属 |
|---|---|
| 这个作业要求在哪里 | 作业要求 |
| 这个作业的目标 | 通过开发自动生成小学四则运算题目的命令行程序 |
组员 A:封超
(学号 3124004168)
组员 B:谢毕护
(学号 3124004186)
GitHub 仓库:https://github.com/yydbjx/3124004168/tree/main/four-fraction-calculator
一、PSP2.1 表格
开发前预估
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 20 |
| · Estimate | ・估计这个任务需要多少时间 | 20 |
| Development | 开发 | 320 |
| · Analysis | ・需求分析(包括学习新技术) | 40 |
| · Design Spec | ・生成设计文档 | 30 |
| · Design Review | ・设计复审 | 15 |
| · Coding Standard | ・代码规范 | 10 |
| · Design | ・具体设计 | 40 |
| · Coding | ・具体编码 | 150 |
| · Code Review | ・代码复审 | 25 |
| · Test | ・测试(自我测试,修改代码,提交修改) | 40 |
| Reporting | 报告 | 60 |
| · Test Report | ・测试报告 | 25 |
| · Size Measurement | ・计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | ・事后总结,并提出过程改进计划 | 25 |
| 合计 | 400 |
开发后实际
| PSP2.1 | 实际耗时(分钟) |
|---|---|
| Planning | 15 |
| · Estimate | 15 |
| Development | 360 |
| · Analysis | 35 |
| · Design Spec | 25 |
| · Design Review | 20 |
| · Coding Standard | 10 |
| · Design | 50 |
| · Coding | 175 |
| · Code Review | 25 |
| · Test | 40 |
| Reporting | 70 |
| · Test Report | 30 |
| · Size Measurement | 10 |
| · Postmortem & Process Improvement Plan | 30 |
| 合计 | 445 |
实际比预估多花约 11%,主要超在 Coding 与 Postmortem 上 —— 原因是 Unicode 运算符在 MSVC 下的解析踩了一个坑(详见第六节测试用例),返工了一次。
二、效能分析
2.1 性能数据
| 场景 | 规模 | 耗时 |
|---|---|---|
| 生成题目 | 10 题,r=10 | < 1 ms |
| 生成题目 | 10 000 题,r=20 | 49 ms |
| 判题 | 10 000 题 | 23 ms |
| 去重校验 | 10 000 题全部唯一 | 通过 |
2.2 性能热点分析
用 Visual Studio 的性能探查器跑了一次 -n 10000 -r 20,采样 100 000 次。Top 3 函数如下:

-
哈希表容量调优:最初用 4096 槽位,一万题时冲突链较长;改成 FNV-1a 哈希 + 16384 槽位后,平均链长 < 1.2,查询从~30 ms 降到~5 ms。
-
字符串池复用:
collect_flat每次malloc小字符串开销大。改成在一个大缓冲区上做切片,省去大量malloc/free。 -
约分时机:中间结果只在需要比较(
fcmp)和输出时才fnorm,避免每步都约分。
改进后,万题生成从最初的~180 ms 降到 49 ms,满足 "支持一万道" 的要求。
三、设计实现过程
3.1 整体架构
整个程序是一个单文件 C 程序(main.c,约 490 行),分为 6 个模块:
┌─────────────────────────────────────────────┐
│ main.c │
├──────────┬──────────┬──────────┬─────────────┤
│ 分数运算 │ 表达式树 │ 随机生成 │ 规范化序列化 │
│ Frac │ Expr │gen\\\_expr │ expr\\\_to\\\_s │
│ fnorm │ mk\\\_op │gen\\\_num │ collect\\\_flat│
├──────────┴──────────┴──────────┴─────────────┤
│ 哈希去重 (FNV-1a) │ 文件 I/O │
├─────────────────────┼──────────────────────────┤
│ 递归下降解析器 │ 判题 grade() │
│ parse\\\_addsub │ read\\\_whole / Grade.txt │
└─────────────────────┴──────────────────────────┘
3.2 核心数据结构
分数(避免浮点误差,全程精确运算):
typedef struct { i64 num, den; } Frac; /\\\* num/den, den>0 \\\*/
所有四则运算都在分数域内进行,fnorm 每次运算后用最大公约数约分,保证 a/b == c/d 时 num 与 den 完全相等,从而可以直接用整数比较代替浮点比较。
表达式树:
typedef enum { ND\\\_NUM, ND\\\_OP } NType;
typedef enum { OP\\\_ADD, OP\\\_SUB, OP\\\_MUL, OP\\\_DIV } Op;
typedef struct Expr {
\  NType type;
\  Frac val; /\\\* 该子树的值(递归算出) \\\*/
\  Op op;
\  struct Expr \\\*l, \\\*r;
} Expr;
生成时自底向上递归:叶子是分数,内部节点是运算符。每个节点在创建时立即算出自己的 val,这样后续做约束检查(减法非负、除法真分数)和最终答案都直接读 val,不必再遍历树求值。
3.3 题目生成算法

3.4 去重设计
我们的做法是把表达式树规范化成一个字符串,再查哈希表:
-
+ 和 × 展平:递归把所有嵌套的
+/×子树展平成操作数列表(利用结合律)。 -
排序:对展平后的操作数字符串列表做
strcmp排序(利用交换律)。 -
重新拼接:用
" + "或" × "连接,得到规范字符串。 -
括号规则:只有当子节点优先级低于父节点,或父节点是
-/÷且子节点是右孩子时才加括号,保证(1+2)×3与1+2×3不会被错误合并。
举例:
-
3+(2+1)→ 展平+→[3, 2, 1]→ 排序[1, 2, 3]→"1 + 2 + 3" -
1+2+3(左结合(1+2)+3)→ 展平 →[1, 2, 3]→"1 + 2 + 3"
两题规范化后字符串相同,被判重,只保留一道。
3.5 判题模式
-e <题目> -a <答案> 模式启动一个递归下降表达式解析器:
expr := addsub
addsub := muldiv (('+'|'-') muldiv)\\\*
muldiv := unary (('×'|'÷') unary)\\\*
unary := number | '(' addsub ')'
number := integer (''integer '/' integer)? | integer '/' integer
解析出表达式树后,值已经在 mk_op 时算好,直接与用户答案比较(分数比较用通分后的整数交叉相乘)。
四、代码说明
4.1 分数运算(关键代码)
static Frac fnorm(Frac f) {
\  if (f.den == 0) { fprintf(stderr, "div by zero\n"); exit(1); }
\  if (f.den < 0) { f.num = -f.num; f.den = -f.den; }
\  i64 g = gcd64(f.num, f.den);
\  f.num /= g; f.den /= g;
\  return f;
}
static int fcmp(Frac a, Frac b) {
\  i64 l = a.num\\\*b.den, r = b.num\\\*a.den;
\  return (l > r) - (l < r);
}
注释
:
fnorm
保证每个分数都约分到最简、分母为正;
fcmp
用交叉相乘避免浮点误差。
4.2 约束生成(关键代码)
} else if (op == OP\\\_DIV) {
\  /\\\* 结果必须是真分数: 0 < l/r < 1, 且除数非0 \\\*/
\  if (rr->val.num == 0 || fcmp(l->val, rr->val) >= 0) ok = 0;
}
注释
:
fcmp(l, r) >= 0
表示
l >= r
,此时
l/r >= 1
,不满足 "除法结果是真分数" 的要求。
4.3 规范化序列化(关键代码)
if (e->op == OP\\\_ADD || e->op == OP\\\_MUL) {
\  collect\\\_flat(e, e->op, \\\&arr, \\\&cnt, \\\&cap, e->op); // 展平
\  qsort(arr, cnt, sizeof(SB\\\*), sb\\\_cmp); // 排序
\  for (int i = 0; i < cnt; i++) {
\  if (i) sbputs(out, (e->op == OP\\\_ADD) ? " + " : " × ");
\  sbputs(out, arr\\\[i]->d);
\  }
}
注释
:展平 + 排序后,
a+b
与
b+a
、
(a+b)+c
与
a+(b+c)
都映射到同一个字符串。
4.4 UTF-8 运算符识别(踩坑记录)
题目要求输出 × ÷,它们在 UTF-8 下是双字节(0xC3 0x97 / 0xC3 0xB7)。最初直接写 peek(ps) == '×',但 MSVC 下多字节字符常量无法和文件里的 UTF-8 字节流正确匹配,导致判题全部失败。改成显式字节匹配后修复:
static int match\\\_times(Pr \\\*ps) {
\  if ((unsigned char)ps->p\\\[0] == 0xC3 &&
\  (unsigned char)ps->p\\\[1] == 0x97) { ps->p += 2; return 1; }
\  if (ps->p\\\[0] == '\\\*') { ps->p += 1; return 1; }
\  return 0;
}
五、测试运行
5.1 十个测试用例
| # | 测试命令 | 预期 | 实际 |
|---|---|---|---|
| 1 | Myapp.exe -n 10 -r 10 |
生成 10 道题,写入 Exercises.txt/ Answers.txt | 输出 10 题,文件正常 |
| 2 | Myapp.exe -n 10000 -r 20 |
1 万题在 1 秒内生成 | 49 ms |
| 3 | Myapp.exe(不带参数) |
报错并打印 Usage | 打印帮助信息 |
| 4 | Myapp.exe -n 10(缺 -r) |
报错(r 必须给定) | 打印 Usage |
| 5 | Myapp.exe -n 3 -r 1 |
只能生成 0 的四则运算 | 出 0 + 0 - 0 等 |
| 6 | 生成后跑 -e Exercises.txt -a Answers.txt |
全对 10000/10000 | Correct: 10000 |
| 7 | 故意改 3 道答案为错误值 | 判出 3 错 7 对 | Correct: 7 (1,3,5,6,8,9,10) / Wrong: 3 (2,4,7) |
| 8 | 一万题去重校验 | Total == Unique |
10000 == 10000 |
| 9 | 手算验证 2'1/2 × 3'1/2 × 7 × 8'3/4 |
= 535'15/16 | Answers.txt 一致 |
| 10 | 手算验证 1/2 - 1/2 ÷ (4 × 8/9) |
= 23/64 | Answers.txt 一致 |
5.2 为什么能确定程序正确
-
分数全程精确:不使用任何浮点数,所有比较都是整数交叉相乘,杜绝了
0.1+0.2 != 0.3这类问题。 -
生成与判题独立:生成时用表达式树自顶向下求值,判题时用递归下降解析器自底向上重建树。两条路径独立实现,互为验证 —— 如果生成器算错,判题器会在自校时发现(用
-e校自己生成的答案)。 -
约束可机器校验:减法非负、除法真分数、运算符个数 ≤ 3 这些约束都在生成代码里硬编码,并用随机抽样 + 人工抽查双重确认。
-
去重用数学等价:规范化字符串严格对应
+/×的交换律和结合律,不是靠运气;一万题Total == Unique实证无重复。
六、项目小结
6.1 做对的地方
-
分数运算全程精确,没有引入任何浮点误差,答案可以放心交给小学生用。
-
去重设计把 "数学等价" 翻译成 "字符串相等",思路清晰且可扩展。
-
性能万题生成 49 ms、判题 23 ms,远快于题目要求。
6.2 踩过的坑
-
Unicode 编码:源文件、编译器选项(
/utf-8)、文件输出、解析字节四者必须统一,否则× ÷一定会乱码或解析失败。这个坑花了我们近 40 分钟。 -
题号前缀误跳过:最初写
while (isdigit || '.' || isspace)跳前缀,结果把题目本体的数字也跳了,导致判题全错。改成 "只跳题号数字 + 一个点 + 空格" 后修复。
6.3 结对感受
-
封超:这次结对最大的收获是学会了 "先写解析器再写生成器"—— 我们一开始直接写生成器,等到判题模式才发现要重新写一个表达式 parser,等于把同一套语法分析写了两遍。下次会先把文法定下来,生成器和解析器共用同一套 AST 定义。谢毕护在去重规范化上的思路非常清晰,是我一开始没想到的。
-
谢毕护:需求理解偏差代价高:最初误以为“字符串排序”就能判重,结果把 1+2+3 和 3+2+1 误判为重复。后来重新分析结合律才修正,浪费了半天时间。性能优化要尽早关注:初期使用大量 shared_ptr 拷贝,后来减少拷贝、预分配 vector 容量,才降到 大幅降低时间。如果早做性能分析,可以少走弯路。虽然中间因为判重规则和编码问题卡过壳,但每次讨论后都能找到更好的方案。我们不仅学会了如何用 C++ 精确处理分数、构建表达式树、实现递归下降解析,更学会了如何沟通、妥协、互相 Review。
6.4 改进计划
-
把
main.c拆成fraction.c / expr.c / generator.c / grader.c四个文件,方便后续维护。 -
用 GNU 的
flex/bison或手写更健壮的 parser,支持错误恢复。 -
增加
-s随机种子参数,方便复现 bug。 -
加单元测试(用
assert写 20 个分数运算和规范化的断言),纳入 CI。

浙公网安备 33010602011771号