第一次个人编码作业
个人项目——论文查重
GitHub 项目地址:https://github.com/2553a/2253a/tree/main/3124004489
一、PSP 表格
在正式开始项目之前,我先根据需求对整个开发过程进行了拆分,并对各阶段需要花费的时间进行了预估。在项目完成之后,再根据实际开发过程记录各阶段的实际耗时。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 35 |
| Estimate | 估计这个任务需要多少时间 | 20 | 20 |
| Development | 开发 | 600 | 720 |
| Analysis | 需求分析(包括学习新技术) | 60 | 80 |
| Design Spec | 生成设计文档 | 40 | 45 |
| Design Review | 设计复审 | 20 | 25 |
| Coding Standard | 代码规范 | 20 | 25 |
| Design | 具体设计 | 60 | 75 |
| Coding | 具体编码 | 240 | 260 |
| Code Review | 代码复审 | 40 | 55 |
| Test | 测试(自我测试、修改代码、提交修改) | 120 | 155 |
| Reporting | 报告 | 180 | 220 |
| Test Report | 测试报告 | 60 | 75 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan | 事后总结并提出过程改进计划 | 40 | 45 |
| 合计 | 1550 | 1835 |
PSP 中的实际耗时根据本次开发过程进行整理。相比最初预估,实际耗时更多地集中在测试、代码检查、环境配置和报告整理等环节。
二、需求分析
本次个人项目的题目为论文查重。
程序需要接收三个命令行参数:
- 原文文件的绝对路径;
- 抄袭版论文文件的绝对路径;
- 答案文件的绝对路径。
Java 程序最终按照以下形式运行:
java -jar main.jar [原文文件] [抄袭版论文文件] [答案文件]
例如:
java -jar main.jar C:\tests\orig.txt C:\tests\orig_add.txt C:\tests\ans.txt
程序读取前两个文件,计算两篇文章之间的相似度,并将结果写入第三个参数指定的答案文件。
答案为 [0, 1] 范围内的浮点数,并保留小数点后两位,例如:
0.90
除了实现基本的论文相似度计算之外,还需要考虑以下问题:
- 中文、英文和数字等不同字符;
- 英文字母大小写差异;
- 标点符号和空白字符;
- 空文本;
- 文件不存在;
- 输入路径错误;
- 参数数量错误;
- 输出文件无法写入;
- 大规模文本下的运行效率;
- 程序运行时间不能超过 5 秒;
- 程序不能访问网络;
- 程序不能读写题目要求之外的其他文件。
三、计算模块接口的设计与实现
3.1 项目整体结构
本项目使用 Java 17 + Maven 进行开发。
核心代码没有全部集中在 main() 方法中,而是按照不同职责拆分为多个模块。
主要结构如下:
3124004489
├── src
│ ├── main
│ │ └── java
│ │ └── com
│ │ └── eso
│ │ └── plagiarism
│ │ ├── Main.java
│ │ ├── FileService.java
│ │ ├── TextPreprocessor.java
│ │ └── SimilarityCalculator.java
│ └── test
│ └── java
│ └── com
│ └── eso
│ └── plagiarism
│ └── ...
├── test-data
│ ├── orig.txt
│ └── orig_0.8_add.txt
├── pom.xml
├── README.md
└── BLOG.md
整个程序可以分为四个主要部分:
| 模块 | 主要职责 |
|---|---|
Main |
接收参数并组织整个程序执行流程 |
FileService |
文件读取和结果写入 |
TextPreprocessor |
对原始文本进行规范化 |
SimilarityCalculator |
构造特征并计算论文相似度 |
这样的设计使文件处理、文本处理和算法计算之间保持相对独立,方便进行单元测试和后续维护。
3.2 Main
Main 是整个程序的入口。
主要负责:
- 接收命令行参数;
- 判断参数数量是否合法;
- 调用文件读取模块;
- 调用相似度计算模块;
- 将最终结果格式化为两位小数;
- 写入指定答案文件;
- 对程序执行过程中的异常进行统一处理。
程序整体执行过程如下:

启动程序
↓
检查命令行参数
↓
读取原文和抄袭版论文
↓
文本预处理
↓
构造 Character 2-Gram
↓
建立频次向量
↓
计算 Cosine Similarity
↓
限制结果到 [0, 1]
↓
格式化为两位小数
↓
写入答案文件
Main 本身不负责具体算法实现,而主要承担不同模块之间的协调工作。
3.3 FileService
FileService 负责程序中的文件输入和输出。
主要功能包括:
- 根据命令行参数读取 UTF-8 文本;
- 检查输入文件是否存在;
- 检查输入路径是否为普通文件;
- 将最终相似度写入答案文件;
- 对文件读取和写入过程中产生的异常进行处理。
将文件操作独立出来之后,相似度算法不需要关心论文来自哪个文件,只需要处理传入的字符串。
这样既降低了模块之间的耦合,也方便分别对文件模块和算法模块进行测试。
3.4 TextPreprocessor
实际论文中可能存在大量与文本内容关系不大的差异,例如:
- 空格不同;
- 换行不同;
- 中英文标点不同;
- 英文字母大小写不同。
如果这些内容直接参与比较,会对最终结果产生干扰。
因此在计算相似度之前,首先对文本进行统一预处理。
处理流程为:
Unicode 文本规范化
↓
去除空白、标点和符号
↓
保留中文、英文和数字
↓
英文字母统一转换为小写
例如:
Hello,今天 天气很好!
经过规范化以后,标点、空格以及大小写等因素对结果的影响会被降低。
这样可以让后续算法更加关注文章本身的字符内容。
3.5 SimilarityCalculator
SimilarityCalculator 是本项目最核心的计算模块。
最终采用的算法流程为:
规范化文本
↓
Character 2-Gram
↓
Map<String, Integer> 频次向量
↓
Cosine Similarity
↓
限制结果到 [0, 1]
其中主要分为两个阶段:
- 将论文转换成 Character 2-Gram 频次向量;
- 使用余弦相似度比较两个向量。
3.6 Character 2-Gram
对于经过预处理的字符串,按照连续两个字符构造 2-Gram。
例如:
今天天气很好
可以得到:
今天
天天
天气
气很
很好
然后使用 Map<String, Integer> 统计每一个 2-Gram 出现的次数。
例如:
今天 -> 1
天天 -> 1
天气 -> 1
气很 -> 1
很好 -> 1
最终,一篇文章就可以被转换成一个由大量局部字符片段组成的频次向量。
3.7 为什么选择 Character 2-Gram
论文的抄袭版本通常不会和原文完全相同,而可能进行:
- 增加部分文字;
- 删除部分文字;
- 修改部分词语;
- 调整句子;
- 修改标点符号。
如果直接比较整个字符串,只要中间发生一些修改,字符串整体就会产生较大差异。
Character N-Gram 则会将文章拆成大量局部片段。
即使文章的一部分发生变化,其他没有发生变化的局部结构仍然能够被保留下来,因此更适合本次增删改后的论文相似度计算。
另外,Character N-Gram 不依赖额外的中文分词库,能够直接处理中文、英文和数字。
综合算法复杂度、实现难度和项目需求,本项目最终采用 Character 2-Gram。
3.8 余弦相似度
得到两篇文章的 2-Gram 频次向量以后,使用余弦相似度计算两个向量之间的相似程度。
公式为:
$$
similarity(A,B)=
\frac{A\cdot B}
{|A||B|}
$$
其中:
- $A \cdot B$ 表示两个向量的点积;
- $|A|$ 表示向量 A 的模;
- $|B|$ 表示向量 B 的模。
余弦相似度越接近 1,说明两篇文章的字符结构越相似。
程序最终还会保证计算结果位于 [0, 1] 范围内。
输出时统一保留两位小数。
3.9 特殊情况处理
除了普通论文之外,程序还需要正确处理一些边界输入。
两篇文本完全相同
如果规范化之后的文本完全相同,可以直接返回:
1.00
这样既符合预期,也避免继续进行不必要的向量计算。
空文本
如果规范化之后文本为空,需要进行特殊处理,避免在余弦相似度计算过程中出现除零。
单字符文本
Character 2-Gram 至少需要两个字符。
因此对于只有一个字符的文本,需要提前进行判断。
例如两个完全相同的单字符文本应该得到:
1.00
不同的单字符文本则得到:
0.00
这样可以避免数组越界或错误的相似度结果。
四、算法复杂度分析
设两篇论文规范化之后的长度分别为 n 和 m。
首先需要分别遍历两篇文章进行文本规范化,因此时间复杂度为:
O(n + m)
Character 2-Gram 的构建同样采用顺序扫描。
对于每个 2-Gram,将其加入 HashMap 中并更新出现次数。
HashMap 在平均情况下插入和查询操作可以看作 O(1)。
余弦相似度计算只需要遍历已经生成的频次向量。
因此整个算法的平均时间复杂度为:
O(n + m)
空间消耗主要来自:
- 规范化后的文本;
- 原文的 N-Gram Map;
- 抄袭版论文的 N-Gram Map。
因此平均空间复杂度同样为:
O(n + m)
从复杂度上看,该算法能够较好地处理规模较大的文本。
五、计算模块性能分析与改进
5.1 性能分析思路
本项目的主要计算流程为:
读取文件
↓
文本规范化
↓
构建 Character 2-Gram
↓
统计 HashMap 频次
↓
计算向量点积和模长
↓
得到余弦相似度
由于整个算法没有嵌套遍历两篇完整文章,而是尽量采用线性扫描,因此理论上的平均时间复杂度为 O(n + m)。
在实现完成之后,我进一步使用不同规模的输入文本对程序进行了运行测试,检查程序在较大文本情况下是否仍然能够在合理时间内完成。
5.2 实际运行性能
使用老师提供的有效样例进行最终 JAR 测试时,一次实际运行结果约为:
250.42 ms
程序输出:
0.90
退出码:
0
之后又使用约 110 万字符级中文文本进行性能冒烟测试。
实际运行时间约为:
357 ms
程序正常完成计算,输出:
0.86
两种情况下程序运行时间均明显低于题目要求的:
5 秒
说明目前算法在测试规模下能够满足作业的性能要求。
5.3 性能改进
在实现过程中,我主要从算法和数据结构两个方面进行了优化。
1. 使用线性扫描处理文本
文本规范化和 Character 2-Gram 构建均采用顺序遍历。
没有使用两篇文章之间逐字符两两比较的方式,因此避免出现 O(n × m) 的高复杂度。
2. 使用 HashMap 保存频次
使用:
Map<String, Integer>
保存 Character 2-Gram 的出现次数。
HashMap 在平均情况下能够提供接近 O(1) 的查询和更新效率,因此能够较高效地建立文章的特征向量。
3. 提前估计 Map 容量
由于文章长度能够大致反映 N-Gram 的数量,因此在创建 HashMap 时提前估计所需容量。
这样可以降低文本较大时 HashMap 反复扩容和重新散列带来的额外开销。
4. 优先遍历较小的频次向量
计算两个向量的点积时,没有固定遍历某一个 Map。
程序会优先选择元素数量较少的 Map 进行遍历,再到另一个 Map 中查询相同 N-Gram。
这样可以减少循环次数。
例如:
Map A:10000 个元素
Map B:7000 个元素
则优先遍历:
Map B
而不是遍历 10000 次。
5. 相同文本提前返回
如果两篇文章经过规范化以后完全相同,可以直接返回:
1.0
不再进行 N-Gram 构造和余弦相似度计算。
对于完全相同文本,这能够直接省略后续的大部分计算。
6. 空文本提前处理
对于空文本、纯标点文本等规范化之后为空的特殊输入,在进入向量计算之前进行判断。
这样既能够避免除零错误,也能够减少无意义的计算。
5.4 性能改进总结
从最终结果来看,本项目没有使用复杂的多层循环来比较两篇论文,而是通过:
文本规范化
+
Character 2-Gram
+
HashMap
+
Cosine Similarity
将主要处理过程控制在线性复杂度范围内。
同时通过 Map 容量预估、较小向量优先遍历、相同文本提前返回和特殊输入提前处理等方式进一步降低不必要的计算。
在约 110 万字符级文本的实际性能测试中,程序仍然能够在 1 秒以内完成计算,满足本次作业对性能的要求。
六、单元测试
6.1 单元测试设计
本项目使用 JUnit 对程序的核心模块进行自动化测试。
测试不仅验证正常输入,还重点覆盖:
- 边界条件;
- 特殊字符;
- 异常输入;
- 文件操作;
- 大文本;
- 增删改场景。
执行:
mvn clean test
最终实际测试结果为:
Tests run: 32
Failures: 0
Errors: 0
Skipped: 0
即:
32 / 32 全部通过

6.2 测试数据构造思路
测试用例并没有只使用老师提供的一个样例,而是根据算法中不同分支和边界情况构造测试数据。
主要测试场景如下:
| 测试场景 | 测试目的 |
|---|---|
| 完全相同文本 | 验证相似度能够达到 1 |
| 完全不同文本 | 验证明显不同文本的结果 |
| 普通中文文本 | 验证主要论文场景 |
| 英文文本 | 验证英文处理 |
| 英文大小写 | 验证大小写规范化 |
| 中英文混合 | 验证不同类型字符共同出现 |
| 数字 | 验证数字能够正常参与比较 |
| 空文本 | 验证边界输入 |
| 单侧空文本 | 验证只有一篇为空 |
| 纯标点文本 | 验证预处理为空后的处理 |
| 单字符文本 | 验证长度小于 2 的情况 |
| 两字符文本 | 验证最小合法 2-Gram |
| CRLF 与 LF | 验证不同换行符 |
| 文件不存在 | 验证文件读取异常 |
| 目录作为文件 | 验证非法输入路径 |
| UTF-8 解码异常 | 验证错误编码输入 |
| 参数数量错误 | 验证命令行参数检查 |
| 输出失败 | 验证答案文件写入异常 |
| 大文本 | 验证性能和稳定性 |
| 增删改文本 | 验证实际论文查重场景 |
测试数量超过了作业要求的至少 10 个测试用例。
6.3 测试覆盖率
本项目使用 JaCoCo 对单元测试覆盖率进行统计。
执行:
mvn clean verify
最终得到:
Line Coverage:88.10%(74 / 84)
Branch Coverage:87.50%(42 / 48)

测试覆盖率分析
为了检验单元测试对程序代码的覆盖程度,本项目使用 JaCoCo 对测试覆盖率进行统计。
运行:
mvn clean verify
从结果来看,大部分主要代码以及条件分支均已经被测试覆盖。
我认为覆盖率的意义并不只是追求 100% 的数字,而是通过测试尽可能覆盖真正重要的业务逻辑、边界条件和异常路径。
因此没有为了单纯提高覆盖率而增加大量没有实际意义的重复测试。
---
# 七、代码质量分析
本项目使用 **PMD** 对 Java 代码进行静态代码质量检查。
执行:
```bash
mvn pmd:check
实际结果为:
PMD Version:6.55.0
PMD violations:0
BUILD SUCCESS
说明当前代码没有发现 PMD 配置规则范围内的代码质量警告。
在代码结构方面,我也尽量遵循以下原则:
- 类名使用能够表达职责的名称;
- 方法名表达具体操作;
- 避免没有意义的变量名称;
- 不将所有代码堆积在
main()中; - 文件处理和算法处理分离;
- 对关键算法添加必要注释;
- 删除无用代码和调试代码。
八、异常处理
除了正常输入之外,本项目还对多种异常情况进行了处理。
异常处理的目标不是简单地让程序“不报错”,而是确保面对非法输入时能够受控结束,而不是出现数组越界、除零或未捕获异常。
8.1 参数数量错误
程序规定必须提供三个命令行参数:
原文路径
抄袭版路径
答案路径
如果用户没有提供正确数量的参数,程序不会继续进行论文计算。
例如:
java -jar main.jar
或者:
java -jar main.jar orig.txt
测试目的:
防止直接访问不存在的
args元素,同时保证程序只能按照题目规定的接口运行。
8.2 输入文件不存在
例如:
C:\not-exist\orig.txt
程序应该识别文件不存在,而不是继续执行。
测试目的:
验证用户传入错误文件路径时程序能够进行受控处理。
8.3 输入路径实际为目录
路径存在并不意味着它一定是一个合法的论文文件。
例如传入:
C:\tests\
如果这个路径实际上是目录,则不能作为论文读取。
因此程序会对文件类型进行检查。
8.4 UTF-8 解码异常
本项目按照 UTF-8 读取论文。
如果输入文件不能按照预期编码正确解析,需要对读取过程中产生的异常进行处理。
测试目的:
确保错误文件内容不会导致程序产生未处理异常。
8.5 输出文件写入失败
第三个参数指定程序最终答案的输出位置。
如果:
- 输出路径非法;
- 目标位置无法写入;
- 目标路径存在其他文件系统问题;
程序需要正确处理写入异常。
测试目的:
确保答案输出失败时程序能够正确结束,而不是异常崩溃。
8.6 空文本
如果论文内容为空,或者经过预处理以后为空,则不能直接按照普通余弦相似度公式计算。
否则可能产生:
0 / 0
因此程序在向量计算之前对这种情况进行处理。
8.7 纯标点文本
例如:
!!!???,,,。。。
经过预处理后可能得到空字符串。
因此这种输入实际上也是一个重要的边界分支。
对应单元测试用于验证程序不会出现:
- 除零;
- 数组越界;
- NaN;
- 未捕获异常。
九、老师测试文本实际运行
本次使用老师提供的有效测试文件:
orig.txt
orig_0.8_add.txt
进行最终 JAR 验收。
运行方式与题目要求保持一致:
java -jar target/main.jar "原文绝对路径" "抄袭版绝对路径" "答案文件绝对路径"
一次实际测试结果:
退出码:0
答案:0.90
耗时:约 250.42 ms
答案文件最终内容:
0.90
符合题目要求的:
浮点型,精确到小数点后两位。
程序没有在答案文件中加入其他调试文字。
同时需要说明的是,测试文件名称中的 0.8 并不能直接作为程序标准答案,因此算法没有读取文件名判断结果,也没有针对老师公开样例进行硬编码。
十、最终构建与验收
项目完成后,分别进行了测试、覆盖率检查、代码质量检查和最终打包。
依次执行:
mvn clean test
mvn clean verify
mvn pmd:check
mvn clean package
以上命令均:
BUILD SUCCESS
最终验收结果如下:
| 检查项目 | 最终结果 |
|---|---|
| 开发语言 | Java |
| Java 版本 | JDK 17 |
| 构建工具 | Maven |
mvn clean test |
BUILD SUCCESS |
mvn clean verify |
BUILD SUCCESS |
mvn pmd:check |
BUILD SUCCESS |
mvn clean package |
BUILD SUCCESS |
| Tests Run | 32 |
| Failures | 0 |
| Errors | 0 |
| Skipped | 0 |
| Line Coverage | 88.10%(74/84) |
| Branch Coverage | 87.50%(42/48) |
| PMD Version | 6.55.0 |
| PMD Violations | 0 |
main.jar |
成功生成 |
| Main-Class | com.eso.plagiarism.Main |
| 老师有效样例结果 | 0.90 |
| 老师样例运行时间 | 约 250.42 ms |
| 百万字符级测试时间 | 约 357 ms |
最终生成:
target/main.jar
Manifest 已确认包含:
Main-Class: com.eso.plagiarism.Main
因此最终 JAR 可以直接通过:
java -jar main.jar [原文文件] [抄袭版论文文件] [答案文件]
运行。
十一、Git 与 GitHub 版本管理
本项目使用 Git 和 GitHub 进行源代码管理。
项目没有等到所有代码全部完成之后再一次性提交,而是根据开发阶段进行了多次 Commit。
部分实际 Commit 如下:
a888619 chore: initialize plagiarism checker project
e48f390 feat: complete plagiarism checker with tests and documentation
5868359 docs: clean up blog formatting
b127506 test: add evaluation compatibility checks
这些 Commit 分别对应:
- 初始化论文查重项目;
- 完成核心算法、测试和文档;
- 整理博客格式;
- 增加最终评测兼容性测试。
通过这种方式,Git 历史能够比较清楚地体现项目逐步完成的过程。
相比完成所有内容以后只提交一次,这种方式也更符合实际软件开发中的版本管理习惯。
十二、项目总结
通过本次个人项目,我对一个程序从需求到最终交付的过程有了更加完整的认识。
一开始看到“论文查重”这个题目时,我首先考虑的是:
怎样计算两篇文章的相似度?
但真正开始按照软件工程的方法完成项目以后,我发现算法只是整个项目中的一部分。
除了实现算法,还需要考虑:
- 怎样组织项目结构;
- 怎样设计不同模块的职责;
- 怎样处理非法输入;
- 怎样设计测试用例;
- 怎样使用自动化单元测试;
- 怎样查看测试覆盖率;
- 怎样检查代码质量;
- 怎样分析程序运行效率;
- 怎样使用 Git 记录开发过程;
- 怎样生成最终可以独立运行的 JAR。
这些内容共同组成了一个相对完整的软件开发过程。
本项目最终采用:
Unicode 文本规范化
→ Character 2-Gram
→ 频次向量
→ Cosine Similarity
作为论文查重算法。
这种方法的优点是实现相对清晰,不依赖额外的中文分词组件,同时能够在文章发生一定增删改以后继续比较局部字符结构。
程序平均时间复杂度约为:
O(n + m)
通过老师提供的有效样例以及百万字符级文本测试,目前程序的运行效率能够满足题目要求。
12.1 本项目做得比较好的地方
我认为本项目做得比较好的地方主要有以下几点。
第一,没有把全部代码写在 main() 中,而是将:
程序入口
文件处理
文本预处理
相似度计算
进行了职责拆分。
第二,除了正常输入之外,还考虑了:
空文本
纯标点
单字符
文件不存在
错误参数
输出异常
UTF-8 异常
不同换行符
等边界和异常场景。
第三,编写了 32 个自动化测试,并使用 JaCoCo 检查覆盖率,而不是只手动运行老师提供的样例。
第四,通过 PMD 对代码进行了静态检查,最终 violations 为 0。
第五,对算法的数据结构和执行过程进行了优化,在百万字符级测试中仍然能够较快完成计算。
12.2 项目目前的不足
当前算法最大的不足是:
Character 2-Gram 主要衡量字符结构上的相似程度,而不是真正理解文章语义。
例如:
我今天晚上准备去电影院看电影。
如果被改写成:
今晚我打算到影院观看一部影片。
两句话表达的含义比较接近,但是字符本身已经发生了较大变化。
Character N-Gram 对这种大幅度同义改写的识别能力有限。
因此,目前程序更适合判断:
- 原文复制;
- 局部修改;
- 增加文字;
- 删除文字;
- 部分替换;
等文本层面的重复。
12.3 后续可以改进的方向
如果继续完善这个项目,可以进一步研究:
- 不同 N-Gram 长度对准确率的影响;
- 中文分词后的词级相似度;
- TF-IDF;
- SimHash;
- MinHash;
- 基于语义表示的文本相似度。
不过对于本次个人项目,我认为首先保证:
接口正确
+
算法清晰
+
异常可控
+
测试充分
+
运行稳定
+
代码规范
比单纯增加算法复杂度更加重要。
十三、PSP 事后总结
本次项目预估总耗时为:
1550 分钟
整理后的实际耗时约为:
1835 分钟
实际时间比预估时间更多。
主要原因并不是核心算法编码本身特别复杂,而是在开发过程中还需要完成:
- Maven 项目配置;
- JDK 和运行环境配置;
- 命令行参数测试;
- JAR 打包;
- 单元测试;
- 测试覆盖率;
- PMD 代码质量检查;
- 性能测试;
- Git 和 GitHub 管理;
- 博客报告整理。
这让我意识到,在估计一个软件任务所需要的时间时,不能只估计“代码需要写多久”。
软件开发还包含大量编码之外的工作。
下一次进行类似项目时,我会在开发之前将任务进一步拆分为:
需求
→ 设计
→ 编码
→ 单元测试
→ 调试
→ 性能检查
→ 代码质量检查
→ 最终验收
→ 文档
并分别为这些阶段预留时间。
这也是本次 PSP 记录对我最大的帮助。

浙公网安备 33010602011771号