结对项目

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15703
这个作业的目标 完成结对项目要求的程序作业

结对项目:小学四则运算题目生成器的设计与实现

肖武阳3124004185,王梓瀚3124004181
GitHub 项目地址:https://github.com/yangyang254/pairwork1


目录


一、题目与需求分析

1.1 需求清单

把题目要求拆成可核对的功能点:

编号 需求 实现
1 -n 指定题目个数 Myapp.exe -n 10 -r 10
2 -r 指定数值范围,必须给出 缺省时打印错误 + 帮助信息并返回 1
3 计算过程不出现负数 减法处强制 e1 >= e2;乘加结果不越界
4 e1 ÷ e2 的结果是真分数 除法处强制 e1 < e2,即商 < 1
5 每道题运算符不超过 3 个 生成时随机 1~3 个运算符,检查程序复核
6 一次运行内题目不重复 规范形式 + 哈希表去重
7 题目写 Exercises.txt 一行一道:题目1: 3 + 4 * 5 =
8 答案写 Answers.txt 一行一道:答案1: 23;真分数 3/5、带分数 2'3/8
9 支持一万道题 实测 29 毫秒生成一万道题
10 -e/-a 批改并统计 结果写 Grade.txt:Correct: n (...) / Wrong: n (...)

1.2 算术表达式与数据结构

题目给出的文法:

e = n | e1 + e2 | e1 − e2 | e1 × e2 | e1 ÷ e2 | (e)

“表达式”天然是递归的,所以内部用二叉树表示一道题:叶子是数(自然数或真分数),
内部结点是一个运算符。这样“生成—求值—打印—解析”四个动作都能在同一份结构上完成。


二、PSP 表格(计划与实际)

时间单位:分钟。计划在动手之前填好,实际在提交之后回填。

2.1 计划(开始实现之前)

PSP 阶段 任务 计划时间
Planning 需求分析、确定数据结构与四个版本的拆分 40
Design 表达式树、分数类、生成算法、去重算法设计 60
Coding 第 1 版:参数解析 + 自然数出题 + 文件输出 60
Coding 第 2 版:真分数 + 解析器 + 去重 90
Coding 第 3 版:括号 + 题面自检 + 一万道题 60
Coding 第 4 版:批改统计 + 性能优化 60
Test 编写 test.c(单元测试 + 集成测试) 60
Test 编写 check.c(按题目要求逐条检查) 40
Test 四个版本各跑一万道题的回归验证 30
Analysis 性能分析、定位耗时最大的函数并优化 40
Doc 写博文、整理提交记录 60
合计 600

2.2 实际(提交之后)

PSP 阶段 任务 实际时间 偏差说明
Planning 需求分析、版本拆分 35 需求里有两条表述需要讨论
Design 数据结构与算法设计 70 多花了时间想“重复”怎么判
Coding 第 1 版 75 为“题面歧义”返工了一次
Coding 第 2 版 120 加了表达式解析器,比预估多
Coding 第 3 版 70 括号打印规则比想象的细
Coding 第 4 版 55 有前面的解析器可以复用
Test test.c 85 集成用例里踩到工作目录的坑
Test check.c 50 去重检查用“排序后找相邻相同”
Test 回归验证 25 自动化脚本立功了
Analysis 性能分析 45 自己写了 bench.c 分阶段计时
Doc 博文与提交记录 75
合计 705 比计划多约 17%

偏差主要出在“第 2 版”和“测试”上:写解析器时低估了分数解析和除零、负数这些边界;
写集成测试时又踩了“子进程当前目录”和“父进程当前目录不是同一个”的坑。
经验是——先把“检查手段”写出来,再回头改代码,返工会少很多。


三、设计实现过程

3.1 代码如何组织

整个项目每个版本都是一个 .c 文件,更易检查,
内部按“自底向上”分成六层,层与层之间只通过函数调用连接,没有全局状态耦合:

  • 第 1 版只有:工具层、树、生成、打印、文件输出、参数解析;
  • 第 2 版加入:Frac 运算、解析器、规范形式、哈希表;
  • 第 3 版加入:括号打印、自检、一万道题;
  • 第 4 版加入:批改模式、性能优化,并把 main4.c 作为最终提交的 main.c。

3.2 关键函数与流程图

(1) 生成一道不重复、无歧义的题目

            ┌──────────────────────────┐
            │ gen_expr(随机 1~3 个运算符)│
            └────────────┬─────────────┘
                         ▼
            ┌──────────────────────────┐
            │ validate_expr:按题意校验 │  不合法 → 丢弃重来
            │ 负数 / 除零 / 商≥1        │
            └────────────┬─────────────┘
                         ▼
            ┌──────────────────────────┐
            │ canon_to:算出规范形式    │
            └────────────┬─────────────┘
                         ▼
            ┌──────────────────────────┐
       ┌───▶│ hash_add_unique:是否重复 │  重复 → 丢弃重来(最多重试 1000 次)
       │    └────────────┬─────────────┘
       │                 ▼
       │    ┌──────────────────────────┐
       │    │ print_expr:打印题面      │
       │    └────────────┬─────────────┘
       │                 ▼
       │    ┌──────────────────────────┐
       └────│ parse_line + canon_equal  │ 自检失败(题面有歧义)→ 丢弃重来
            │ 题面读回来必须还是原树     │
            └────────────┬─────────────┘
                         ▼
                  ┌──────────────┐
                  │ 写入两个文件  │
                  └──────────────┘

(2) 生成表达式树(递归 + 随机重试)

gen_expr(ops, range):
    若 ops == 0:返回一个随机叶子(自然数或真分数),结束

    重复最多 RAND_LIMIT 次:
        随机选一个运算符 op ∈ {+,-,*,/}
        随机把剩下的 ops-1 个运算符分给左右子树
        建结点 e = (e_left op e_right)
        若 validate_expr(e) 合法:返回 e
        否则释放 e,换一个运算符重试

    兜底:返回一个叶子,保证一定能返回合法表达式

(3) 批改模式

读一行题目 → 去掉行首编号与行尾 “=”,得到表达式文本
读一行答案 → 去掉行首编号,得到学生答案文本
用“按题意校验”的方式解析题目   → 标准答案;题目本身不合法就记错
用“只求值”的方式解析学生答案 → 学生答案(支持 3/5、2'3/8)
两个分数约分后逐位比较(a 与 a、d 与 d),相等记对
最后写 Grade.txt:Correct: n (编号列表) / Wrong: n (编号列表)

(4) 规范形式:判断“重复”的核心

canon_to(e, buf):
    若是叶子:直接写数值(整数写 n,真分数写 a/b,带分数写 n'a/b)
    若 e 是 + 或 ×:
        把同运算符的操作数全部展开收集(结合律)
        对每个操作数递归求规范字符串
        排序后拼接成 "( + a b c )" 这样的形式(交换律)
    若 e 是 - 或 ÷:
        左、右子表达式分别递归,保持顺序拼接 "( - a b )"

这样:

题目 规范形式 是否重复
23 + 45 / 45 + 23 ( + 23 45 ) 重复
1+2+3 / 3+(2+1) / 3+2+1 ( + 1 2 3 ) 重复
3 - 2 / 2 - 3 ( - 3 2 ) / ( - 2 3 ) 不重复
(1+2)*3 / 1+2*3 ( * ( + 1 2 ) 3 ) / ( + 1 ( * 2 3 ) ) 不重复

3.3 “除以一个数结果必须是真分数”怎么落地

题目说:如果存在 e1 ÷ e2,那么其结果应是真分数。我们把它严格化为:

e1 ÷ e2 合法  ⟺  e2 ≠ 0,且 e1 < e2(也就是商小于 1,是真分数)
  • 生成时:validate_expr 在除法结点上检查 frac_is_proper(商),不满足就重抽;
  • 检查程序 check.c 用同样的规则复核(0 也算真分数,例如 (2-2)/8);
  • 这样“生成的题”与“检查的规则”完全一致,不会出现“自己生成了自己判不合法”的尴尬。

四、代码说明

下面的代码摘自最终版 main.c,注释为提交版本中的原文。

4.1 分数:用 long long 精确表示,一次运算一次约分

typedef struct
{
    long long a;            /* 分子 */
    long long d;            /* 分母,恒大于 0 */
} Frac;

static long long gcd64(long long x, long long y)     /* 最大公约数 */
{
    long long t;
    if (x < 0) x = -x;
    if (y < 0) y = -y;
    while (y) { t = x % y; x = y; y = t; }
    return x;
}

static Frac frac_make(long long a, long long d)      /* 构造并约分到最简 */
{
    Frac f;
    long long g;
    if (d < 0) { a = -a; d = -d; }                   /* 分母恒正,符号放分子 */
    g = gcd64(a, d);
    if (g == 0) g = 1;
    f.a = a / g;
    f.d = d / g;
    return f;
}

思路:全程用整数运算,避免浮点误差;每次运算后立刻约分,让分子分母不会滚雪球。
测试用例 1/6 + 1/8 = 7/24 就是靠这个保证的。

4.2 求值与按题意校验分开写

/* eval_expr 只负责“算”,不管题目是否合题意 */
static void eval_expr(const Expr *e, Frac *out)
{
    Frac a, b;
    if (e->op == 0) { *out = e->val; return; }
    eval_expr(e->l, &a);
    eval_expr(e->r, &b);
    switch (e->op)
    {
        case 1:  *out = frac_add(a, b); break;
        case 2:  *out = frac_sub(a, b); break;
        case 3:  *out = frac_mul(a, b); break;
        default: *out = frac_div(a, b); break;
    }
}

/* validate_expr 按题意检查:不出现负数、除数不为 0、除法结果是真分数 */
static int validate_expr(const Expr *e, Frac *out)
{
    ...
        case 2:
            if (frac_less(a, b)) return 0;           /* e1 >= e2,不出现负数 */
            r = frac_sub(a, b);
            break;
        default:
            if (frac_is_zero(b)) return 0;           /* 除数不能为 0 */
            r = frac_div(a, b);
            if (!frac_is_proper(r)) return 0;        /* 除法结果必须是真分数 */
            break;
    ...
}

为什么拆成两个函数? 这是第 4 版改批改功能时才想明白的:
批改学生答案时我们只关心“数值对不对”,不想让“结果不是真分数”这种出题规则去否决学生的答案;
而生成题目时又必须严格按规则来。拆开之后,parse_line(text, &v, strict) 用一个
strict 参数就能同时服务两种场景,代码没有再出现重复的解析逻辑。

4.3 括号什么时候必须加

static int need_paren(const Expr *child, const Expr *parent, int is_right)
{
    if (child->op == 0)
    {
        /* 叶子一般不用括号;但如果这个叶子是分数,而父亲是 × 或 ÷,
         * 就必须加括号,否则 "80 * 3/4 / 27" 会产生歧义:
         * 分不清 3/4 是一个分数,还是 "80 * 3 ÷ 4"。 */
        if (child->val.d != 1 && (parent->op == 3 || parent->op == 4)) return 1;
        return 0;
    }
    if (op_prio(child->op) < op_prio(parent->op)) return 1;    /* 优先级低要加 */
    if (op_prio(child->op) > op_prio(parent->op)) return 0;    /* 优先级高不加 */

    /* 同优先级时,- 和 / 的右操作数必须加括号:8 / (4 / 2)、7 - (3 - 1) */
    return (is_right && (parent->op == 2 || parent->op == 4));
}

思路:括号有三个来源——优先级低、同级但右结合、以及“分数字面量歧义”。
前两条是编译原理里的常规规则,第三条是这个题目特有的坑。

4.4 生成题目时的双自检

        put_begin(text, MAX_LINE);                   /* 自检一:题面能算出同样的答案 */
        print_expr(e);
        if (parse_line(text, &by_parse, 0) &&
            by_parse.a == ans->a && by_parse.d == ans->d &&
            canon_equal(e, text))                    /* 自检二:题面无歧义 */
            return e;

        free_expr(e);                                /* 自检失败,弃用重来 */

这是本项目最有价值的一段代码。 canon_equal 会把题面重新解析成树,
再比较两棵树的规范形式;只要不同,就说明“打印出来的题面”表达的不是“生成的那棵树”。
在加入这段自检之前,我们的一万道题里约有 5% 存在这种歧义;
加入之后,Exercises.txt 与 Answers.txt 永远是一致的。

4.5 去重用的哈希表

static unsigned int hash_str(const char *s)
{
    unsigned int h = 2166136261u;                    /* FNV-1a 哈希 */
    while (*s) { h ^= (unsigned char)*s++; h *= 16777619u; }
    return h % HASH_SIZE;
}

/* 返回 1 表示新题目(已插入),0 表示重复(重复的不会再插入) */
static int hash_add_unique(const char *key)
{
    unsigned int h = hash_str(key);
    HashNode *p;

    for (p = g_table[h]; p != NULL; p = p->next)
        if (strcmp(p->key, key) == 0) return 0;      /* 已经出现过 */

    p = (HashNode *)malloc(sizeof(HashNode));        /* 新题目,插入链表头 */
    p->key = (char *)malloc(strlen(key) + 1);
    strcpy(p->key, key);
    p->next = g_table[h];
    g_table[h] = p;
    return 1;
}

思路:判断重复只需要“查得到”,不需要“取得出”,所以用最简单的拉链法哈希表即可,
一次插入平均 O(1),一万道题的判重总耗时只有几毫秒。表的大小取 262144(2 的 18 次方),
用一个静态数组就够,不占栈。

4.6 批改:同一套解析器

        if (!parse_line(expr, &std_ans, 1))              /* 题目本身要符合题意 */
        {
            wrong[n_wrong++] = no;
            continue;
        }

        /* 学生答案支持 3/5、2'3/8 这样的分数写法,只比较数值 */
        if (parse_line(user_ans, &my_ans, 0) &&
            my_ans.a == std_ans.a && my_ans.d == std_ans.d)
            right[n_right++] = no;
        else
            wrong[n_wrong++] = no;

思路:题目算一遍、学生答案解一遍,两个分数都化为最简后比较分子分母。
这样 6/8 和 3/4 会被判为同一个答案(都是最简 3/4),符合小学批改的直觉。


五、效能分析

5.1 分析手段

题目要求“展示一张性能分析的图”。我们试过用 gprof(gcc -pg),
但在本机的 TDM-GCC 上带 -pg 的程序运行时卡住,因此改为自己写一个分阶段计时程序 bench.c:
它把“生成一道题”拆成 6 个阶段,分别累计耗时,比 gprof 的函数采样更直观。

gcc bench.c -o bench.exe -O2
bench.exe 10000 100

5.2 优化前的数据(一万道题,-r 100)

阶段 总耗时(ms) 占比
1. 生成表达式树(含按题意校验) 5.0 15.2%
2. 计算答案 0.0 0.0%
3. 计算规范形式(去重键) 6.0 18.2%
4. 哈希表查重与存储 2.0 6.1%
5. 打印题面文本 4.0 12.1%
6. 解析题面(自检) 7.0 21.2%
合计 33 约 73%

5.3 耗时最大的一处:规范形式里的 sprintf

canon_to 对每个叶子都要拼一次字符串,原来是这样写的:

char nb[64];
frac_text(e->val, nb, sizeof(nb));   /* 内部是 snprintf */
strncpy(buf, nb, size - 1);

一万道题里,每道题要算 3~4 次规范形式(去重一次、自检再一次,还有递归子结点),
每次又有 4 个左右的叶子,于是有十几万次 snprintf。改成直接往目标缓冲区写十进制:

static void put_ch(char c)
{
    if (g_p < g_end) { *g_p++ = c; *g_p = '\0'; }
}

/* 把整数按十进制直接写进缓冲区,省掉一次 sprintf */
static void put_ll(long long x)
{
    char tmp[24];
    int n = 0;
    if (x < 0) { put_ch('-'); x = -x; }
    if (x == 0) { put_ch('0'); return; }
    while (x > 0) { tmp[n++] = (char)('0' + (int)(x % 10)); x /= 10; }
    while (n > 0) put_ch(tmp[--n]);
}

优化后:

阶段 优化前(ms) 优化后(ms) 变化
3. 计算规范形式 6.0 1~2 约 −70%
5. 打印题面文本 4.0 2~3 约 −40%
1. 生成表达式树 5.0 5.0 不变
6. 解析题面(自检) 7.0 6~7 不变(本来就是线性扫描)
整程序 33 29~31 约 −10%

5.4 其他性能考虑

  • 内存:题目按题生成、用完即 free,一万道题不驻留,峰值内存只有哈希表的几 MB;
  • 文件 I/O:用 fputs 逐行写,配合标准库缓冲,一万道题的两个文件约 600 KB,写入时间可忽略;
  • 重试次数:-r 很小时(例如 -r 10)可用的不重复题目本来就少,
    程序会重试最多 1000 次后接受重复题,并打印“丢弃的重复题目数”,避免死循环。

六、测试运行

6.1 测试程序与检查程序

项目里有两个测试用的程序(题目允许“生成一个测试项目 C 文件”):

  1. test.c:把 main4.c 当作库包含进来(用 MYAPP_NO_MAIN 屏蔽它的 main),
    直接调用内部函数做单元测试,再用 system() 启动 Myapp.exe 做集成测试;
  2. check.c:站在“验收者”的角度,按题目要求逐条检查任意一版生成的
    Exercises.txt / Answers.txt:格式、数值范围、运算符个数、负数、除法真分数、
    答案是否与题目一致、以及题目是否重复(把规范形式排序后找相邻相同)。
gcc test.c  -o test.exe  -O2 && test.exe
gcc check.c -o check.exe -O2 && check.exe Exercises.txt Answers.txt 10
python verify.py          # 一键跑完四个版本的全部检查,输出 verify_log.txt

6.2 十个测试用例

下表是 test.exe 中的代表性用例(共 69 个断言,全部通过):

序号 用例 预期结果 说明
1 1/6 + 1/8 7/24 题目中给出的真分数运算样例
2 2'3/8 解析 19/8 带分数(二又八分之三)
3 4/8 解析 1/2 解析后自动约分
4 3 + 4 * 5 23 先乘后加
5 ( 3 + 4 ) * 5 35 括号优先
6 3 - 5 拒绝 出现负数,不符合题意
7 5 / 3 拒绝 除法结果不是真分数
8 3 / 0 拒绝 除数为 0
9 23+45 与 45+23 判为重复 交换律
10 1+2+3 与 3+(2+1) 判为重复 结合律 + 交换律
11 3-2 与 2-3 不重复 减法不满足交换律
12 (1+2)*3 与 1+2*3 不重复 括号改变了意义
13 80 * 3/4 / 27 这类打印 必须加括号或丢弃 分数字面量歧义的专用用例
14 Myapp.exe -n 20 -r 12 生成 20 行题目/答案 行数、格式、含等号
15 同上,统计运算符个数 每道 ≤ 3 个 自动跳过分数里的 /
16 同上,统计数值 全部 < 12 数值范围检查
17 把第 3、7、15 题答案改错后批改 Correct: 17 / Wrong: 3 (3, 7, 15) 批改统计与编号
18 Myapp.exe -n 10000 -r 100 正好 10000 行 一万道题
19 批改上面的一万道题 Correct: 10000 全对
20 不带 -r 运行 返回非 0 且打印帮助 参数校验

check.exe 的回归结果:

版本 本次生成 题意 范围 运算符 答案 重复
v1 -n 200 -r 100(nodup) 200 道 0 0 0 0 0
v2 -n 200 -r 100(nodup) 200 道 0 0 0 0 0
v3 -n 10000 -r 10 10000 道 0 0 0 0 0
v4 -n 10000 -r 10 10000 道 0 0 0 0 0
v4 -n 10000 -r 100 10000 道 0 0 0 0 0

表中的数字都是“不符合该检查项的题目数”,全 0 表示全部合格。
另外 python verify.py 的最后会跑 test.exe,结果是 通过 69 个用例,失败 0 个用例。

6.3 运行记录实例

> Myapp.exe -n 10 -r 10
已生成 10 道题目:Exercises.txt / Answers.txt
生成过程中丢弃的重复题目:0 道
总耗时:0.0 毫秒

Exercises.txt                     Answers.txt
题目1: ( 4/9 + 3 * ( 1/2 ) ) / 4 =    答案1: 35/72
题目2: ( 3/5 ) / 4 =                  答案2: 3/20
题目3: 8 * 4 - 1/2 + 3 =              答案3: 34'1/2
题目4: 1/2 - ( 6/7 ) / 2 * ( 2/7 ) =  答案4: 37/98
题目5: ( 4/5 + 2 + 2 ) * ( 1/3 ) =    答案5: 1'3/5
题目6: 5 * 7 + 2 + 1/4 =              答案6: 37'1/4
题目7: 6 - 7 * ( 1/6 ) =              答案7: 4'5/6
题目8: ( 5/8 ) * 1 =                  答案8: 5/8
题目9: ( 1/4 ) * ( 3/4 ) - ( 1/3 ) / 7 =  答案9: 47/336
题目10: 6 / 7 / 6 + 1 =               答案10: 1'1/7

> Myapp.exe -e Exercises.txt -a Answers.txt
共批改 10 道题:正确 10 道,错误 0 道,结果已写入 Grade.txt
Correct: 10 (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
Wrong: 0 ()

手工验算几道:

  • 第 1 题:4/9 + 3 × 1/2 = 4/9 + 3/2 = 35/18,再 ÷ 4 = 35/72 ✔
  • 第 4 题:(6/7) ÷ 2 = 3/7,× 2/7 = 6/49,1/2 − 6/49 = 49/98 − 12/98 = 37/98 ✔
  • 第 9 题:1/4 × 3/4 = 3/16,1/3 ÷ 7 = 1/21,3/16 − 1/21 = 63/336 − 16/336 = 47/336 ✔

七、项目小结(结对感受)

7.1 做对了什么

  1. 一开始就把“每个版本该做什么”拆清楚,每个版本都能独立编译、独立运行、独立验收,
    出了问题知道是哪一次改动引入的(事实上确实靠这个快速定位了第 1 版的题面歧义);
  2. 先写检查程序,再改功能:check.c 写出来之后,几乎所有需求都能“用程序证明”,
    不用靠肉眼一题一题看;
  3. 把“打印”和“解析”当成一对逆运算来设计:最后用“题面自检”把两者锁在一起,
    这是整个项目最让我们安心的一段代码;
  4. 性能优化用数据说话:自己写 bench.c 分阶段计时,找到了 sprintf 这个点,
    优化后规范形式阶段快了约 70%。

7.2 踩过的坑

坑 现象 教训
手工拼字符串忘记补 '\0' 文件里会混入上一个字符串的残留内容,甚至跑出乱码 自己管理缓冲区时,“什么时候置 \0”要跟“什么时候写入”一样认真地想
没有括号时生成 55 - 38 * 3 生成时按从左到右算是 51,读题的人按先乘除算是 −59 题面必须能唯一地表达那棵树;做不到就限制生成范围或补括号
分数字面量 3/4 与除号 / 混淆 80 * 3/4 / 27 有两种读法 打印和解析是一对函数,必须一起测
集成测试里父子进程的工作目录 system("cd /d dir && app.exe") 改变了子进程目录,父进程读文件却还在原目录 子进程和父进程的当前目录是两个东西;测试里统一 _chdir 或全部用绝对路径
除法结果约束写反 `if (op == 4 && (a % b != 0
检查程序的“重复”判定 v1/v2 还没做去重,却被判为不合格 检查项要跟“这个版本承诺了什么”对齐(我们给 check.exe 加了 nodup)

7.3 还可以做得更好的地方

  1. 目前所有代码都在一个文件里,虽然方便提交,但 main.c 已经有 900 多行,
    如果继续加功能(比如支持负数、支持小数),应该拆成 fraction.c、parser.c、generate.c;
  2. 没有做真正的图形界面,-n、-r、-e、-a 都是命令行参数,对小学生不友好;
  3. 没有做题目难度分级,目前 -r 是唯一的难度旋钮;

posted @ 2026-09-21 15:25  肖武阳  阅读(17)  评论(0)    收藏  举报