第一次个人编程作业——论文查重项目
GitHub 仓库地址: https://github.com/qiyan5957/3124004077/tree/main
学号: 3124004077
项目介绍:
本项目实现了一个基于中文 2-gram + 余弦相似度的论文查重工具,支持自动识别 UTF-8 / GBK / UTF-16 编码、HTML 标签清洗、命令行传参运行,配套 JUnit 单元测试与 IntelliJ Profiler 性能分析。
一、PSP 记录
1.1 预估耗时
| PSP2.1 | Personal Software Process Stages | 预估耗时(min) |
|---|---|---|
| Planning | 计划 | 30 |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | 400 |
| · Analysis | · 需求分析(包括学习新技术) | 50 |
| · Design Spec | · 生成设计文档 | 30 |
| · Design Review | · 设计复审 | 20 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 50 |
| · Coding | · 具体编码 | 150 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试、修改代码) | 60 |
| Reporting | 报告 | 90 |
| · Test Report | · 测试报告 | 40 |
| · Size Measurement | · 计算工作量 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 520 |
1.2 实际耗时
| PSP2.1 | Personal Software Process Stages | 实际耗时(min) |
|---|---|---|
| Planning | 计划 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 25 |
| Development | 开发 | 370 |
| · Analysis | · 需求分析(包括学习新技术) | 45 |
| · Design Spec | · 生成设计文档 | 25 |
| · Design Review | · 设计复审 | 15 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 40 |
| · Coding | · 具体编码 | 130 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试、修改代码) | 75 |
| Reporting | 报告 | 85 |
| · Test Report | · 测试报告 | 35 |
| · Size Measurement | · 计算工作量 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 480 |
二、计算模块接口的设计与实现
2.1 代码组织
项目采用面向对象设计,共 5 个核心类,职责分明,互不依赖对方内部实现:
EssayCheck/
├── src/
│ └── com/essaycheck/
│ ├── Main.java // 命令行入口,参数解析与流程串联
│ ├── FileIO.java // 文件读取与写入,异常统一封装
│ ├── EncodingDetector.java // 自动识别 UTF-8 / GBK / UTF-16 编码
│ ├── TextCleaner.java // HTML 标签清洗 + 仅保留中文字符
│ └── Similarity.java // 核心算法:2-gram + 余弦相似度
└── test/
└── com/essaycheck/
├── TextCleanerTest.java // 文本清洗模块测试
├── EncodingDetectorTest.java // 编码识别模块测试
├── SimilarityTest.java // 相似度计算模块测试
└── ExceptionTest.java // 异常处理测试
2.2 模块与函数接口
| 所属文件 | 模块 / 函数 | 输入 | 输出 | 主要功能 |
|---|---|---|---|---|
| Main.java | main(String[] args) | 3 个文件绝对路径 | 进程退出码 | 校验参数数量,依次调用读取、清洗、查重、写结果 |
| FileIO.java | readText(Path path) | 文件路径 | 文本字符串 | 读取文件字节,交给 EncodingDetector 解码,异常统一封装为 IOException |
| FileIO.java | writeText(Path path, String content) | 输出路径、内容 | 无 | 以 UTF-8 写入答案文件 |
| EncodingDetector.java | decode(byte[] bytes) | 字节数组 | 解码后的字符串 | 宽容版解码,任何情况都不抛异常 |
| EncodingDetector.java | decodeStrict(byte[] bytes) | 字节数组 | 解码后的字符串 | 严格版解码,无法识别时抛 UnsupportedEncodingException |
| TextCleaner.java | clean(String text) | 原始文本 | 清洗后的纯中文 | HTML 洗入 + 只保留中文字符 |
| Similarity.java | cosineSimilarity(String, String) | 原文、抄袭文本 | [0,1] 浮点数 | 计算模块主接口:预处理 → n-gram 切分 → 余弦相似度 |
| Similarity.java | buildNgramVector(String) | 清洗后文本 | Map<Integer,Integer> | 用 int 编码二元组构建词频向量 |
类之间关系:Main 依次调用 FileIO 读取文件,EncodingDetector 负责解码,TextCleaner 清洗文本,最后 Similarity 计算重复率。模块之间通过方法参数和返回值通信,低耦合。
关键函数调用流程:
Main.main
├── FileIO.readText
│ └── EncodingDetector.decodeStrict
│ └── strictDecode(UTF-8) / strictDecode(GBK)
├── TextCleaner.clean
│ └── SCRIPT_STYLE / HTML_COMMENT / HTML_TAG 正则替换
└── Similarity.cosineSimilarity
├── buildNgramVector(orig)
├── buildNgramVector(copy)
└── 点积 / 模长 → 余弦相似度
2.3 算法关键与独到之处
- 中文 2-gram 切分:无需分词,对增删改敏感,鲁棒性强。相比单字匹配能保留局部语义,相比整句匹配又不会因个别字改动而失效。
- 余弦相似度:将文本映射为 n-gram 词频向量,计算夹角余弦,值域 [0,1],不受文本长度差异影响。
- 性能优化:使用
int编码二元组(c1 << 16) | c2替代String.substring,避免创建大量临时对象;HashMap<Integer,Integer>减少哈希和内存开销。 - 编码自适应:自动识别 UTF-8 BOM、UTF-16 BE/LE、GBK,保证各种来源文件均可正确读取,避免中文乱码。
- HTML 清洗:去除
<script>、<style>、注释、标签,并解码常见 HTML 实体,只保留中文字符,兼容网页爬取的论文数据。 - 异常分层设计:宽容版
decode用于生产路径保证不崩溃,严格版decodeStrict用于测试和诊断,兼顾健壮性与可测试性。
三、计算模块接口部分的性能改进
3.1 性能瓶颈定位
使用 IntelliJ IDEA Ultimate 自带的 Profiler(底层集成 Async Profiler)对 Similarity.cosineSimilarity 进行 CPU 采样。火焰图显示 buildNgramVector 占用 CPU 时间最多,达 45.86%,绝对耗时 122 ms(5000 次循环)。

3.2 改进思路
- 消除 String 键:将
String.substring生成的二元组改为int编码键(c1 << 16) | c2,减少对象创建和 GC 压力。 - 优化 HashMap 初始容量:根据文本长度预估容量
(int)(len * 1.4),减少 rehash。 - 提前返回:两文本完全相同时直接返回 1.0,跳过全部计算。
- 文本清洗优化:
TextCleaner.clean中中文过滤由正则Matcher.find()改为单次字符遍历for + charAt,避免正则引擎逐字符开销。
3.3 改进耗时
本次性能改进共花费约 90 分钟:
- Profiler 数据采集与火焰图分析:40 分钟
Similarity.buildNgramVector重构(String 键 → int 键):20 分钟TextCleaner.clean中文过滤重构(正则 → 单次遍历):15 分钟- 优化前后性能对比验证:15 分钟
3.4 优化效果
优化后再次采样,buildNgramVector 绝对耗时降至 88 ms,降幅 27.8%。火焰图中该方法占比降至 44.44%,但仍是最大消耗函数。进一步可考虑使用 fastutil 的 Int2IntOpenHashMap 避免自动装箱,但当前性能已满足需求。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| buildNgramVector 绝对耗时 | 122 ms | 88 ms | -27.8% |
| buildNgramVector CPU 占比 | 45.86% | 44.44% | -1.42% |
| TextCleaner.clean 占比 | 高 | 显著下降 | - |

四、计算模块部分单元测试展示
4.1 测试组织
使用 JUnit 4 编写单元测试,共 28 个用例,覆盖正常路径、边界条件和异常路径。
4.2 部分测试代码
SimilarityTest.java 片段:
@Test
public void testSameText() {
double score = Similarity.cosineSimilarity("今天天气真好", "今天天气真好");
assertEquals(1.0, score, 0.01);
}
@Test
public void testEmptyStrings() {
assertEquals(0.0, Similarity.cosineSimilarity("", "你好"), 0.01);
assertEquals(0.0, Similarity.cosineSimilarity("你好", ""), 0.01);
}
@Test(expected = IllegalArgumentException.class)
public void testNullThrows() {
Similarity.cosineSimilarity(null, "你好");
}
EncodingDetectorTest.java 片段:
@Test
public void testUtf8Bom() {
byte[] content = "你好".getBytes(StandardCharsets.UTF_8);
byte[] data = new byte[3 + content.length];
data[0] = (byte) 0xEF; data[1] = (byte) 0xBB; data[2] = (byte) 0xBF;
System.arraycopy(content, 0, data, 3, content.length);
assertEquals("你好", EncodingDetector.decode(data));
}
4.3 构造测试数据的思路
- 正常数据:选取典型中文句子、带 HTML 标签的文本,验证主路径正确性。
- 边界数据:空字符串、单字符、纯空白,覆盖长度不足 N 和空输入分支。
- 异常数据:
null、不存在的文件、非法字节流,验证异常处理逻辑。 - 编码覆盖:分别构造 UTF-8(带/不带 BOM)、GBK 字节数组,验证编码识别分支。
4.4 覆盖率截图

改进前覆盖率数据分析:
项目整体行覆盖率为 57%,分支覆盖率为 57%,类覆盖率为 71%。
其中 Similarity(行 72%)、TextCleaner(行 91%)、EncodingDetector(行 60%)作为核心算法模块,核心逻辑与关键分支已被测试覆盖。
整体数值偏低的原因:
- Main 类作为命令行入口,其逻辑已通过真实的命令行集成测试(java -jar main.jar ...)验证,未计入单元测试范围。
- PerformanceTest 为性能分析专用的辅助类,非 JUnit 测试目标。
- 排除上述非测试目标后,核心业务模块的平均行覆盖率约为 75%+。
改进方案:
补充 FileIO 模块的读写测试,以及 EncodingDetector 的 UTF-16 编解码测试用例。
改进后覆盖率:

项目整体行覆盖率提升至约 70%+,其中 FileIO 从 33% 提升至 85%+,EncodingDetector 从 60% 提升至 90%+,核心模块的测试覆盖已满足要求。
总测试结果:


五、计算模块部分异常处理说明
5.1 异常设计目标
| 异常类型 | 设计目标 | 单元测试样例 | 对应场景 |
|---|---|---|---|
IllegalArgumentException |
防止调用方传入 null 导致后续 NPE |
testNullThrows |
相似度计算接口收到 null 文本 |
IOException |
统一封装文件不存在、读取失败、编码异常等 IO 问题 | testFileNotFound |
命令行传入的文件路径不存在 |
UnsupportedEncodingException |
当字节流无法识别为 UTF-8 / UTF-16 / GBK 时,明确指出编码错误 | testInvalidEncoding |
文件被篡改或使用了未知编码 |
5.2 异常处理测试代码
ExceptionTest.java 片段:
@Test(expected = IllegalArgumentException.class)
public void testNullTextThrows() {
Similarity.cosineSimilarity(null, "你好");
}
@Test
public void testFileNotFound() {
try {
FileIO.readText(Paths.get("不存在的文件.txt"));
fail("应该抛出 IOException");
} catch (IOException e) {
assertTrue(e.getMessage().contains("文件不存在"));
}
}
@Test
public void testInvalidEncoding() {
byte[] invalid = {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF};
try {
EncodingDetector.decodeStrict(invalid);
fail("应该抛出 UnsupportedEncodingException");
} catch (UnsupportedEncodingException e) {
assertTrue(e.getMessage().contains("无法识别"));
}
}
5.3 容错兜底
主程序使用宽容版 EncodingDetector.decode,即使遇到非法编码也会用 UTF-8 替换字符兜底,保证程序不崩溃并输出答案文件。
@Test
public void testLenientDecodeNeverThrows() {
byte[] invalid = {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF};
String result = EncodingDetector.decode(invalid);
assertNotNull(result); // 宽容版只保证不抛异常
}
浙公网安备 33010602011771号