第一次个人编程作业——论文查重项目

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%,但仍是最大消耗函数。进一步可考虑使用 fastutilInt2IntOpenHashMap 避免自动装箱,但当前性能已满足需求。

指标 优化前 优化后 变化
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 编解码测试用例。

改进后覆盖率:

屏幕截图 2026-09-15 212158

项目整体行覆盖率提升至约 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);   // 宽容版只保证不抛异常
}
posted @ 2026-09-15 21:59  憩烟  阅读(6)  评论(0)    收藏  举报