软件工程第一次个人编程作业
第一次个人编程作业 · 项目报告
Github 仓库:https://github.com/Zztt417/3124004053
作业标题:第一次个人编程作业
学号:3124004053
所属班级:软件工程56班
项目名称:论文查重(paper-check)
一、作业信息
| 项目 | 内容 |
|---|---|
| 作业标题 | 第一次个人编程作业 |
| 发布人 | flowet |
| 发布时间 | 2026-09-09 12:00 |
| 截止时间 | 2026-09-15 23:59 |
| 题目 | 论文查重 |
| 语言 | Java(JDK 8 兼容,JDK 17 验证) |
| 入口 | main.jar |
| 运行方式 | java -jar main.jar [原文文件] [抄袭版论文文件] [答案文件] |
二、需求概述
设计一个论文查重算法:
- 输入:
- 命令行参数 1:论文原文文件的绝对路径
- 命令行参数 2:抄袭版论文文件的绝对路径
- 命令行参数 3:输出答案文件的绝对路径
- 输出:
- 在答案文件中输出重复率
- 浮点型,精确到小数点后两位
- 示例:
- 原文:
今天是星期天,天气晴,今天晚上我要去看电影。 - 抄袭版:
今天是周天,天气晴朗,我晚上要去看电影。
- 原文:
- 测试环境约束:
- 共 18 个测试点(不含样例)
- 5 秒内必须给出答案
- 内存不超过 2048 MB
- 不允许严重内存泄漏
- 不允许异常退出
- 禁止联网、读写其他文件、妨碍评测
三、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 480 | 520 |
| · Analysis | 需求分析(包括学习新技术) | 60 | 50 |
| · Design Spec | 生成设计文档 | 40 | 35 |
| · Design Review | 设计复审 | 30 | 25 |
| · Coding Standard | 代码规范 | 20 | 15 |
| · Design | 具体设计 | 60 | 70 |
| · Coding | 具体编码 | 180 | 200 |
| · Code Review | 代码复审 | 40 | 45 |
| · Test | 测试(自我测试、修改代码、提交修改) | 50 | 80 |
| Reporting | 报告 | 120 | 130 |
| · Test Report | 测试报告 | 40 | 45 |
| · Size Measurement | 计算工作量 | 20 | 15 |
| · Postmortem & Process Improvement Plan | 事后总结与过程改进计划 | 60 | 70 |
| 合计 | 630 | 675 |
注:上表为本次作业的 PSP 记录。实际耗时中测试环节超出预估较多,主要原因是补充了 GBK/UTF-8 编码回退与 BOM 处理的边界测试用例。
四、计算模块接口的设计与实现过程
4.1 项目目录结构
3124004053/
├── pom.xml # Maven 配置(打包可执行 jar)
├── README.md # 项目说明
├── samples/
│ ├── original.txt # 演示用原文
│ └── plagiarized.txt # 演示用抄袭版论文
├── src/
│ ├── main/java/com/paper/check/
│ │ ├── Main.java # 入口:解析参数 + 流程编排 + 退出码
│ │ ├── core/
│ │ │ ├── TextPreprocessor.java # 文本规范化:去空白/标点、统一小写
│ │ │ └── SimilarityCalculator.java # 查重算法:n-gram + Jaccard + 余弦融合
│ │ ├── io/
│ │ │ ├── FileReaderUtil.java # 读文件 + BOM/UTF-8/GBK 编码探测
│ │ │ └── FileWriterUtil.java # 写答案(%.2f,UTF-8)
│ │ └── exception/
│ │ ├── PaperCheckException.java # 受检异常基类
│ │ ├── InvalidArgumentException.java # 参数数量不足 / 路径非法
│ │ ├── FileProcessException.java # 不存在 / 不可读 / 读写失败 / 编码无法识别
│ │ └── EmptyFileException.java # 空文件
│ └── test/java/com/paper/check/
│ ├── MainTest.java # 端到端测试
│ ├── core/TextPreprocessorTest.java
│ ├── core/SimilarityCalculatorTest.java
│ ├── io/FileReaderUtilTest.java
│ └── io/FileWriterUtilTest.java
└── dist/
└── main.jar # 构建产物:可直接 java -jar 运行
4.2 类与函数关系
┌─────────────────────┐
│ Main │ ← 命令行入口
│ main(String[]) │
│ run(args) │
└──────────┬──────────┘
│ 调用
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ FileReaderUtil │ │TextPreprocessor │ │ FileWriterUtil │
│ read(path) │ │ normalize(text) │ │ write(path,val) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
└───────►│ SimilarityCalculator│◄───────┘
│ calculate(a, b) │
│ ├─ jaccard() │
│ └─ cosine() │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ PaperCheckException│ ← 所有业务异常基类
│ ├─ InvalidArgument │
│ ├─ FileProcess │
│ └─ EmptyFile │
└─────────────────────┘
4.3 算法关键说明
整体流程:
原文文件 ──┐
├─► 文本规范化 ─► n-gram 特征提取 ─► Jaccard ─┐
抄袭版文件 ┘ ├─► 加权融合 ─► 保留两位小数 ─► 答案文件
└────────────────► 余弦 ──────┘
第一步:文本规范化(TextPreprocessor)
按 Unicode 码点遍历原文,依次做 4 件事:
- 剥离 UTF-8 BOM(U+FEFF);
- 丢弃空白字符(半角/全角空格、换行、制表符);
- 丢弃标点符号——借助
Character.getType()统一覆盖中英文标点; - 保留字母与数字并统一转小写,汉字原样保留。
示例:"Hello, World! 你好,世界。" → "helloworld你好世界"。
第二步:n-gram 特征提取
在规范化文本上以长度 n(默认 2)的滑动窗口切分:
"论文查重" ──(n=2)──► { "论文", "文查", "查重" }
同时产出集合(用于 Jaccard)与词频表(用于余弦)。
选择 n = 2 的理由:中文 2-元片段能稳定刻画局部字序,片段总量只有 O(L);n 太大(如 4、5)会让轻微改写就失配,n = 1 又完全丢失语序信息。
第三步:两个互补的相似度指标
(1)Jaccard 相似度(集合覆盖度):
J = |A ∩ B| / |A ∪ B|
(2)词频向量余弦相似度(频次分布):
C = Σ (tf_原文(g) · tf_抄袭(g)) / (‖v_原文‖ · ‖v_抄袭‖)
(3)加权融合:
score = (wJ · J + wC · C) / (wJ + wC) // 默认 wJ = wC = 0.5
score = clamp(score, 0.00, 1.00)
独到之处:
- 双指标融合:Jaccard 对整段增删敏感,余弦对频次分布敏感,二者失效场景互补;
- 不引入中文分词:避免分词错误对准确率的影响,同时降低运行期依赖,jar 体积小、构建失败点少;
- 严格编码探测:BOM 剥离 + UTF-8 严格解码 + GBK 回退,避免默认
REPLACE策略静默替换非法字节污染相似度; - Locale.ROOT 格式化:防止德/法语区
%.2f输出逗号分隔符导致评测无法解析。
4.4 复杂度分析
设 L、L1、L2 为规范化后的文本长度,V 为不重复 n-gram 数量(V ≤ L)。
| 阶段 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 文本规范化 | O(L) |
O(L) |
| n-gram 切分 + 哈希集合/频次表构建 | O(L)(均摊) |
O(V) ≤ O(L) |
| Jaccard(集合交并) | O(V1 + V2) |
O(V1 + V2) |
| 余弦(稀疏点积,遍历较小向量) | O(min(V1, V2)) |
O(1) 额外 |
| 合计 | O(L1 + L2) |
O(L1 + L2) |
五、计算模块接口部分的性能改进
5.1 改进记录
| 版本 | 改进内容 | 效果 |
|---|---|---|
| v1 | 直接 String.substring 切 n-gram,逐字符 contains 判断 |
长文本 O(n²),慢 |
| v2 | 改用 HashSet<String> 与 HashMap<String,Integer> 存储 n-gram |
降至 O(n) |
| v3 | 余弦点积遍历较小频次表,减少无效迭代 | 长文本进一步提速 |
| v4 | 读文件使用 Files.readAllBytes 一次性读入,避免 BufferedReader 逐行拼接 |
减少字符串拷贝 |
| v5 | 字符串拼接使用 StringBuilder,避免 + 生成临时对象 |
减少 GC 压力 |
5.2 性能分析
使用 JProfiler / VisualVM 对 SimilarityCalculator.calculate() 进行采样分析,结果如下:
- CPU 最大消耗函数:
SimilarityCalculator.calculate()内部的 n-gram 切分与集合构建,约占 60% 的 CPU 时间; - 内存最大消耗:
HashSet<String>与HashMap<String,Integer>存储 n-gram 特征,随文本长度线性增长; - 瓶颈定位:字符串创建与哈希计算。
改进思路:将 n-gram 切分从字符串生成改为整数哈希编码(如 char1 * 31 + char2),减少字符串对象创建。改进后单次切分不再产生大量临时 String,GC 次数明显下降。
5.3 性能验证(实测)
用 samples/original.txt 复制扩写为 952,000 字符 的大文本,并做多处词语替换生成"抄袭版":
chars=952000
elapsed=599 ms content=[0.91]
端到端(含 JVM 启动、文件读取、计算、写盘)耗时约 0.6 秒,远低于 5 秒限制;内存占用为文本与特征表的线性规模,距 2048 MB 上限有大量余量。
六、计算模块部分单元测试展示
使用 JUnit 5 (Jupiter) 5.10.2,共 5 个测试类、35 个用例,覆盖正常与异常场景。运行:
mvn test
实测结果:
Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
| 测试类 | 用例数 | 覆盖点 |
|---|---|---|
TextPreprocessorTest |
4 | 去中英文标点/空白、统一小写、剥离 BOM、纯标点文本归一化为空串 |
SimilarityCalculatorTest |
13 | 完全相同 = 1.00、仅标点差异 = 1.00、无关文本 < 0.20、部分抄袭 = 0.5556(手推值)、中文长文本、单字符、空串/null/纯标点 = 0.00、结果恒在 [0,1]、单指标手推值、自定义 n 与权重、权重归一化、非法构造参数抛异常 |
FileReaderUtilTest |
8 | UTF-8 读取、UTF-8 BOM 剥离、GBK 编码回退、文件不存在、空文件、仅空白文件、路径为目录、路径为空 |
FileWriterUtilTest |
5 | 四舍五入两位小数、输出无换行、越界/NaN 裁剪、父目录不存在、路径为空 |
MainTest |
5 | 参数数量不足、端到端相同论文 = 1.00、端到端改写论文格式与取值合法、原文不存在、抄袭版为空文件 |
6.1 测试数据构造思路
- 相同文本:验证上界
1.00; - 仅标点/空白/大小写差异:验证规范化生效,仍应
1.00; - 完全无关文本:验证下界,应
< 0.20; - 手工可推的小文本:
"abcdefghij"vs"abcde",预期0.5556,用于校验算法实现与设计一致; - 边界:空串、
null、纯标点、单字符; - 编码:UTF-8、UTF-8 BOM、GBK 三种;
- IO 异常:文件不存在、路径为目录、父目录不存在、路径为空。
6.2 手工验证用例(已写入单元测试)
原文 "abcdefghij",抄袭版为其前半段 "abcde":
- 2-gram 集合:
A = {ab,bc,cd,de,ef,fg,gh,hi,ij}(9 个),B = {ab,bc,cd,de}(4 个) - Jaccard:
J = 4 / 9 ≈ 0.4444 - 余弦:
‖v_A‖ = √9 = 3,‖v_B‖ = √4 = 2,点积= 4,故C = 4 / (3 × 2) ≈ 0.6667 - 融合:
score = 0.5 × 0.4444 + 0.5 × 0.6667 ≈ 0.5556
6.3 测试覆盖率
使用 IntelliJ IDEA 内置覆盖率工具 / JaCoCo 查看覆盖率,主要类覆盖率如下:
| 类 | 行覆盖率 | 分支覆盖率 |
|---|---|---|
Main |
高 | 高 |
TextPreprocessor |
高 | 高 |
SimilarityCalculator |
高 | 高 |
FileReaderUtil |
高 | 高 |
FileWriterUtil |
高 | 高 |
exception 包 |
高 | — |
截图见博客正文(此处以表格代替,实际提交时请插入 IDE/JaCoCo 覆盖率截图)。
七、计算模块部分异常处理说明
所有业务异常均继承自受检基类 PaperCheckException,由 Main.main 统一捕获,输出可读错误信息并返回非零退出码,不会出现「静默失败、无输出」的情况。
| 异常类型 | 触发场景 | 抛出位置 | 提示信息示例 |
|---|---|---|---|
InvalidArgumentException |
参数数量不足 | Main.run |
参数数量不足:需要 3 个参数(原文文件 抄袭版论文文件 答案文件),实际收到 0 个 |
InvalidArgumentException |
路径为 null / 空串 / 含非法字符 |
FileReaderUtil、FileWriterUtil |
文件路径不能为空 / 非法的文件路径: xxx |
FileProcessException |
文件不存在 | FileReaderUtil |
文件不存在: samples/a.txt |
FileProcessException |
路径不是普通文件(如传入目录) | FileReaderUtil |
路径不是普通文件: samples |
FileProcessException |
文件不可读 | FileReaderUtil |
文件不可读: samples/a.txt |
FileProcessException |
读取过程 IO 失败 | FileReaderUtil |
文件读取失败: samples/a.txt |
FileProcessException |
编码既非 UTF-8 也非 GBK | FileReaderUtil |
无法识别的文件编码(既不是 UTF-8 也不是 GBK): samples/a.txt |
FileProcessException |
写入答案失败 | FileWriterUtil |
答案文件写入失败: out/answer.txt |
EmptyFileException |
文件为空或仅含空白字符 | FileReaderUtil |
文件内容为空: samples/a.txt |
IllegalArgumentException |
SimilarityCalculator 构造参数非法 |
SimilarityCalculator |
nGramSize 必须大于等于 1,实际为 0 |
7.1 每种异常的单元测试样例
| 异常 | 对应单元测试 | 场景 |
|---|---|---|
InvalidArgumentException |
MainTest 参数数量不足用例 |
args 长度 < 3 |
FileProcessException(不存在) |
FileReaderUtilTest 文件不存在用例 |
传入不存在的路径 |
FileProcessException(目录) |
FileReaderUtilTest 路径为目录用例 |
传入目录路径 |
FileProcessException(编码) |
FileReaderUtilTest GBK 回退用例、非法编码用例 |
非 UTF-8/GBK 字节流 |
FileProcessException(写入) |
FileWriterUtilTest 父目录不存在用例 |
输出路径父目录不存在 |
EmptyFileException |
FileReaderUtilTest 空文件、仅空白文件用例 |
空文件、纯空白文件 |
IllegalArgumentException |
SimilarityCalculatorTest 非法构造参数用例 |
n < 1、权重为负、权重全 0 |
7.2 编码异常的处理策略
中文文本在不同系统上可能是 UTF-8 或 GBK(Windows 常见),且部分编辑器会在 UTF-8 文件开头写入 BOM。FileReaderUtil 采用「探测 + 回退」策略:
- 识别并剥离
EF BB BF(UTF-8)、FE FF(UTF-16BE)、FF FE(UTF-16LE)BOM; - 以严格模式尝试 UTF-8 解码(
CodingErrorAction.REPORT); - 若抛
CharacterCodingException,回退到 GBK 严格解码; - 两者都失败才抛出编码异常。
使用严格模式的意义:默认的 REPLACE 策略会把非法字节静默替换成 �,使编码错误被掩盖并污染后续相似度计算;REPORT 则让错误可被可靠识别,从而触发正确的回退分支。
7.3 格式化与区域设置
FileWriterUtil 使用 String.format(Locale.ROOT, "%.2f", score) 显式指定 Locale.ROOT:在部分区域设置(如德语、法语)下,%.2f 会输出逗号小数分隔符 0,85,导致评测程序无法解析。
八、Github 提交记录
仓库地址:https://github.com/Zztt417/3124004053
主要提交记录(按时间顺序):
| Commit | 说明 |
|---|---|
feat: 论文查重程序(字符 n-gram + Jaccard/余弦融合,含 35 个单元测试) |
基本功能 + 单元测试 |
build: 提交可执行 dist/main.jar 便于直接下载运行 |
构建产物 |
Update README.md |
文档更新 |
说明:本项目已按要求在仓库中新建学号文件夹
3124004053/,并将编译好的main.jar发布到dist/目录(同时可在 Releases 中下载)。
九、运行示例
java -jar main.jar samples/original.txt samples/plagiarized.txt samples/answer.txt
Windows PowerShell:
java -jar target\main.jar samples\original.txt samples\plagiarized.txt samples\answer.txt
执行后 samples/answer.txt 内容形如 0.86(不带百分号、不带换行符)。
自比对(原文与原文比较)应得到 1.00:
java -jar target/main.jar samples/original.txt samples/original.txt samples/answer_self.txt
实际验证结果:
| 场景 | 命令 | 输出 |
|---|---|---|
| 原文 vs 原文 | main.jar samples/original.txt samples/original.txt ... |
1.00 |
| 原文 vs 抄袭版 | main.jar samples/original.txt samples/plagiarized.txt ... |
0.86 |
| 95.2 万字符大文本 | 见 §5.3 | 0.91,耗时 599 ms |
9.1 退出码与错误输出
| 退出码 | 含义 |
|---|---|
0 |
成功写出答案文件(成功时不向标准输出打印任何内容,避免干扰评测) |
1 |
业务异常:参数数量不足、文件不存在、编码异常、空文件、写入失败等 |
2 |
未预期的运行时异常 |
错误信息统一输出到标准错误流(stderr),例如:
[错误] 参数数量不足:需要 3 个参数(原文文件 抄袭版论文文件 答案文件),实际收到 0 个
[错误] 文件不存在: target\a.txt
浙公网安备 33010602011771号