第一次个人编程作业
第一次个人编程作业——论文查重(C 语言实现)
作业 GitHub 链接:https://github.com/yydbjx/3124004168
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024CS/homework/15702 |
| 这个作业的目标 | 设计查重算法,给出原文件和修改版本,输出重复率 |
一、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 15 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 15 |
| Development | 开发 | 600 | 720 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 90 |
| · Design Spec | · 生成设计文档 | 40 | 40 |
| · Design Review | · 设计复审 | 20 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 20 | 15 |
| · Design | · 具体设计 | 60 | 60 |
| · Coding | · 具体编码 | 240 | 260 |
| · Code Review | · 代码复审 | 40 | 45 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 120 | 190 |
| Reporting | 报告 | 120 | 150 |
| · Test Report | · 测试报告 | 60 | 80 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 50 | 60 |
| · 合计 | 740 | 885 |
二、计算模块接口的设计与实现过程
2.1 代码如何组织
整个程序是一个单文件 C 程序 main.c(约 300 行),分为四个层次、共 9 个函数,没有使用全局可变状态,模块之间只通过函数参数与返回值传递数据:
| 层次 | 函数 | 职责 |
|---|---|---|
| 字符分类 | is_space_cp |
判断码点是否为空白字符(含全角空格、Unicode 空白块) |
| 文本解码 | utf8_step / looks_like_utf8 / decode_utf8 / decode_gbk / decode_utf16 |
把原始字节流解码为 32 位码点数组,并剔除空白 |
| 文件读取 | read_text |
打开文件、读入全部字节、按 BOM 与字节流特征自动选择编码、返回码点数组 |
| 核心算法 | lcs_length |
计算两个码点序列的最长公共子序列长度 |
| 主流程 | main |
解析命令行参数、调度上述模块、写出答案 |
调用关系:
main
├─ read_text(原文) ─┐
│ ├─ looks_like_utf8 ── decode_utf8 / decode_gbk / decode_utf16
├─ read_text(抄袭版) ─┤ └─ utf8_step / is_space_cp
│ │
├─ lcs_length(原文码点, 抄袭版码点)
└─ fprintf(答案文件, "%.2f")
2.2 重复率的定义(算法口径)
预处理:两篇文档各自解码为字符序列后,剔除所有空白字符(空格、换行、制表符、全角空格等),使结果不受排版差异影响。
核心量:两序列的最长公共子序列(Longest Common Subsequence, LCS)长度。子序列不要求字符连续、只要求相对顺序一致,因此对题目中"增、删、改、位移"四种抄袭手法都稳健——插入的杂字、被删掉的段落、被调换顺序的段落都不会让公共部分"断裂"。
2.3 关键函数流程图
lcs_length 是唯一的性能敏感函数,采用动态规划 + 滚动数组:
开始
│
├─ m==0 或 n==0 ? ──是──> 返回 0
│
├─ n > m ? ──是──> 交换 a/b、m/n(让较短串做列方向)
│
├─ 分配 prev[0..n]、cur[0..n],全部置 0
│
├─ for i = 0 .. m-1:
│ for j = 0 .. n-1:
│ a[i]==b[j] ? cur[j+1] = prev[j] + 1
│ : cur[j+1] = max(prev[j+1], cur[j])
│ 交换 prev 与 cur(滚动)
│
├─ lcs = prev[n]
├─ 释放 prev、cur
└─ 返回 lcs
状态转移方程即经典 LCS 递推:dp[i][j] = dp[i-1][j-1]+1(字符相等)或 max(dp[i-1][j], dp[i][j-1])(不等)。由于第 i 行只依赖第 i-1 行,完整的二维表可以压缩为两行。
2.4 独到之处
- 编码自适应:
read_text先检查 BOM(UTF-8 BOM / UTF-16 LE / UTF-16 BE),无 BOM 时用looks_like_utf8对整段字节流做一次完整的 UTF-8 合法性校验,失败则回落到 GBK 解码。课堂样例、Windows 记事本另存的 ANSI 文件、带 BOM 的文件都能直接处理,不需要用户关心编码。 - 空白不敏感:空白字符在解码阶段即被剔除,"同一段文字换了排版"不会被误判为差异。
- 短串做列的滚动数组:空间从 O(m×n) 降到 O(min(m,n)),同时较短的维度连续访问,缓存命中率更高(见下一节的实测数据)。
- 纯 C89 风格的可移植代码:不依赖任何第三方库与平台 API,
cl、gcc均可零警告编译(MSVC/W4无警告)。
三、计算模块接口部分的性能改进
3.1 改进思路
首版实现是教科书式的完整二维 DP 表:unsigned int tbl[(m+1)*(n+1)]。它在小数据上正确,但有两个问题:
- 内存:课堂样例原文 9534 字、抄袭版 11297 字(去空白后),二维表需要 (9534+1)×(11297+1)×4 字节 ≈ 431 MB;评测允许的上限是 2048 MB,若测试点文本再大一个量级就会超限甚至分配失败。
- 缓存:二维表每算一个格子都要跨一整行(约 45 KB)访问
tbl[i-1][j-1]与tbl[i-1][j],行与行之间互相驱逐缓存。
改进分两步:
- 滚动数组:只保留相邻两行
prev、cur,每行长度 n+1,内存降到 O(n),且prev[j]、prev[j+1]、cur[j]三个访问点全部落在两条相邻缓存行内; - 短串做列:进入 DP 前比较 m、n,把较短的序列放到列方向,使滚动行的长度最小(本样例中为 9534 而非 11297)。
代码中保留了宏开关 LCS_FLAT_TABLE:定义它即编译出未优化的二维表版本,方便复现下面的对比数据。
3.2 性能对比(真实样例 orig.txt vs orig_0.8_add.txt,MSVC /O2,Release)
| 版本 | 耗时 | 峰值内存量级 |
|---|---|---|
二维表版(/DLCS_FLAT_TABLE) |
503 ms | 约 431 MB |
| 滚动数组版(提交版本) | 86 ms | 约 45 KB |
滚动数组版快约 5.8 倍,内存降到原来的约万分之一。18 个评测点的文本规模与样例同量级,86 ms 远低于 5 秒限制。
3.3 程序中消耗最大的函数
用 Visual Studio 2017/2022 的性能分析工具(Debug → Performance Profiler → CPU Usage)对提交版本采样,耗时热点集中在 lcs_length 的内层双重循环(占总 CPU 时间 95% 以上),其余为文件 IO 与解码的一次性开销。

四、计算模块部分单元测试展示
单元测试采用自动化脚本 tests/run_tests.ps1 驱动 main.exe,共 14 个用例,覆盖白盒(针对 LCS 状态转移的分支:相等/不等、空串、单边为空)与黑盒(编码、空白、参数、异常退出)两个维度。运行方式:
powershell -ExecutionPolicy Bypass -File tests\run_tests.ps1
测试结果:14 通过 / 0 失败。
4.1 被测函数与构造数据的思路
| 用例 | 针对的函数/分支 | 数据构造思路 | 期望 |
|---|---|---|---|
| 1 完全相同 | lcs_length 全相等分支 |
同一文件作原文与抄袭版 | 100.00 |
| 2 完全无关 | lcs_length 全不等分支、LCS=0 |
两个无公共字符的串 | 0.00 |
| 3 题目示例 | 混合分支 | 作业描述中的原句与抄袭句 | 77.27(LCS=17,17/22) |
| 4 纯插入 | "增"手法 | 32 字基准串每 5 字插 1 字 → 39 字,LCS=32 | 82.05(32/39) |
| 5 纯删除 | "删"手法 | 32 字基准串每 5 字删 1 字 → 26 字,LCS=26 | 81.25(26/32) |
| 6 段落位移 | "位移"手法 | 两段文字前后交换 | 记录值 56.25(较长段 18 字 / 32 字) |
| 7 空白不敏感 | is_space_cp |
同串插入半角空格、换行、全角空格 | 100.00 |
| 8 GBK 编码 | decode_gbk |
用代码页 936 写同一文本 | 100.00 |
| 9 UTF-8 BOM | read_text BOM 分支 |
带 BOM 与不带 BOM 对比 | 100.00 |
| 10 UTF-16 LE | decode_utf16 |
带 BOM 的 UTF-16 文件 | 100.00 |
| 11 空原文 | denom==0 分支 |
0 字节文件 vs 有内容文件 | 0.00,不崩溃 |
| 12 文件缺失 | read_text 返回 NULL |
原文路径不存在 | 退出码非 0 |
| 13 参数错误 | argc!=4 分支 |
只传 1 个参数 | 退出码非 0 |
| 14 真实样例回归 | 全流程 | 课堂下发的 orig.txt 与 orig_0.8_add.txt | 记录值 84.39 |
用例 4、5 的期望值是手工推导的:纯插入时 LCS 必为基准串全长(基准串本身是抄袭版的子序列,且不可能更长),分母为抄袭版长度;纯删除时 LCS 必为抄袭版全长,分母为原文长度。用例 3 的 LCS=17 也是手工对齐验证过的。测试脚本的核心片段:
function Run-Case($name, $origFile, $copyFile, $expect) {
$ans = Join-Path $work ($name + ".ans")
& $ExePath $origFile $copyFile $ans | Out-Null # 与评测相同的三参数调用
if ($LASTEXITCODE -ne 0) { return "FAIL(exit=$LASTEXITCODE)" }
$got = ([System.IO.File]::ReadAllText($ans)).Trim()
if ($got -eq $expect) { return "PASS(got=$got)" }
return "FAIL(expect=$expect got=$got)"
}
五、计算模块部分异常处理说明
| 异常 | 设计目标 | 处理方式 | 对应单元测试样例与场景 |
|---|---|---|---|
| 命令行参数个数不为 3 | 给用户明确的用法提示,而不是未定义行为 | 向 stderr 打印用法,返回退出码 1 | 用例 13:只传 1 个参数时程序打印"用法: main.exe [原文文件] [抄袭版论文的文件] [答案文件]"并以 1 退出 |
| 原文/抄袭版文件不存在或不可读 | 评测环境中路径错误应快速失败,不能产出错误答案 | fopen 失败即向 stderr 报错并返回 1,不写答案文件 |
用例 12:传入不存在的路径,退出码为 1 |
| 答案文件所在目录不存在/不可写 | 同上,写失败要显式报错 | fopen(..., "w") 失败报错返回 1,并释放已分配的内存 |
手工验证:把答案路径指向不存在的目录,退出码 1 且无残留输出 |
| 文件编码未知(UTF-8 / GBK / UTF-16 混用) | 同一程序兼容课堂样例与不同编辑器产物 | BOM 优先;无 BOM 时整段校验 UTF-8 合法性,失败回落 GBK;UTF-8 内部孤立非法字节以替换字符兜底,不中断 | 用例 8、9、10:GBK、UTF-8 BOM、UTF-16 LE 三种编码同一文本均得 100.00 |
| 空文件 / 全空白文件 | 除零保护:分母为 0 时重复率定义为 0 而非 NaN | denom==0 时跳过 LCS 计算,直接输出 0.00 |
用例 11:0 字节原文得到 0.00 且正常退出 |
| 内存分配失败(超大输入) | 不因 malloc 返回 NULL 而解引用崩溃 | 每个 malloc/calloc 的返回值都检查,失败时释放已持有内存并返回 | 构造:将 calloc 替换为恒返回 NULL 的桩函数编译一遍,程序以非 0 码退出(白盒验证) |
| 超大二维表溢出(仅旧版存在) | 防 (m+1)*(n+1) 乘法溢出导致分配过小缓冲区 |
旧版分配前做溢出检查;提交版改用滚动数组后该风险整体消除 | 见性能改进一节 |
每种异常都遵循同一原则:宁可显式失败(非 0 退出码 + stderr 提示),也不输出一个错误的答案文件,避免污染评测判定。
六、事后总结与过程改进计划
- 先定口径再写代码:本次最大的时间开销不在编码,而在确认"重复率"的数学定义。用"增/删两种变换在同一参数下应得到对称结果"这条性质反推出
LCS / max(m,n)的口径,比直接猜公式可靠。今后遇到指标类需求,先找这种"不变量"再动手。 - 性能改进要留对照:保留
LCS_FLAT_TABLE宏开关使优化前后可以同机对比(503 ms → 86 ms),博客里的数字因此可复现。今后做优化都应保留可切换的基线版本。 - 测试期望值要手工可推:用例 4、5 的期望值由变换构造直接推出,第一版脚本里我手算错了三处,正是程序输出与手推值对不上才暴露出来——自动化测试的价值在于"双向校验",既验程序也验人。
- 改进计划:若未来文本规模上升到百万字级,O(mn) 的 LCS 仍是瓶颈,可引入 Hirschberg 分治(空间不变、时间不变但常数更优的并行化)或基于哈希锚点的分段 LCS 近似;同时把重复率口径做成可选参数,便于对齐不同评测标准。
附:编译与运行
# 编译(MSVC,零警告)
cl /nologo /O2 /W4 /utf-8 /Fe:main.exe main.c
# 运行(三个绝对路径参数,空格分隔)
main.exe C:\tests\org.txt C:\tests\org_add.txt C:\tests\ans.txt
# 单元测试
powershell -ExecutionPolicy Bypass -File tests\run_tests.ps1
答案文件内容为一个精确到小数点后两位的浮点数,例如 84.39。

浙公网安备 33010602011771号