第一次个人编程作业

第一次个人编程作业——论文查重(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 独到之处

  1. 编码自适应read_text 先检查 BOM(UTF-8 BOM / UTF-16 LE / UTF-16 BE),无 BOM 时用 looks_like_utf8 对整段字节流做一次完整的 UTF-8 合法性校验,失败则回落到 GBK 解码。课堂样例、Windows 记事本另存的 ANSI 文件、带 BOM 的文件都能直接处理,不需要用户关心编码。
  2. 空白不敏感:空白字符在解码阶段即被剔除,"同一段文字换了排版"不会被误判为差异。
  3. 短串做列的滚动数组:空间从 O(m×n) 降到 O(min(m,n)),同时较短的维度连续访问,缓存命中率更高(见下一节的实测数据)。
  4. 纯 C89 风格的可移植代码:不依赖任何第三方库与平台 API,clgcc 均可零警告编译(MSVC /W4 无警告)。

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

3.1 改进思路

首版实现是教科书式的完整二维 DP 表:unsigned int tbl[(m+1)*(n+1)]。它在小数据上正确,但有两个问题:

  1. 内存:课堂样例原文 9534 字、抄袭版 11297 字(去空白后),二维表需要 (9534+1)×(11297+1)×4 字节 ≈ 431 MB;评测允许的上限是 2048 MB,若测试点文本再大一个量级就会超限甚至分配失败。
  2. 缓存:二维表每算一个格子都要跨一整行(约 45 KB)访问 tbl[i-1][j-1]tbl[i-1][j],行与行之间互相驱逐缓存。

改进分两步:

  • 滚动数组:只保留相邻两行 prevcur,每行长度 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 与解码的一次性开销。

微信图片_20260915194652_41_3

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

单元测试采用自动化脚本 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 提示),也不输出一个错误的答案文件,避免污染评测判定。

六、事后总结与过程改进计划

  1. 先定口径再写代码:本次最大的时间开销不在编码,而在确认"重复率"的数学定义。用"增/删两种变换在同一参数下应得到对称结果"这条性质反推出 LCS / max(m,n) 的口径,比直接猜公式可靠。今后遇到指标类需求,先找这种"不变量"再动手。
  2. 性能改进要留对照:保留 LCS_FLAT_TABLE 宏开关使优化前后可以同机对比(503 ms → 86 ms),博客里的数字因此可复现。今后做优化都应保留可切换的基线版本。
  3. 测试期望值要手工可推:用例 4、5 的期望值由变换构造直接推出,第一版脚本里我手算错了三处,正是程序输出与手推值对不上才暴露出来——自动化测试的价值在于"双向校验",既验程序也验人。
  4. 改进计划:若未来文本规模上升到百万字级,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

posted @ 2026-09-15 20:43  yydbjx  阅读(5)  评论(0)    收藏  举报