第一次个人编程作业
作业github链接:https://github.com/woshi314/woshi314/tree/main
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | 个人项目 - 作业 - 计科24级78班 - 班级博客 - 博客园 |
| 这个作业的目标 | 设计一个基于命令行文件输入输出的论文查重程序,计算原文与抄袭版论文的重复率 |
一、PSP表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 25 |
| · Estimate | 估计这个任务需要多少时间 | 10 | 8 |
| Development | 开发 | 210 | 225 |
| · Analysis | 需求分析(包括学习新技术) | 35 | 40 |
| · Design Spec | 生成设计文档 | 15 | 12 |
| · Design Review | 设计复审 | 10 | 12 |
| · Coding Standard | 代码规范 | 10 | 8 |
| · Design | 具体设计 | 25 | 28 |
| · Coding | 具体编码 | 65 | 72 |
| · Code Review | 代码复审 | 15 | 13 |
| · Test | 测试(自我测试,修改代码,提交修改) | 35 | 40 |
| Reporting | 报告 | 85 | 92 |
| · Test Report | 测试报告 | 25 | 28 |
| · Size Measurement | 计算工作量 | 10 | 9 |
| · Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 50 | 55 |
| 合计 | 600 | 625 |
二、计算模块接口的设计与实现过程
2.1 需求拆解
需求:程序从命令行接收三个文件路径(原文、抄袭版、答案),读取两个输入文件,计算重复率,以两位小数的浮点数写入答案文件。
拆解为 5 个可独立实现的步骤:
- 校验命令行参数:必须恰好 3 个且非空,在接触文件系统前拦下非法调用
- 读取文件:按 UTF-8 读取两个输入文件,兼容带 BOM 的 UTF-8 / UTF-16 编码
- 文本预处理:清洗文本(全角转半角、英文统一小写、去标点空白),再切分为可比较的特征
- 计算重复率:用 SimHash 文本指纹 + 汉明距离算出 0~1 的重复率
- 输出答案:格式化保留两位小数,写入答案文件
2.2 类与函数设计
共 4 个功能类 + 3 个异常类,包结构 cn.itcogon:
| 类 | 主要函数 | 作用 |
|---|---|---|
Main |
main、checkArgs、formatRate |
程序入口:校验参数、调度"读→算→写"全流程,统一捕获异常 |
FileUtil |
readFile、writeFile、decode |
UTF-8 读写文件;读取时先做 BOM 检测,兼容意外编码 |
TextProcessor |
clean、segment、toBigram |
文本清洗、按单字分词(辅助)、生成 bigram 特征(核心) |
SimilarityCalculator |
calculate、overlapRatio、simhash、hash64 |
核心算法:SimHash 指纹、重合度判定、汉明距离求相似度 |
exception.PaperCheckException |
— | 自定义异常基类,把业务异常统一归为一个类型 |
exception.ArgumentException |
— | 命令行参数非法时抛出 |
exception.FileProcessException |
— | 文件不存在、读写失败时抛出 |
调用关系:Main 依次调用 FileUtil(读写)和 SimilarityCalculator(计算);SimilarityCalculator 依赖 TextProcessor 生成特征;异常类被 Main 与 FileUtil 引用,入口只捕获一次、统一处理。
2.3 流程图
主流程:
相似度计算(SimHash)流程:
2.4 算法的关键
核心算法:SimHash(64 位文本指纹)+ 汉明距离,特征采用字符 bigram。具体四步:
- 清洗:正则式遍历把全角字符转半角、英文转小写、标点空白替换为空格。保证"天气晴"和"天气晴!"不会因标点被当成两个不同的文本。
- 特征切分:中文按相邻两字切一个特征("天气晴朗"→"天气","气晴","晴朗"),连续英文单词、数字整块保留。相比按单字切,bigram 对"逐字插入干扰字"的抄袭方式敏感得多——实测同一份样例,单字特征给出的重复率是 1.00(干扰字每个只出现一次、权重太小,指纹几乎不变),换成 bigram 后降到 0.91,能真实反映改动量。
- 指纹生成:每个特征用自实现的 FNV-1a 64 位哈希映射到 64 个维度,按出现频率加权累加(位为 1 加权重、为 0 减权重),最后每个维度取符号拼成 64 位指纹。指纹与特征出现的先后顺序无关,因此对句子乱序的抄袭版几乎不受影响。
- 相似度:两篇指纹异或后统计 1 的个数即汉明距离,
相似度 = 1 - 距离 / 64。
独到之处:
- 重合度前置判定:SimHash 的固有缺陷是"完全无关的两篇文本指纹平均只有一半位不同,相似度会落在 0.5 左右"。为此在算指纹前先求两篇特征集合的 Jaccard 重合度,低于 0.2 直接判 0.00(实测完全不同文本输出 0.00),补上了这个短板。
- 零第三方依赖:分词、哈希(FNV-1a)、指纹全部手写,不引入任何词典或分词库,
main.jar不依赖外部包,在任何 JDK 8+ 环境都能直接运行。 - 线性复杂度:全文只做两遍扫描(一遍清洗切分、一遍统计频率),对万级字符的论文文本计算耗时在毫秒级,满足评测的 5 秒限制。
- 边界语义明确:两篇都空视为完全相同(1.00),仅一篇为空视为完全不同(0.00)。
三、计算模块接口部分的性能改进
3.1 改进耗时
| 阶段 | 耗时 |
|---|---|
| 安装 JProfiler 并与 IDEA 集成 | 约 20 分钟 |
| 构造大文本、第一次采样、查看 Hot Spots | 约 15 分钟 |
| 改代码(预设容量、容斥原理、逐字符小写) | 约 20 分钟 |
| 再跑一次性能对比 + 回归测试 | 约 15 分钟 |
| 合计 | 约 70 分钟 |
3.2 分析环境与方法
使用 JProfiler 16.2.1(采样模式,采样间隔 5 ms)对计算模块进行性能分析。为了让采样数据充分,构造了 300 倍于样例的大文本(原文约 8.9 MB、抄袭版约 10.5 MB),并编写 PerfMain 入口循环执行 20 次查重算法,取稳定运行阶段的数据。
3.3 性能分析结果
CPU 调用树如下,SimilarityCalculator.calculate 占据了 99.5% 的 CPU 时间(12,264 ms / 12,329 ms),是绝对的性能瓶颈:

进一步展开热点方法表(按自身时间排序),前四名全部与集合操作相关:

| 排名 | 方法 | 自身时间 | 占比 | 调用来源 |
|---|---|---|---|---|
| 1 | java.util.HashMap.put |
2,655 ms | 21% | countFrequency(SimHash 词频统计) |
| 2 | TextProcessor.toBigram |
2,418 ms | 19% | calculate |
| 3 | java.util.HashSet.<init> |
2,413 ms | 19% | overlapRatio(Jaccard 重合度) |
| 4 | java.util.ArrayList.add |
2,404 ms | 19% | toBigram 内部 |
四个热点合计占 78%,本质上是同一个问题:在 300 万元素量级下,ArrayList / HashMap / HashSet 反复动态扩容,每次扩容都要拷贝整个底层数组。此外 String.toLowerCase(5%)和 StringBuilder.setLength(6%)也有一定开销。
3.4 改进思路与具体措施
针对上述热点,做了三项优化:
措施一:预设集合初始容量,消除动态扩容
toBigram中new ArrayList<>()→new ArrayList<>(cleaned.length()):bigram 数量不超过字符数,一次性分配到位。countFrequency中new HashMap<>()→new HashMap<>((int)(grams.size() / 0.75) + 1):按加载因子 0.75 精确计算所需容量。
这里有一个值得记录的细节:最初误写成
grams.size() * 2,结果 HashMap 会把容量向上取到最近的 2 的幂(8,388,608),分配了过大的 table 数组,反而因内存占用和 CPU 缓存失效导致性能下降 6%。修正为/0.75后才达到预期效果。这说明预设容量不是越大越好,必须匹配加载因子。
措施二:用容斥原理直接计算并集,省去一个 HashSet
原 overlapRatio 中构建了三个 HashSet(setA、setB、union),其中 union = new HashSet<>(setA); union.addAll(setB) 会再做一次全量遍历和构建。改进后用容斥原理 |A∪B| = |A| + |B| - |A∩B| 直接算出并集大小,省去第三个 HashSet 的构建和一次 300 万元素的遍历。
措施三:逐字符转小写,省去整串 toLowerCase
原 clean 方法最后对整串清洗结果调用 toString().toLowerCase(),对 300 万字符的字符串做一次全量转换。改进后在遍历字符时对英文字符直接调用 Character.toLowerCase(c),省去最后一次大字符串操作。
3.5 改进前后对比
在同等预热状态下(文件缓存已热、JIT 已预热),用 PerfMain 循环 20 次测得:
| 指标 | 改进前 | 改进后 | 变化 |
|---|---|---|---|
| 20 次总耗时 | 10,579 ms | 8,300 ms | -21.5% |
| 单次平均耗时 | 528 ms | 415 ms | -113 ms |
改进后单次查重(300 倍大文本,约 300 万字符)仅需 415 ms,远低于评测要求的 5 秒上限。三项优化中,预设集合容量贡献最大,容斥原理次之,逐字符转小写主要降低了内存分配压力。
四、计算模块部分单元测试展示
使用 JUnit 4 + JaCoCo,共 41 个测试用例,分 4 个测试类。
4.1 测试代码
分词与特征部分(测 TextProcessor 的 bigram 特征,这是算法输入质量的关键):
@Test
public void testToBigramChinese() {
List<String> grams = TextProcessor.toBigram("天气晴朗");
assertEquals(3, grams.size());
assertEquals("天气", grams.get(0));
assertEquals("气晴", grams.get(1));
assertEquals("晴朗", grams.get(2));
}
@Test
public void testToBigramPunctuationBreaks() {
// 标点会断开 bigram,逗号两边不组合
List<String> grams = TextProcessor.toBigram("今天,天气");
assertTrue(grams.contains("今天"));
assertTrue(grams.contains("天气"));
assertTrue(!grams.contains("天天"));
}
@Test
public void testToBigramEmptyAndNull() {
assertTrue(TextProcessor.toBigram("").isEmpty());
assertTrue(TextProcessor.toBigram(null).isEmpty());
}
相似度部分(测 SimilarityCalculator.calculate 在各篡改方式下的输出):
@Test
public void testSampleOfRequirement() {
String original = "今天是星期天,天气晴,今天晚上我要去看电影。";
String copy = "今天是周天,天气晴朗,我晚上要去看电影。";
double rate = SimilarityCalculator.calculate(original, copy);
assertTrue("同义改写后的重复率应大于0.5,实际为" + rate, rate > 0.5);
}
@Test
public void testAddNoiseChars() {
// 逐字插入干扰字(add 型抄袭)
String original = "一位真正的作家永远只为内心写作,只有内心才会真实地告诉他。";
String copy = "一位真正丽的作家永远医只为内心写腥作,只怖有惠内陆心才会真实地告诉他。";
double rate = SimilarityCalculator.calculate(original, copy);
assertTrue("插入干扰字后重复率应大于0.7,实际为" + rate, rate > 0.7);
}
@Test
public void testShuffledOrder() {
// 句子乱序:SimHash 指纹与顺序无关
String original = "第一句。第二句。第三句。第四句。第五句。第六句。第七句。第八句。";
String copy = "第五句。第二句。第八句。第一句。第四句。第七句。第三句。第六句。";
double rate = SimilarityCalculator.calculate(original, copy);
assertTrue("乱序后重复率应大于0.8,实际为" + rate, rate > 0.8);
}
端到端(测 Main.main,顺便验证答案文件格式):
@Test
public void testMainEndToEnd() throws Exception {
Path original = tempFolder.newFile("orig.txt").toPath();
Path copy = tempFolder.newFile("copy.txt").toPath();
Path answer = tempFolder.getRoot().toPath().resolve("ans.txt");
Files.write(original, "今天是星期天,天气晴,今天晚上我要去看电影。"
.getBytes(StandardCharsets.UTF_8));
Files.write(copy, "今天是周天,天气晴朗,我晚上要去看电影。"
.getBytes(StandardCharsets.UTF_8));
Main.main(new String[]{original.toString(), copy.toString(), answer.toString()});
assertTrue("答案文件应被创建", Files.exists(answer));
String result = new String(Files.readAllBytes(answer), StandardCharsets.UTF_8);
assertTrue("答案格式应为两位小数,实际为:" + result,
result.matches("0\\.\\d{2}|1\\.00"));
}
4.2 测试函数与构造思路
| 被测函数 | 用例数 | 数据怎么凑的 |
|---|---|---|
TextProcessor.clean |
4 | 对着清洗的每个分支凑:全角转半角、英文小写、标点变空格、null 入参 |
TextProcessor.segment / toBigram |
7 | 中文正常切分、英文数字整块、标点断开 bigram、空串与 null 提前返回 |
SimilarityCalculator.calculate |
11 | 按方法的判定分支:完全相同(=1)、完全不同(=0)、两边都空(=1)、一边空(=0),再叠加四种篡改方式——同义改写、插入干扰字、删减、乱序,外加对称性、结果越界钳制、单字差异 |
FileUtil.readFile / writeFile |
11 | 读写往返、UTF-8 中文不乱码、父目录自动创建、文件不存在、路径是文件夹、空路径、null、UTF-8 BOM、UTF-16 BOM |
Main.checkArgs / formatRate / main |
8 | 参数个数 1/2/3/0、null、空串、合法参数、格式化 0.856/0.8/1.0/0.0、端到端两次(含嵌套目录与文件不存在) |
思路是白盒法:先读源码把每个 if 分支列出来,一个分支给一组数据,保证每条路径都被走到;再补一层边界(空文件、null、越界值),最后用端到端用例验证整条主流程和输出格式。41 个用例覆盖了 4 个核心类的所有公开方法。
4.3 测试覆盖率
使用 IDEA 内置的 Coverage 工具(基于 JaCoCo)对全部 41 个测试用例运行覆盖率统计,结果如下:

整体行覆盖率 87%(138/157)、分支覆盖率 83%(115/138)。各核心类的覆盖率均处于较高水平:
| 类 | 行覆盖率 | 分支覆盖率 | 说明 |
|---|---|---|---|
TextProcessor |
98% (54/55) | 88% (48/54) | 未覆盖行为空文本/null 的提前返回分支 |
SimilarityCalculator |
95% (47/49) | 91% (33/36) | 未覆盖分支为结果越界钳制的边界情况 |
Main |
100% (12/12) | 90% (9/10) | 全部行覆盖,仅剩一个异常分支未触发 |
FileUtil |
88% (22/25) | 69% (25/36) | 未覆盖部分为真实环境中难以稳定复现的 IO 异常分支 |
PerfMain |
0% (0/11) | 0% (0/2) | 性能分析专用入口,不参与评测,无对应单元测试 |
未覆盖的指令主要集中在 FileUtil 的 IO 异常分支(写入失败、权限不足等真实环境难以稳定复现的场景)和异常类的部分构造方法,属于可接受的测试盲区。
五、计算模块部分异常处理说明
程序共定义 3 个异常类,都继承自 RuntimeException,位于 cn.itcogon.exception 包。统一由 Main 入口捕获一次、打印错误信息后正常退出,不向 JVM 抛出堆栈(评测规则要求程序不能"异常退出")。
5.1 PaperCheckException(异常基类)
- 设计目标:作为所有业务异常的公共父类型,让入口只需 catch 这一次。以后需要新增异常类型时继承它即可,
Main的捕获逻辑不用改动。 - 对应场景:不会直接被抛出,它是下面两个异常的共同类型。
5.2 ArgumentException(命令行参数异常)
- 设计目标:在接触文件系统之前拦截非法调用——参数个数不是 3 个、参数为 null 或空字符串时抛出,避免把"参数没传对"误报成"文件不存在"。
- 对应场景:
java -jar main.jar C:\tests\orig.txt(只传了 1 个参数,缺少抄袭版和答案路径)。 - 测试:
@Test
public void testCheckArgsWrongCount() {
assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[]{"a.txt"}));
assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[]{"a.txt", "b.txt"}));
assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[0]));
}
@Test
public void testCheckArgsNull() {
assertThrows(ArgumentException.class, () -> Main.checkArgs(null));
}
@Test
public void testCheckArgsEmptyString() {
assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[]{"", "b.txt", "c.txt"}));
}
5.3 FileProcessException(文件读写异常)
- 设计目标:文件相关的所有错误统一走这个类型——文件不存在、路径指向文件夹、路径为空、读取或写入失败。异常信息中带上具体路径,方便定位是哪个文件出了问题。
- 对应场景:原文路径写错,比如实际文件叫
orig.txt却传成了org.txt。 - 测试:
@Test(expected = FileProcessException.class)
public void testReadNotExistFile() {
FileUtil.readFile(tempFolder.getRoot().getAbsolutePath() + "\\no-such-file.txt");
}
@Test(expected = FileProcessException.class)
public void testReadDirectoryAsFile() {
FileUtil.readFile(tempFolder.getRoot().getAbsolutePath());
}
@Test(expected = FileProcessException.class)
public void testReadEmptyPath() {
FileUtil.readFile("");
}
5.4 入口统一处理
Main.main 中对整个流程包一层 try-catch:任何异常只打印提示信息到 stderr,程序正常返回。对应测试用"文件不存在"的端到端场景验证程序不会把异常传播到外层:
@Test
public void testMainFileNotExist() {
// main 内部已捕获异常,这里验证调用不会把异常抛到外层
Main.main(new String[]{
tempFolder.getRoot().getAbsolutePath() + "\\no-such.txt",
tempFolder.getRoot().getAbsolutePath() + "\\no-such2.txt",
tempFolder.getRoot().getAbsolutePath() + "\\ans.txt"
});
// 到达这里说明 main 正常返回,没有异常传播
assertTrue(true);
}
浙公网安备 33010602011771号