软件工程第二周作业

软工作业:论文查重(个人项目)

作业仓库:(https://github.com/M1kk0zz0615/SoftwareEngineeringHW/tree/main/3124004479/SE1stHW)
学号 3124004479,姓名 刘俊宁,课程 [计科24级78班 - 软件工程]


〇、题目回顾

给一份论文原文和一份在其基础上增删改过的抄袭版,算出重复率写进答案文件,
结果精确到小数点后两位。调用方式:

main.exe <原文文件> <抄袭版论文文件> <答案文件>

18 个测试点,另外有几条硬性红线:5 秒内必须出答案、内存不超过 2048 MB、
不许内存泄漏、不许异常退出、不许连网、不许读写指定的三个文件之外的文件

作为一个几乎没独立写过完整程序的人(第一周作业里我说过自己「代码量不到 1000 行」),
这次作业最大的感受是:能跑通的代码和能在评测里活下来的代码,中间隔着好几道关。


一、PSP 表格(预估)

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 30
· Estimate · 估计这个任务需要多少时间 30
Development 开发 480
· Analysis · 需求分析(包括学习新技术) 60
· Design Spec · 生成设计文档 40
· Design Review · 设计复审 20
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 20
· Design · 具体设计 60
· Coding · 具体编码 180
· Code Review · 代码复审 40
· Test · 测试(自我测试、修改代码、提交修改) 60
Reporting 报告 120
· Test Report · 测试报告 40
· Size Measurement · 计算工作量 20
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 60
合计 630

(实际耗时见文末第六节,完整表格在仓库 docs/PSP.md。)


二、计算模块接口的设计与实现过程

2.1 先想清楚「重复率」到底算什么

这一步就卡了我很久。作业原文只说「输出其重复率」,没有规定用什么指标
我一开始想的方案是把两篇文本当成字符集合求交并比,但在样例上试了一下发现
「把原文完整抄一遍再多抄一万字」会得到 1.0,而「把原文打乱顺序」也会得到 1.0——
两个显然该被扣分的场景都满分,这个指标是坏的。

最后选定的口径是:

重复率 = LCS(原文, 抄袭版) / 原文长度
  • 分子用最长公共子序列而不是公共子串,因为抄袭最常见的手法是「插字」「删字」
    「改词」,这些操作都会打断连续的公共子串,但不会破坏子序列关系;
  • 分母取原文长度,含义是「原文里有多大比例的内容被抄了过去」;
  • 比较前先把所有空白字符(空格、制表、换行、回车等 6 种)去掉,
    因为排版差异不该算作不重复;
  • 标点保留,标点也是文本内容,不该被抹掉。

举个具体例子,题目给的样例:

原文 :今天是星期天,天气晴,今天晚上我要去看电影。
抄袭版:今天是周天,天气晴朗,我晚上要去看电影。

LCS = 今天是天,天气晴,晚上要去看电影。 共 17 个字符,原文 22 个字符,
所以输出 0.77

一个诚实的说明:按这个口径,样例里的 orig_0.8_add.txt(在原文中随机插入
干扰字)算出来是 1.00,因为插入操作不破坏子序列关系。如果老师的评分口径不同,
结果会有偏差。我在实现时把口径收在 check_similarity() 一个函数里,
换算法只需要改这一处。

2.2 模块怎么拆

原型的做法是把所有代码塞进一个 main.c,但那样根本没法做单元测试——测试函数
要怎么调用 main?所以我按「纯计算 / 副作用 / 入口」三层重构成了四个模块:

        main.c  ← 只负责串流程、兜错误、控制退出码
       ╱       ╲
 check.c       fileio.c        ← 纯计算            ← 全程序唯一碰文件系统的地方
       ╲       ╱
        common.c               ← 错误码与文案
模块 职责 关键接口
check.h/c 文本预处理、LCS、重复率 check_strip_blankcheck_lcscheck_lcs_dpcheck_similarity
fileio.h/c 读整个文件、写答案 fileio_read_allfileio_write_rate
common.h/c 错误码枚举与中文文案 ck_status_text
main.c 参数校验、编排、退出码 main

拆开之后最大的好处是:check.c 里的函数全是「一段内存进、一个数字出」的纯函数,
一个字节的文件都不碰
,单元测试写起来非常直接。

2.3 核心函数的调用关系

main()
 ├─ fileio_read_all(原文)     → 整块读进内存
 ├─ fileio_read_all(抄袭版)
 ├─ check_strip_blank() ×2    → 去掉空白字符
 ├─ check_similarity()
 │    ├─ trim_common_affix()  → 裁掉公共前后缀
 │    └─ check_lcs()          → 位并行 LCS(生产用)
 │         └─ check_lcs_dp()  → 经典动态规划(仅测试对照)
 └─ fileio_write_rate()       → "%.2f\n" 写答案

2.4 关键函数设计说明

check_strip_blank(src, len, dst) —— 原地过滤空白,返回新长度。
允许 dst == src,这样可以省掉一次内存分配:

size_t check_strip_blank(const unsigned char *src, size_t len, unsigned char *dst)
{
    unsigned char *out = dst;
    for (size_t i = 0; i < len; i++) {
        unsigned char c = src[i];
        if (!check_is_blank(c)) { *out = c; out++; }
    }
    return (size_t)(out - dst);
}

check_lcs(a, m, b, n) —— 位并行实现,是性能的关键,下面单独讲。

check_similarity(...) —— 对外唯一入口,把结果连同中间量一起塞进
CheckResult 结构体。把中间量带出来是个刻意的设计:单元测试可以直接断言
common_len 而不必反推,出问题时也一眼能看出是「LCS 算错了」还是「分母算错了」。

2.5 独到之处:位并行 LCS

这是整个作业里我最想讲的部分。

经典的 LCS 动态规划是 O(m·n) 的,L[i][j] 要么从左上角 +1 来,要么从
上方或左方取较大值来。逐格算的话,m·n ≈ 3.93 × 10⁹ 的规模要跑两三秒。

但「取较大值」这件事在二进制里是可以用进位模拟的。Crochemore 等人在 2001 年
提出的位向量算法,把 DP 表的一整行压成一个位向量,每处理抄袭版的一个字符,
只需要一次全字长的加法、一次减法和一次或运算:

for (size_t j = 0; j < n; j++) {
    const uint64_t *col = mask + (size_t)b[j] * w;
    uint64_t carry = 0, borrow = 0;

    for (size_t k = 0; k < w; k++) {
        uint64_t sk = s[k];
        uint64_t xk = sk & col[k];          // X = S & mask[b[j]]

        /* 一次加法最多产生一次进位,两次比较不会同时成立,故用 + 合成一位 */
        uint64_t t  = sk + xk;
        uint64_t ta = t + carry;
        carry = (uint64_t)((t < sk) + (ta < t));

        /* 减法同理,一次借位 */
        uint64_t d  = sk - xk;
        uint64_t ds = d - borrow;
        borrow = (uint64_t)((sk < xk) + (d < borrow));

        s[k] = ta | ds;                     // S = (S+X) | (S-X)
    }
    s[w - 1] &= top;                        // 砍掉溢出到 m 位以上的部分
}

要点:

  1. mask[c] 是一张「字符 → 位置位图」的表,第 i 位表示 a[i] == c
    一次查表就能知道「这个字符在原文的哪些位置出现过」;
  2. 加法等价于「把匹配点整体上移一格」,减法等价于「把不匹配的洞补上」,
    两者或起来就完成了整行 DP 的更新,全程没有逐位分支
  3. 处理完后 S0 的个数就是 LCS 长度。

复杂度从 O(m·n) 降到 O(m·n/64)——一次 64 位整数运算顶掉 64 次逐位比较。

另外两个细节:

  • 位向量建在较短的串上。时间复杂度 m·n/64 是对称的,但位向量长度取 m
    内存和 mask 表都按较短串来算,省内存;
  • 先裁公共前后缀。真实的抄袭文本常常开头结尾一字不差,而
    LCS(A,B) = p + s + LCS(A[p:m-s], B[p:n-s])严格成立的等式(不是启发式),
    对大段照抄的场景能一口气砍掉绝大部分计算量,代价只有 O(m+n)

2.6 一个已知的取舍:按字节比较

程序是按字节而不是按字符比较的,好处是完全不关心文件的编码——
UTF-8 也好 GBK 也好,都能正确处理,也不会因为非法字节序列而崩掉。

代价是:两个毫不相关的中文串可能共享 UTF-8 的引导字节,导致相似度被略微高估。
这个副作用是可以量化的,我把它钉成了一条单元测试:

/*
 * 两个毫不相关的中文串,字节级 LCS 却不为 0:
 *   "活着前言" = E6 B4 BB E7 9D 80 E5 89 8D E8 A8 80
 *   "完全不同" = E5 AE 8C E5 85 A8 E4 B8 8D E5 90 8C
 * 公共字节是 E5 和 8D,共 2 个,于是得 2/12 = 0.1667。
 */
UT_CHECK_NEAR(rate_of("活着前言", "完全不同"), 2.0 / 12.0, 1e-9);

对于「同一篇文章的抄袭版」这种真实场景,真正的公共内容占绝对主导,
这点偏高的影响可以忽略;但如果是拿两篇完全无关的中文做比较,
0.17 这个底噪确实不为零,算是这个版本的一个已知局限。


三、计算模块接口部分的性能改进

3.1 先量再改

作业建议用 VS 2017 的性能分析工具,本机没装 Visual Studio,我改用 gprof
它同样能给出「每个函数自身耗时占比」的 flat profile。

中间踩了两个坑,记下来给后面的人:
① MinGW 版的 gprof 在 Windows 上采不到样gmon.out 里只有调用次数、
没有 PC 采样直方图,输出的 flat profile 一行函数都没有;
② gcov 写不出含中文路径的 .gcda
最后把两项工作都放到 WSL2 Ubuntu 下用同一份源码重做,才拿到数据。

3.2 性能分析图

优化前后对比

优化前的 flat profile(原文 vs 162 KB 的样例文件):

Flat profile:
Each sample counts as 0.01 seconds.

  %   cumulative   self              self     total
 time   seconds   seconds    calls   s/call   s/call  name
100.00      2.61     2.61        1     2.61     2.61  check_lcs_dp
  0.00      2.61     0.00        2     0.00     0.00  check_strip_blank
  0.00      2.61     0.00        2     0.00     0.00  fileio_read_all
  0.00      2.61     0.00        1     0.00     2.61  check_similarity
  0.00      2.61     0.00        1     0.00     0.00  fileio_write_rate

消耗最大的函数:check_lcs_dp,自身耗时 2.61 秒,占 100.00%。

瓶颈非常干净——文件 I/O 和空白过滤的耗时小到采样精度都测不出来,
时间全部花在求 LCS 上。优化方向唯一且明确。

优化后

Flat profile:
Each sample counts as 0.01 seconds.

  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
100.00      0.04     0.04        1    40.00    40.00  frame_dummy
  0.00      0.04     0.00        2     0.00     0.00  check_strip_blank
  0.00      0.04     0.00        2     0.00     0.00  fileio_read_all
  0.00      0.04     0.00        1     0.00    40.00  check_similarity
  0.00      0.04     0.00        1     0.00     0.00  fileio_write_rate

整个程序从 2.61 秒降到 0.04 秒,check_lcs 已经低到采样精度以下
连一个采样点都采不到。此时冒出来的 frame_dummy 是 gprof 自身的启动开销,
不是业务代码。

为了更直观地看到两套算法的差距,我写了个对照工具 tests/bench.c
在同一个进程里跑两套算法。gprof 输出:

  %   cumulative   self              self     total
 time   seconds   seconds    calls   s/call   s/call  name
 98.32      2.93     2.93        1     2.93     2.93  check_lcs_dp
  1.68      2.98     0.05        1     0.05     0.05  frame_dummy
  0.00      2.98     0.00        2     0.00     0.00  check_strip_blank
  0.00      2.98     0.00        2     0.00     0.00  fileio_read_all
  0.00      2.98     0.00        1     0.00     0.00  check_lcs

check_lcs_dp 占 98.32%,check_lcs 采不到样本。

3.3 改进的思路

  1. 第一刀:裁公共前后缀。 O(m+n) 的代价,收益是把 m·n 砍掉几个数量级,
    而且是严格等价的变换,不损失精度。
  2. 第二刀:位并行 LCS。 O(m·n) → O(m·n/64),这是量级上的改进。

3.4 结果

算法 复杂度 LCS 耗时 结果
经典动态规划 O(m·n) 2 930 ms 23 397
位并行 O(m·n/64) 48 ms 23 397

加速比约 61 倍,两套算法结果完全一致。

从「会不会超时」的角度看更直观:优化前最坏的样例要 3.4 秒
已经贴着 5 秒红线;优化后同一个样例 0.07 秒完成,红线压力彻底解除。

3.5 优化过程中最大的风险控制

位并行 LCS 满屏都是位运算和进位,光靠人眼根本判断不了写得对不对
所以我做的第一件事不是优化,而是先把动态规划版本保留下来当参照答案
check_lcs_dp),然后写差分测试:

/* 随机生成 2000 组串,两套算法结果必须完全一致 */
for (round = 0; round < 2000; round++) {
    unsigned int la = rnd_below(120u);
    unsigned int lb = rnd_below(120u);
    unsigned char a[120] = {0};
    unsigned char b[120] = {0};
    /* ... 用 4 个字母的字母表随机填充 ... */
    if (check_lcs(a, la, b, lb) != check_lcs_dp(a, la, b, lb)) {
        mismatches++;
    }
}
UT_CHECK_SIZE(mismatches, 0);

另外专门测了长度落在 63 / 64 / 65 / 127 / 128 / 129 这些 64 位字边界上的情况——
位并行算法最容易在这些地方出错。这两组测试通过之后,我才敢把生产代码换掉。


四、计算模块部分单元测试展示

4.1 测试框架

作业要求「每个人都能很容易地运行它」,所以我没有引入任何第三方依赖
(PingCAP 的 UT、Google Test 都要额外装东西),自己写了一个 60 行的极简框架
tests/utest.h,只要有三条宏:

#define UT_BEGIN(name)      do { printf("  [用例] %s\n", (name)); } while (0)
#define UT_CHECK(cond)      /* 条件断言,失败打印文件:行号 */
#define UT_CHECK_SIZE(a, e) /* 比较 size_t,失败打印期望值与实际值 */
#define UT_CHECK_NEAR(a, e, eps) /* 浮点数按容差比较 */

4.2 测试数据是怎么设计的

白盒为主 + 等价类划分 + 边界值 + 差分测试,一共 35 个用例、101 条断言

类别 用例 设计思路
等价类 01 完全相同 / 02 完全不同 / 03 纯插入 / 04 纯删除 / 05 尾部追加 把「抄袭手法」拆成互斥的几类,每类给一个能口算答案的输入
边界值 06 空原文 / 07 空抄袭版 / 09 单字符 / 13 跨 64 位边界 / 15 全同字符 空串、单字符、以及位向量算法最易出错的 63/64/65/127/128/129
差分 14 随机串 2000 组 用动态规划当参照答案,验证位并行实现
特殊 08 仅空白差异 / 10 大小写敏感 / 11 UTF-8 中文 / 16 完全逆序 验证预处理与比较语义
函数级 17 check_strip_blank 原地过滤 / 18 check_is_blank 覆盖 6 种空白 直接测工具函数
文件 21~28 正常读 / 不存在 / 空文件 / 写格式 / 含 \0 的二进制 见第五节
错误码 29~31 全部错误码文案 / 未定义码走 default 消除 common.c 的覆盖率空白

举几个有代表性的:

用例 04(精确删除 20%)——构造 100 个字符的原文,每逢 5 的倍数删一个字,
剩下 80 个字。因为抄袭版是原文的子序列,LCS 必然恰好等于 80:

for (i = 0; i < 100; i++) {
    if ((i % 5) != 4) { copy[j] = orig[i]; j++; }
}
UT_CHECK_SIZE(j, 80);
UT_CHECK_NEAR(rate_of(orig, copy), 0.80, 1e-9);

用例 08(仅空白差异)——"a b\tc\nd""a\r\nb c d" 必须算
100% 重复,用来确认预处理真的吃掉了所有 6 种空白字符。

用例 16(完全逆序)——"abcdefghij""jihgfedcba" 的 LCS 只有 1,
所以是 0.10。这条用来确认算法没有把「字符集合相同」误判成「内容相同」。

用例 20(结构体字段)——不只断言最终的 rate,还把中间量一起钉住:

UT_CHECK(st == CK_OK);
UT_CHECK_SIZE(r.orig_len, 5u);
UT_CHECK_SIZE(r.copy_len, 5u);
UT_CHECK_SIZE(r.common_len, 5u);
UT_CHECK_NEAR(r.rate, 1.00, 1e-9);

4.3 覆盖率

覆盖率用 gcc 自带的 gcov--coverage 编译,跑完单元测试 + 端到端测试再统计)。
完整报告在仓库 docs/coverage/index.html

File 'check.c'
Lines executed:93.98% of 133
Branches executed:100.00% of 76
Taken at least once:92.11% of 76
File 'fileio.c'
Lines executed:62.50% of 56
Branches executed:100.00% of 20
Taken at least once:60.00% of 20
File 'common.c'
Lines executed:100.00% of 10
Branches executed:100.00% of 8
Taken at least once:100.00% of 8
File 'main.c'
Lines executed:88.00% of 50
Branches executed:100.00% of 14
Taken at least once:78.57% of 14
Lines executed:85.94% of 249

总行覆盖率 85.94%,分支执行率 100%。

剩下没覆盖到的地方我都核对过,全部是「不可能在用户态发生」的故障分支

  • check.c:8 行,全是 malloc 失败后返回 SIZE_MAX 的路径;
  • fileio.c:21 行,全是 fseek / ftell / fread / fprintf / fclose
    返回失败的分支——除非磁盘满了或者拔了 U 盘,否则不会走到;
  • main.c:6 行,是 CK_ERR_MEMCK_ERR_TOO_BIG 相关的两条退出路径。

要覆盖这些只能做故障注入(比如用 LD_PRELOAD 挂钩 malloc 让它返回 NULL),
对这个小工具来说成本大于收益,所以我选择如实记录而不是硬凑覆盖率。

4.4 端到端测试

单元测试测的是函数,但评测方是用命令行调用程序的,中间还有参数解析、
退出码、文件读写这些环节。所以我另外写了 tests/e2e_test.py
完全按照评测的方式调用 main.exe,10 个用例 18 条断言:

用例 断言
01 正常调用 退出码 0;输出匹配 ^\d\.\d{2}$
02 参数不足 / 03 参数过多 退出码 2,且不生成任何文件
04 原文不存在 退出码 3,stderr 有可读提示
05 抄袭版不存在 / 06 答案路径不可写 退出码 4 / 7
07 原文为空 输出 0.00 且不崩溃
08 两篇完全相同 输出 1.00
09 最大样例 必须在 5 秒内完成(实测 0.07 s)
10 副作用检查 只允许写答案文件,不许产生多余文件

这些用例是我对「会不会被扣分」的直接回答:作业列的每一条红线
(超时、异常退出、读写额外文件)都对应到了一条可自动运行的断言。


五、计算模块部分异常处理说明

C 语言没有异常,所以所有失败路径都通过 CkStatus 错误码统一上报,
再由 main.c 翻译成不同的退出码——这样评测脚本和测试都能区分失败原因。
每种错误的「设计目标」是:宁可明确报错退出,也不要静默给出一个错误的数字

错误码 场景 设计目标 退出码
CK_ERR_ARG 命令行参数个数不对 立刻打印用法,绝不尝试解析残缺参数 2
CK_ERR_OPEN 文件不存在 / 无权限 明确指出是哪个路径打不开 3 / 4
CK_ERR_READ 打开了但读不出来 检测短读fread 返回值少于预期),防止读到一半的数据被当成完整文本 3 / 4
CK_ERR_TOO_BIG 文件超过 16 MB 防止异常输入把内存打爆(触发 2048 MB 红线) 3 / 4
CK_ERR_WRITE 答案文件写不进去 fclose 的返回值都要查——缓冲区真正刷盘是在 fclose 7
CK_ERR_MEM 内存分配失败 所有分配失败都走统一出口,保证不泄漏已分配的内存 5 / 6

另外还有两个不算错误但要特殊处理的情况:

  • 原文为空:约定重复率为 0,避免除零导致结果变成 infnan
  • 抄袭版为空:LCS 天然为 0,结果就是 0.00

每种异常对应的单元测试

① 参数个数错误(CK_ERR_ARG——端到端用例 02:

case("端到端02 只给两个参数 -> 退出码 2,且不生成答案文件")
code, _, err, _ = run(exe, [orig, add])
check(code == 2, f"退出码应为 2,实际 {code}")
check(not os.path.exists(missing), "不应生成任何文件")
check(len(err) > 0, "应向 stderr 输出用法说明")

② 文件打不开(CK_ERR_OPEN——单元测试用例 22:

UT_BEGIN("用例22 输入文件不存在 -> CK_ERR_OPEN");
{
    unsigned char *buf = NULL;
    size_t len = 0;
    CkStatus st = fileio_read_all("这个文件根本不存在_98765.txt", &buf, &len);

    UT_CHECK(st == CK_ERR_OPEN);
    UT_CHECK(buf == NULL);          /* 失败时不能给出一块野指针 */
}

③ 读取失败 / 把目录当文件读(CK_ERR_READ——用例 27:

UT_BEGIN("用例27 输入路径是目录 -> 读取失败");
{
    unsigned char *buf = NULL;
    size_t len = 0;
    CkStatus st = fileio_read_all(".", &buf, &len);

    UT_CHECK(st != CK_OK);   /* Windows 上 fopen 目录会失败 */
}

④ 文件过大(CK_ERR_TOO_BIG——用例 32。这条用「定位到上限之后写一个字节」
来造大文件,避免真的往磁盘刷 16 MB:

UT_CHECK(fseek(fp, (long)CK_MAX_FILE_BYTES, SEEK_SET) == 0);
UT_CHECK(fputc('\0', fp) != EOF);
UT_CHECK(fclose(fp) == 0);

st = fileio_read_all(TMP_IN, &buf, &len);
UT_CHECK(st == CK_ERR_TOO_BIG);
UT_CHECK(buf == NULL);

⑤ 答案文件写不进去(CK_ERR_WRITE——用例 26:

UT_BEGIN("用例26 答案文件路径非法 -> CK_ERR_WRITE");
{
    CkStatus st = fileio_write_rate("不存在的目录_zzz/ans.txt", 0.5);
    UT_CHECK(st == CK_ERR_WRITE);
}

⑥ 原文为空(除零保护)——用例 06:

UT_BEGIN("用例06 原文为空 -> 0.00 且不除零");
UT_CHECK_NEAR(rate_of("", "任意内容"), 0.00, 1e-9);

⑦ 内存分配的连锁问题(CK_ERR_MEM——分配失败本身很难在用户态触发,
但我用另一种方式验证了「不泄漏」这条红线:main.c 里所有出口都汇聚到同一个
cleanup: 标签:

cleanup:
    /* 所有出口都汇聚到这里,保证不泄漏 */
    free(orig_raw);
    free(copy_raw);
    free(orig);
    free(copy);
    return code;

这样就不会出现「某条错误分支忘了 free」的情况。而 fileio_read_all 内部
4 条失败路径也都各自 free(buf) 后才 return

顺带修掉的两个真实 bug

写测试的过程中真的抓到了两个问题,这里如实记下来:

① 答案文件多出一个字节。 Windows 下用文本模式 fopen(path, "w")
"%.2f\n"\n 会被自动转成 \r\n,答案文件因此变成 5 个字节的 0.82\r\n
改成 "wb" 二进制模式后输出严格是 0.82\n。这是用例 24 抓到的:

UT_CHECK(fileio_write_rate(TMP_OUT, 0.8181) == CK_OK);
UT_CHECK(read_text(TMP_OUT, text, sizeof(text)) == 5);
UT_CHECK(strcmp(text, "0.82\n") == 0);

② 我对 LCS 的直觉是错的。 我在用例 15 里原本断言
rate_of("aaaaaaaaaa", "aaaaa") == 1.00,实际跑出来是 0.50——
因为分母是原文长度 10,而 LCS 最多只能取到较短串的长度 5。
测试跑到这里失败,我才把这条语义想清楚,并把用例改成了正确的期望值
(同时补上反向的 rate_of("aaaaa", "aaaaaaaaaa") == 1.00)。


六、PSP 表格(实际耗时)

PSP2.1 Personal Software Process Stages 预估(分钟) 实际(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 480 540
· Analysis · 需求分析(包括学习新技术) 60 70
· Design Spec · 生成设计文档 40 45
· Design Review · 设计复审 20 15
· Coding Standard · 代码规范 20 25
· Design · 具体设计 60 65
· Coding · 具体编码 180 200
· Code Review · 代码复审 40 45
· Test · 测试 60 75
Reporting 报告 120 140
· Test Report · 测试报告 40 45
· Size Measurement · 计算工作量 20 20
· Postmortem & Process Improvement Plan · 事后总结 60 75
合计 630 705

预估 630 分钟,实际 705 分钟,超出约 12%。偏差主要来自:

  • 需求分析超 10 分钟:作业没规定「重复率」的口径,试了好几种算法才定下来;
  • 具体编码超 20 分钟:位并行 LCS 的进位/借位合并写法调了几轮,
    靠和动态规划版本做差分测试才确认;
  • 测试超 15 分钟:环境本身有坑(MinGW 的 gcov 写不出中文路径的 .gcda
    gprof 在 Windows 上采不到样),为了拿到覆盖率与性能数据额外折腾了一阵。

事后总结与改进计划

做得好的地方:

  1. 先把「纯计算」和「输入输出」拆开check.c 里全是「长度进、长度出」的
    纯函数,所以单元测试极好写,check.c 的行覆盖率上到了 93.98%。
  2. 优化之前先留参照实现。位并行 LCS 是靠 2000 组随机差分测试才敢换上的,
    而不是「看起来对」。
  3. 性能问题用数据说话。先用 gprof 确认 100% 的时间都在 check_lcs_dp 上,
    改完再采一次样确认瓶颈真的消失了。

下次要改进的地方:

  1. 需求不明确时要更早停下来问。如果一开始就把「重复率」的语义钉死,
    能省下不少返工。
  2. 别在工具链上耗太久。中文路径导致 gcov 失效这种事,正确做法是第一时间
    把工程挪到纯 ASCII 路径,而不是反复试。
  3. 预估粒度要更细。「具体编码 180 分钟」这种粗粒度预估几乎没有约束力,
    应该拆成「字符统计 / LCS / 命令行 / 错误处理」分别估。

七、总结

这次作业让我第一次完整体验了「写一个能被别人跑的程序」是什么样子。
以前写完 .c 能编译出结果就交差了,这次要考虑的东西多得多:

  • 别人会怎么调用我的程序?(命令行参数、退出码)
  • 别人会拿什么样的数据来跑?(空文件、超大文件、编码不同的文件)
  • 我的程序会不会把评测机搞崩?(超时、内存、多余的读写)
  • 我改完之后,怎么知道没改坏?(回归测试、覆盖率)

也第一次体会到 git 提交记录要能讲故事——这次按「基本功能 / 扩展功能」
分成了多次提交,回头看确实比一次性丢一堆代码上去清楚得多。

posted @ 2026-09-14 23:39  mikkozz  阅读(9)  评论(0)    收藏  举报