结对项目

结对项目 —— 小学四则运算自动生成程序 结对项目——

这个作业属于哪个课程 课程归属
这个作业要求在哪里 作业要求
这个作业的目标 通过开发自动生成小学四则运算题目的命令行程序

组员 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 函数如下:

微信图片_20260922185208_48_3

  1. 哈希表容量调优:最初用 4096 槽位,一万题时冲突链较长;改成 FNV-1a 哈希 + 16384 槽位后,平均链长 < 1.2,查询从~30 ms 降到~5 ms。

  2. 字符串池复用collect_flat 每次 malloc 小字符串开销大。改成在一个大缓冲区上做切片,省去大量 malloc/free

  3. 约分时机:中间结果只在需要比较(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/dnumden 完全相等,从而可以直接用整数比较代替浮点比较。

表达式树:

typedef enum { ND\\\_NUM, ND\\\_OP } NType;

typedef enum { OP\\\_ADD, OP\\\_SUB, OP\\\_MUL, OP\\\_DIV } Op;

typedef struct Expr {

\&#x20;   NType type;

\&#x20;   Frac  val;          /\\\* 该子树的值(递归算出) \\\*/

\&#x20;   Op    op;

\&#x20;   struct Expr \\\*l, \\\*r;

} Expr;

生成时自底向上递归:叶子是分数,内部节点是运算符。每个节点在创建时立即算出自己的 val,这样后续做约束检查(减法非负、除法真分数)和最终答案都直接读 val,不必再遍历树求值。

3.3 题目生成算法

flowchart

3.4 去重设计

我们的做法是把表达式树规范化成一个字符串,再查哈希表:

  1. + 和 × 展平:递归把所有嵌套的 + / × 子树展平成操作数列表(利用结合律)。

  2. 排序:对展平后的操作数字符串列表做 strcmp 排序(利用交换律)。

  3. 重新拼接:用 " + "" × " 连接,得到规范字符串。

  4. 括号规则:只有当子节点优先级低于父节点,或父节点是 - / ÷ 且子节点是右孩子时才加括号,保证 (1+2)×31+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) {

\&#x20;   if (f.den == 0) { fprintf(stderr, "div by zero\n"); exit(1); }

\&#x20;   if (f.den < 0) { f.num = -f.num; f.den = -f.den; }

\&#x20;   i64 g = gcd64(f.num, f.den);

\&#x20;   f.num /= g; f.den /= g;

\&#x20;   return f;

}

static int fcmp(Frac a, Frac b) {

\&#x20;   i64 l = a.num\\\*b.den, r = b.num\\\*a.den;

\&#x20;   return (l > r) - (l < r);

}

注释

fnorm
保证每个分数都约分到最简、分母为正;
fcmp
用交叉相乘避免浮点误差。

4.2 约束生成(关键代码)

} else if (op == OP\\\_DIV) {

\&#x20;   /\\\* 结果必须是真分数: 0 < l/r < 1, 且除数非0 \\\*/

\&#x20;   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) {

\&#x20;   collect\\\_flat(e, e->op, \\\&arr, \\\&cnt, \\\&cap, e->op);  // 展平

\&#x20;   qsort(arr, cnt, sizeof(SB\\\*), sb\\\_cmp);             // 排序

\&#x20;   for (int i = 0; i < cnt; i++) {

\&#x20;       if (i) sbputs(out, (e->op == OP\\\_ADD) ? " + " : " × ");

\&#x20;       sbputs(out, arr\\\[i]->d);

\&#x20;   }

}

注释
:展平 + 排序后,
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) {

\&#x20;   if ((unsigned char)ps->p\\\[0] == 0xC3 &&

\&#x20;       (unsigned char)ps->p\\\[1] == 0x97) { ps->p += 2; return 1; }

\&#x20;   if (ps->p\\\[0] == '\\\*') { ps->p += 1; return 1; }

\&#x20;   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 为什么能确定程序正确

  1. 分数全程精确:不使用任何浮点数,所有比较都是整数交叉相乘,杜绝了 0.1+0.2 != 0.3 这类问题。

  2. 生成与判题独立:生成时用表达式树自顶向下求值,判题时用递归下降解析器自底向上重建树。两条路径独立实现,互为验证 —— 如果生成器算错,判题器会在自校时发现(用 -e 校自己生成的答案)。

  3. 约束可机器校验:减法非负、除法真分数、运算符个数 ≤ 3 这些约束都在生成代码里硬编码,并用随机抽样 + 人工抽查双重确认。

  4. 去重用数学等价:规范化字符串严格对应 +/× 的交换律和结合律,不是靠运气;一万题 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 改进计划

  1. main.c 拆成 fraction.c / expr.c / generator.c / grader.c 四个文件,方便后续维护。

  2. 用 GNU 的 flex/bison 或手写更健壮的 parser,支持错误恢复。

  3. 增加 -s 随机种子参数,方便复现 bug。

  4. 加单元测试(用 assert 写 20 个分数运算和规范化的断言),纳入 CI。

posted @ 2026-09-22 19:22  yydbjx  阅读(3)  评论(0)    收藏  举报