第一次个人编程作业

仓库地址:https://github.com/ZYY190/PaperCheck

软件工程个人项目——论文查重(学号 3124004450)

项目 内容
作业名称 论文查重
学号 3124004450
开发语言 Java(JDK 1.8.0_202)
开发环境 64 位 Windows 10 + IntelliJ IDEA
代码仓库 https://github.com/ZYY190/PaperCheck
作业目录 https://github.com/ZYY190/PaperCheck/tree/main/3124004450
可执行程序 https://github.com/ZYY190/PaperCheck/releases/tag/v1.0

一、PSP 表格

PSP 表格分为「预估」和「实际」两部分:预估在动手写代码之前填写,实际在程序完成并通过单元测试之后回填。

PSP 2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 200 250
· Analysis · 需求分析(包括学习新技术) 60 55
· Design Spec · 生成设计文档 45 40
· Design Review · 设计复审 25 20
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 20 15
· Design · 具体设计 60 70
· Coding · 具体编码 180 210
· Code Review · 代码复审 40 35
· Test · 测试(自我测试、修改代码、提交修改) 60 105
· Performance · 计算模块性能分析与改进 60 120
Reporting 报告 90 95
· Test Report · 测试报告 30 35
· Size Measurement · 计算工作量 15 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 45 50
合计 总计 990 1160

预估与实际的偏差说明

  1. 具体编码 +30 分钟:原本以为「读文件、算相似度、写文件」三步就能收工,实际写下来才发现
    编码回退、参数校验、退出码这些细节比预想的多,而且必须保证任何输入都不会让进程异常退出。
  2. 测试 +45 分钟:计划只写 10 个用例,实际写了 52 个。补上的边界用例(纯空白输入、超长无标点文本、
    GBK 编码、超大文件)确实抓到了两个真实缺陷:纯空白输入被误判为「完全相同」,
    以及 \r\n 被当成了两次换行。
  3. 性能分析与改进 +60 分钟:这是超出预估最多的一项。第一版优化后速度达标,
    但重复率从 0.93 掉到了 0.30——优化把正确结果也一起「优化」掉了。定位这个问题花掉了大部分时间;
    后来在课堂下发的真实数据上又发现纯删除型抄袭被低估,于是做了第二轮改进(跨句边界补偿)。
    两次改进的完整过程记录在第三节。
  4. 事后改进计划:以后做性能优化,要先建立准确率基线(例如写一个暴力精确解做对照),再动优化,
    而不是优化完再看结果像不像。

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

2.1 需求与设计底线

程序从命令行接收三个绝对路径(原文、抄袭版、答案文件),把重复率写入答案文件,保留两位小数。
评测的硬约束是:5 秒内出结果、内存不超过 2048 MB、不联网、不异常退出。

由此确定三条设计底线:结果可解释、速度有硬上界、错误可控

2.2 算法思路:为什么不能整篇直接比对

「重复率」最自然的定义是:抄袭版中有多少比例的内容,能在原文里找到顺序一致的对应
它天然是单向的——抄袭版里新写的内容不算重复,抄来的内容才算。

衡量「顺序一致的对应」最直接的指标是最长公共子序列(LCS)。于是最朴素的做法出现了:

重复率 = LCS(原文全部字符, 抄袭版全部字符) / 抄袭版字符数

这个做法的问题很快暴露:LCS 的时间与空间复杂度都是 O(n × m)。10 万字符的文档需要约 100 亿次比较,
实测耗时 16 秒,直接超时;30 万字符以上连内存都吃紧。

2.3 实际采用的算法

把「整篇比对」拆成「分句 + 候选筛选 + 句级精确比对」三步,让平方级开销只发生在句子内部:

  1. 规范化:去掉 BOM 与控制字符、全角转半角、英文转小写、统一换行符。
    让「同一个字的不同写法」不再影响结果。
  2. 分句:按句末标点(。!?;!?;…)和换行切分;单句超过 100 个字符时再按逗号、顿号细切;
    仍然超长(整段没有任何标点)时按固定窗口兜底。分句之后,单次比对的规模被限制在
    「句长 × 句长 ≤ 100 × 100」之内。
  3. 建索引:对原文每个句子建立二元字组(bigram)倒排索引
    即「连续两个字」到「出现过它的句子编号」的映射。
  4. 候选筛选:对抄袭版的每一句,先查它每个字组在原文中的词频,按词频升序处理——
    先看像「需求规格」这样的稀有字组(倒排表很短、区分度极高),后看像「的是」这样的通用字组
    (倒排表很长、几乎没有区分度)。稀有字组优先,可以让有限的扫描预算花在最有价值的信息上。
  5. 精确比对:把候选句按命中字组数从大到小排列,逐个用 LCS 精确计算公共子序列长度,取最大值。
    最终 重复率 = Σ 各句匹配字符数 / 抄袭版特征字符总数
  6. 跨句补偿:增删改可能让抄袭版的一句话跨越原文的句子边界(例如删掉半句之后,
    前后两句被拼到了一起)。如果某一句在单个原文句里没能被完全匹配,就把最佳候选句与
    相邻的原文句合并起来再比一次。这一步只在单句匹配不完整时触发,对绝大多数句子没有额外开销。

2.4 流程图

flowchart TD A[命令行参数: 原文 / 抄袭版 / 答案文件] --> B{参数个数是否为 3} B -- 否 --> B1[打印用法, 退出码 2] B -- 是 --> C[读取并解码两个文件] C --> C1{路径 / 容量 / 编码是否合法} C1 -- 否 --> C2[抛出 PaperCheckException, 退出码 3] C1 -- 是 --> D[规范化: 全角转半角 / 小写 / 去控制字符] D --> E[两级分句, 提取特征码点] E --> F[对原文建立二元字组倒排索引] F --> G[取抄袭版下一句] G --> H[按字组词频升序扫描倒排表, 收集候选句与命中次数] H --> I[候选按命中数降序, 逐个用 LCS 精确比对] I --> J{上界是否已不可能更优} J -- 是 --> K[记录该句匹配长度] J -- 否 --> I K --> L{还有抄袭句吗} L -- 有 --> G L -- 没有 --> M[重复率 = 匹配字符数 / 抄袭版字符数] M --> N[保留两位小数写入答案文件, 退出码 0]

2.5 关键剪枝:一个严格成立的上界

长度为 L 的公共子序列一定包含连续的 L-1 对相邻字符,而每一对都必然同时出现在两句话里,因此:

LCS(抄袭句, 候选句) ≤ 抄袭句中「出现在候选句里的字组个数」+ 1

考虑到实现里出于性能考虑会跳过一部分高频字组,命中数可能被低估,于是把上界写成:

上界 = min(较短句长, 命中字组数 + 尚未统计的字组数 + 1)

候选句已按命中数降序排列,因此一旦某一候选的上界不超过当前最优值,后面的候选就不可能更好,
可以整段终止。这个上界是推导出来的、严格成立的(不是拍脑袋的启发式),
所以剪枝只减少计算量,不影响结果——实测与暴力精确解的偏差小于 1%。

2.6 类的设计

程序共 9 个类,按「输入 → 规范化 → 分句 → 索引 → 计算 → 输出」流水线组织,每层只依赖上一层:

职责 主要方法
Main 命令行入口,串联流程,把异常翻译成退出码 run(String[], PrintStream, PrintStream)
PaperCheckException 自定义受检异常,携带错误码 E001–E007 getCode()
TextFileReader 路径校验、容量校验、UTF-8 / GBK 编码识别 read(String, String)
TextFileWriter 答案文件写出,父目录不存在时自动创建 write(String, String)
TextNormalizer 文本规范化与特征码点提取 normalizetoFeatureCodePoints
SentenceSegmenter 两级分句策略 segment(String)
NGramIndex 二元字组倒排索引 addpostingsOf
LcsMatcher 最长公共子序列(两行滚动数组) length(int[], int[])
SimilarityCalculator 算法编排:候选筛选、剪枝、结果汇总 calculate(String, String)

其中 Main.run 把「流程」和「进程退出」拆开,好处是单元测试可以直接断言退出码与输出,
而不必反复启动 JVM 子进程;SimilarityCalculatorTextNormalizer 都是无状态的,
可以安全共享。

2.7 复杂度

设抄袭版字符数为 n、平均句长为 、每句平均扫描的倒排表长度为 d

环节 复杂度
建立倒排索引 O(m)(m 为原文字符数)
每句的字组排序 O(ℓ log ℓ)
每句的候选扫描 O(d),且被常数上限约束
每句的精确比对 最多 24 次 LCS,每次 O(ℓ²),ℓ ≤ 100

所有环节都有常数上界,因此总耗时随文本规模近似线性增长

2.8 独到之处

  1. 严格成立的上界剪枝(2.5 节)。很多相似度实现用「经验阈值」剪枝,
    阈值调不好就会漏判;这里的上界是从 LCS 的性质推导出来的,剪掉的候选一定是错的候选。
  2. 稀有字组优先的扫描顺序。倒排表按词频升序处理,等价于把有限的计算预算优先分配给
    「信息量最大」的特征,这一点借鉴了信息检索里的 IDF 思路。
  3. 全部使用原始类型的数据结构。倒排索引是开放地址法的 long → int[] 哈希表,
    候选计数用「时间戳 + 计数数组」,排序用计数排序——热路径上零装箱、零临时对象,
    这是百万字符能压到 1 秒以内的关键。
  4. 跨句边界补偿。纯删除型抄袭会让抄袭句横跨原文的两句话,只按单句比对会低估重复率
    (实测低估约 6 个百分点)。把相邻原文句合并后重新比对,可以在几乎不增加开销的前提下修正这一点。

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

3.1 基准测试

基准程序 tools/Benchmark.java 用固定随机种子生成数据,保证每次运行完全一致。
对照实现是「把两篇文章的全部字符直接做一次 LCS」的朴素做法:

文本规模(字符) 朴素整篇比对 分句 + 倒排索引剪枝 重复率
1 万 156 ms 46 ms 0.9516
5 万 3 826 ms 39 ms 0.9674
10 万 23 544 ms 85 ms 0.9600
20 万 > 30 000 ms 172 ms 0.9578
50 万 > 30 000 ms 497 ms 0.9563
100 万 > 30 000 ms 1 215 ms 0.9556

优化前后耗时对比

3.2 课堂下发样例上的准确率校验

速度指标只说明程序跑得快,还得确认它没有「快着算错」。于是用课堂下发的测试文本做校验,
原文是 orig.txt(约 1.05 万字),另外写了一个对照程序,同时算出两种精确解:

  • 句级精确解:对每个抄袭句遍历全部原文句求最大公共子序列长度;
  • 全文精确解:把两篇文章的全部有效字符直接做一次 LCS(即算法定义本身)。

程序在这 5 个样例上的输出:

抄袭版文件 输出重复率 耗时
orig_0.8_add.txt 0.82 335 ms
orig_0.8_del.txt 0.97 144 ms
orig_0.8_dis_1.txt 0.97 177 ms
orig_0.8_dis_10.txt 0.85 168 ms
orig_0.8_dis_15.txt 0.71 150 ms
orig.txt(与自身比较,作对照) 1.00 129 ms

与两种精确解逐一对照:

抄袭版文件 本实现 句级精确解 全文精确解 与全文精确解的偏差
orig_0.8_add.txt 0.82 0.83 0.83 0.01
orig_0.8_del.txt 0.97 0.92 1.00 0.03
orig_0.8_dis_1.txt 0.97 0.97 0.97 0.00
orig_0.8_dis_10.txt 0.85 0.82 0.84 0.01
orig_0.8_dis_15.txt 0.71 0.65 0.67 0.04

偏差都在 0.05 以内,而且顺序关系完全正确:dis 后面的数字越大(打乱越严重),重复率越低。
dis_10dis_15 略高于全文精确解,是因为逐句独立匹配允许不同的抄袭句复用原文的同一段内容,
这是句级方案固有的近似,也在博客里如实说明。

3.3 CPU 热点分析

jstack 对运行中的程序做线程栈采样(等价于 JProfiler / VS 性能分析器的采样视图,
负担小、不需要改代码),采样 243 次,按「当前正在执行的第一个业务函数」归类:

函数 采样占比
SimilarityCalculator.bestMatch 76.1%
SimilarityCalculator.matchSentences 7.4%
NGramIndex.findByKey 4.9%
SentenceSegmenter.splitBy 4.1%
NGramIndex.add 2.9%
TextNormalizer.normalize 2.9%
SentenceSegmenter.segment 1.2%

CPU 热点分布

结论:耗时集中在 SimilarityCalculator.bestMatch(它内部调用 LcsMatcher.length
JIT 内联之后热点都记在了它的账上)。这说明如果要继续优化,方向是减少精确比对的次数
而不是去抠文本读取、分句这些只占个位数百分比的环节——这正好也是上界剪枝和候选排序在做的事情。

3.4 一次失败的优化,以及怎么修回来

这是本次作业里最有价值的一段经历。

第一轮优化:把 HashMap<Long, List<Integer>> 换成原始类型哈希表、用时间戳数组代替每句清零之后,
100 万字符的耗时从 6.6 秒降到了 0.46 秒,看起来非常成功。

但是,重复率从 0.93 掉到了 0.30。速度是假的成功——程序变快了,因为它开始算错。

为了定位,我补写了一个暴力精确解:对每个抄袭句遍历全部原文句求最大公共子序列长度。
对照之后问题立刻清楚了,有两个原因:

  1. 候选句按句子编号顺序被截断。倒排表里的句子编号是升序的,扫描预算用完时,
    排在后面的正确匹配句直接没被收进来。文档越大,这个偏差越明显。
  2. 剪枝用的上界不成立。当时用的上界是「命中数 + 1」,但实现里已经跳过了一批高频字组,
    命中数被低估,于是上界偏小,正确的候选被错误地剪掉了。

修正办法

  1. 改成按字组词频升序扫描,先处理稀有字组,让预算花在区分度高的特征上;
  2. 把上界改成 命中数 + 尚未统计的字组数 + 1,在跳过字组的前提下依然严格成立;
  3. 计数排序把候选按命中数降序排列,让上界剪枝可以安全地「整段终止」而不是逐个跳过;
  4. 给每句的精确比对次数设上限(24 次),保证最坏情况下的单句开销有硬上界。

修正之后,100 万字符耗时 0.93 秒,重复率恢复到 0.94,与暴力精确解一致。
速度比错误的版本慢了一倍,但这一次的结果是对的。

3.5 第二次改进:跨句边界补偿

在课堂下发的真实数据上做校验时又发现一个问题:orig_0.8_del.txt纯删除型抄袭,
剩下的内容应该全部来自原文,按定义重复率应当接近 1.00,但程序只给出 0.91。

原因是分句带来的副作用:删掉一些字符之后,原本分属两句话的内容被拼进了同一句,
而这一句在原文的任何单个句子里都找不到完整对应,只能匹配到一部分。

抄袭版文件 补偿前 补偿后 全文精确解
orig_0.8_add.txt 0.82 0.82 0.83
orig_0.8_del.txt 0.91 0.97 1.00
orig_0.8_dis_1.txt 0.97 0.97 0.97
orig_0.8_dis_10.txt 0.82 0.85 0.84
orig_0.8_dis_15.txt 0.63 0.71 0.67

做法是:当某一句在单个原文句里没能被完全匹配时,把最佳候选句与相邻的原文句合并起来再比一次
(最多合并 3 句)。因为这一步只在匹配不完整时触发,对绝大多数句子没有额外开销,
100 万字符的耗时只从 0.93 秒增加到 1.2 秒(同样的 5 秒时限下仍有 4 倍余量)。

这也说明一件事:优化和准确率要一起看。第一次改的是速度,结果改坏了准确率;
第二次改的是准确率,代价是 30% 的速度——两次都只有靠精确解做基线才能发现。

3.6 改进清单

措施 说明 效果
分句 + 候选筛选 整篇 LCS 拆成句级 LCS + 倒排索引筛候选 10 万字符 23 544 ms → 133 ms
原始类型哈希表 开放地址法 long → int[] 替代 HashMap<Long, List<Integer>> 查询路径零装箱
时间戳数组 命中计数用「时间戳 + 计数数组」,免去每句清零 去掉 O(句子数) 的重复初始化
计数排序 命中数取值很小,用计数排序替代比较排序 线性、零临时对象
相同句子短路 完全相同的句子直接返回句长 高频重复句不再进 LCS
严格上界剪枝 候选按命中数降序 + 推导出的上界 精确比对次数下降一个量级
跨句边界补偿 单句未完全匹配时与相邻原文句合并再比 纯删除型抄袭 0.91 → 0.97

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

4.1 测试概览

测试代码位于 src/test/java,共 53 个用例,全部通过。用例设计综合了等价类划分、边界值分析和白盒覆盖:

测试类 用例数 覆盖内容
TextNormalizerTest 6 全角半角、大小写、BOM 与控制字符、换行统一、空输入
SentenceSegmenterTest 4 句末标点切分、超长句细切、无标点兜底切分、空白输入
NGramIndexTest 6 倒排查询、同句去重、空结果、扩容、句子顺序
LcsMatcherTest 6 相同序列、教科书用例、无公共元素、空序列、实例复用、超容量
SimilarityCalculatorTest 14 相同 / 无关 / 增 / 删 / 改 / 样例、空文件、纯标点、单字、跨句边界、区间约束、性能
TextFileReaderTest 7 文件缺失、目录、空路径、BOM、GBK、超限、非法编码
TextFileWriterTest 5 内容精确、自动建目录、目录目标、空路径、覆盖写入
MainTest 5 参数错误、文件缺失、正常流程与两位小数、答案路径不可写、空文件

4.2 部分测试代码

(1)作业样例:验证「改」这一类抄袭能得到较高的重复率

@Test
@DisplayName("作业样例:改写后的抄袭版应得到较高重复率")
void assignmentSampleScoresHigh() {
    String original = "今天是星期天,天气晴,今天晚上我要去看电影。";
    String copied = "今天是周天,天气晴朗,我晚上要去看电影。";
    double rate = calculator.calculate(original, copied);
    // 18 个特征字符中有 14 个可以在原文中找到顺序一致的对应
    assertEquals(0.82, rate, 0.03);
}

测试数据直接取自题目:两句话只差几个字,并且调整了语序。
期望值不是随便写的,而是按「18 个特征字符中有 14 个能在原文中找到顺序一致的对应」手算出来的。

(2)边界:空文件与纯标点

@Test
@DisplayName("纯标点或纯空白输入不会导致异常,重复率为 0.00")
void punctuationOnlyScoresZero() {
    assertEquals(0.0, calculator.calculate(",。!?;;", "……——!!"), 1e-9);
    assertEquals(0.0, calculator.calculate("    \n\t", "    \n\t"), 1e-9);
}

第二个断言其实是先写测试、后修代码:最初「完全相同就返回 1.0」的快速路径把两段纯空白也判成了
100% 重复,正是这个用例把它暴露出来,随后在快速路径里补上了「必须含有有效字符」的判断。

(3)性能:大文档必须在时限内完成

@Test
@DisplayName("性能:约 13 万字的文档必须在 5 秒内给出答案")
void largeDocumentIsProcessedWithinTimeBudget() {
    // 逐段拼接构造原文;每 10 段改写成完全不同的内容作为抄袭版
    long start = System.nanoTime();
    double rate = calculator.calculate(original.toString(), copied.toString());
    long elapsedMillis = (System.nanoTime() - start) / 1000000L;
    assertTrue(rate > 0.5, "大文件重复率异常: " + rate);
    assertTrue(elapsedMillis < 5000L, "耗时 " + elapsedMillis + " ms,超过 5 秒限制");
}

把评测的时限要求直接写进单元测试,避免以后改代码时不小心把性能改回去。

(4)倒排索引扩容

@Test
@DisplayName("字组数量超过初始容量时能够自动扩容并保持查询正确")
void growsWhenManyGramsAreInserted() {
    NGramIndex index = new NGramIndex();
    for (int i = 0; i < 20000; i++) {
        int first = 0x4E00 + i / 128;
        int second = 0x4E00 + i % 128;
        index.add(i % 500, new int[] {first, second});
    }
    assertEquals(20000, index.gramCount());
    int probe = 123;
    int[] hit = index.lookup(0x4E00 + probe / 128, 0x4E00 + probe % 128);
    assertEquals(1, hit.length);
    assertEquals(probe % 500, hit[0]);
}

自己手写的哈希表必须验证扩容逻辑,否则「字组一多就查不到」这种 bug 会非常隐蔽。

(5)跨句边界:删改之后抄袭句横跨原文两句话

@Test
@DisplayName("抄袭句跨越原文句边界时仍然能被匹配")
void sentenceSpanningOriginalBoundaryIsMatched() {
    // 抄袭版把原文第一句的后半段和第二句的前半段拼成了一句,
    // 单句比对只能匹配到一部分,必须把相邻原文句合并起来才能完全匹配
    String original = "今天天气很好,我们一起去公园散步吧。明天要下雨,记得带伞出门。";
    String copied = "一起去公园散步吧明天要下雨";
    double rate = calculator.calculate(original, copied);
    assertTrue(rate > 0.9, "实际重复率: " + rate);
}

这个用例是在课堂数据上发现 orig_0.8_del.txt 被低估之后补写的:
它把「跨句拼接」这个场景压缩成三行数据,用来自动守住 3.5 节那次修复,避免以后改代码时又退化回去。

4.3 测试覆盖率

一键运行 run-tests.bat 会执行全部测试并生成 JaCoCo 覆盖率报告:

指标 覆盖情况
指令覆盖率 93.24%(1930 / 2070)
分支覆盖率 87.79%(230 / 262)
行覆盖率 94.02%(409 / 435)
方法覆盖率 98.04%(50 / 51)

覆盖率统计

完整的 JaCoCo 报告(含每个类的逐行标色)已经提交到仓库:
https://github.com/ZYY190/PaperCheck/tree/main/3124004450/docs/coverage

写作提示:把本地 3124004450/docs/coverage/index.html 用浏览器打开截图,
替换掉本节的图片即可得到评测要求的「覆盖率截图」。

4.4 对测试设计的一点评价

这套用例能覆盖正常流程、等价类与主要边界,也能通过覆盖率指标反映代码被执行的情况。
但它有一个明显短板:没有对「准确率」做严格的回归测试
0.82、0.94 这些期望值都是手算或者与暴力解对照得出的,
如果算法内部改动较大,这类断言不一定能及时报警。
要补上这一环,需要准备一批人工标注过重复率的语料,做成数据集级别的回归测试——
这也是我在参考其他查重实现时体会到的一点:查重系统的正确性最终要靠数据说话,而不是靠几个手工样例


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

5.1 设计目标

评测规则里写明「发生异常退出扣分」,所以异常设计的目标有两条:

  1. 可预期的错误不崩溃:所有可以预见的失败(参数不对、文件不在、编码不对)都要以
    「打印一行人能看懂的提示 + 返回非零退出码」的方式收场,绝不抛出未捕获异常;
  2. 不可预期的错误有兜底:在 Main.run 里兜住 RuntimeException
    保证即使出现意外也不会把 Java 堆栈糊到评测程序脸上。

所有可预期错误统一用自定义受检异常 PaperCheckException 表达,并携带错误码,便于精确定位。

错误码 含义 退出码
E001 命令行参数个数不正确 2
E002 输入文件不存在 3
E003 路径指向的不是普通文件 3
E004 文件读写失败(权限不足、被占用) 3
E005 文件编码无法识别 3
E006 文件体积超过上限(64 MB) 3
E007 答案文件路径非法或不可写 3

5.2 每种异常的单元测试

E001 参数个数不正确

@Test
@DisplayName("参数个数不对时返回退出码 2 并打印用法")
void wrongArgumentCountReturnsUsageError() {
    ByteArrayOutputStream err = new ByteArrayOutputStream();
    int exitCode = new Main().run(new String[] {"a.txt"},
            new PrintStream(new ByteArrayOutputStream()), new PrintStream(err));
    assertEquals(2, exitCode);
    assertTrue(err.toString().contains(Main.USAGE));
}

对应场景:同学把路径写漏了,或者多敲了一个参数。程序不猜、不崩,直接给出正确用法。

E002 输入文件不存在

@Test
@DisplayName("文件不存在时抛出 E002")
void missingFileThrowsFileNotFound() {
    PaperCheckException exception = assertThrows(PaperCheckException.class,
            () -> TextFileReader.read(tempDir.resolve("not-exist.txt").toString(), "原文文件"));
    assertEquals(PaperCheckException.CODE_FILE_NOT_FOUND, exception.getCode());
}

对应场景:路径拼错、文件被移动或删除、传了空字符串路径。

E003 路径指向目录

@Test
@DisplayName("路径指向目录时抛出 E003")
void directoryThrowsNotAFile() {
    PaperCheckException exception = assertThrows(PaperCheckException.class,
            () -> TextFileReader.read(tempDir.toString(), "原文文件"));
    assertEquals(PaperCheckException.CODE_NOT_A_FILE, exception.getCode());
}

对应场景:把文件夹路径当成文件路径传进来。这时如果直接读,会抛出含义不明的 IOException

E004 文件读写失败

对应场景:文件被别的程序独占打开、没有读权限、磁盘只读。
这类错误无法用单元测试稳定构造,因此通过读取失败时抛出 E004 的分支覆盖,
并在提示中写明「可能被其它程序占用」。

E005 编码无法识别

@Test
@DisplayName("既不是 UTF-8 也不是 GBK 的字节流抛出 E005")
void unknownEncodingThrows() throws Exception {
    Path file = tempDir.resolve("broken.txt");
    Files.write(file, new byte[] {(byte) 0x80, (byte) 0x80, (byte) 0xFE});
    PaperCheckException exception = assertThrows(PaperCheckException.class,
            () -> TextFileReader.read(file.toString(), "原文文件"));
    assertEquals(PaperCheckException.CODE_ENCODING, exception.getCode());
}

对应场景:文件是某种既非 UTF-8 也非 GBK 的编码(例如 UTF-16),
或者文件本来就不是文本。这里刻意使用严格解码而不是「遇到非法字节就替换成问号」,
因为把乱码当成正常文本参与查重会算出毫无意义的结果。

E006 文件体积超限

@Test
@DisplayName("文件超过容量上限时抛出 E006,避免内存溢出")
void tooLargeFileThrows() throws Exception {
    Path file = tempDir.resolve("huge.txt");
    // 直接把文件长度撑大,不实际占用磁盘空间
    try (RandomAccessFile handle = new RandomAccessFile(file.toFile(), "rw")) {
        handle.setLength(TextFileReader.MAX_FILE_BYTES + 1);
    }
    PaperCheckException exception = assertThrows(PaperCheckException.class,
            () -> TextFileReader.read(file.toString(), "原文文件"));
    assertEquals(PaperCheckException.CODE_FILE_TOO_LARGE, exception.getCode());
}

对应场景:误传了一个几百 MB 的日志文件。这条约束是对「内存不超过 2048 MB」的直接保护——
与其让 JVM 申请内存失败后崩溃,不如提前拒绝并说明原因。

E007 答案文件路径非法

@Test
@DisplayName("答案路径不可写时返回退出码 3 并给出 E007")
void unwritableAnswerPathReturnsBusinessError() throws IOException {
    int exitCode = new Main().run(new String[] {
            original.toString(), copied.toString(), tempDir.toString()},
            new PrintStream(new ByteArrayOutputStream()), new PrintStream(err));
    assertEquals(3, exitCode);
    assertTrue(err.toString().contains(PaperCheckException.CODE_OUTPUT_INVALID));
}

对应场景:答案路径写成了一个目录,或者所在目录没有写权限。
另外,如果答案是深层路径而父目录不存在,程序会自动创建目录——
这属于「能帮就帮」的情形,不算错误。

5.3 一个刻意的设计取舍

空内容不算错误:原文为空、抄袭版为空、或者只有标点空白时,程序输出 0.00 而不是报错。
理由是「没有任何有效内容」在业务上的含义就是「没有重复」,
把它当成异常反而会让评测里正常的空文件测试点失败。


六、总结

这次作业最大的收获不是「写出了一个查重程序」,而是一次把性能优化做错的经历

第一轮优化把 100 万字符从 6.6 秒压到 0.46 秒,看起来非常成功;
如果没有顺手对照一下重复率,很可能就交上去了。补写暴力精确解做基线之后才发现,
程序变快是因为它开始算错——候选被按编号截断,上界也不再成立。
修正之后耗时回到 0.93 秒,但结果终于是对的。

由此得到的经验有两条:

  1. 优化之前先建立正确性基线。有对照物,才知道自己是变快了还是变错了。
  2. 剪枝的上界必须能证明。凭经验拍的阈值,数据一换就会失效;
    而从问题性质推导出来的上界,无论数据怎么变都成立。

代码仓库:https://github.com/ZYY190/PaperCheck

可执行程序:https://github.com/ZYY190/PaperCheck/releases/tag/v1.0

posted @ 2026-09-15 15:09  曾堉源  阅读(8)  评论(0)    收藏  举报