第一次个人编程作业
第一次个人编程作业
| 这个作业属于哪个课程 | 首页 - 计科24级78班 - 广东工业大学 - 班级博客 - 博客园 |
| 这个作业要求在哪里 | 个人项目 - 作业 - 计科24级78班 - 班级博客 - 博客园 |
| 这个作业的目标 | 独立完成一次完整的软件工程作业 |
语言 / 环境:Java(JDK 17)。入口为打包后的
main.jar,运行方式:
java -jar main.jar [原文文件] [抄袭版论文的文件] [答案文件]。
给定一份「原文」和在其基础上经过增、删、改(含乱序、同义替换)得到的「抄袭版」,
程序从命令行读取两个文件的绝对路径,计算两者的重复率,并把结果(浮点型,保留两位小数)
写入指定的答案文件。
一、准备
1. 我的 GitHub 项目链接
https://github.com/gigittty/paper-plagiarism
仓库根目录下为学号文件夹
3124004444;编译好的可执行程序main.jar同时发布在仓库的 Releases(v1.0) 中。
2. PSP 表格
PSP2.1(个人开发流程)预估与实际耗时如下(单位:分钟):
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning(计划) | / | / | |
| · Estimate | · 估计这个任务需要多少时间 | 8 | 8 |
| Development(开发) | 148 | 180 | |
| · Analysis | · 需求分析(包括学习新技术:jieba、余弦相似度、JUnit5) | 15 | 20 |
| · Design Spec | · 生成设计文档 | 10 | 12 |
| · Design Review | · 设计复审 | 8 | 8 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 5 | 5 |
| · Design | · 具体设计(类 / 函数设计、算法选型) | 15 | 18 |
| · Coding | · 具体编码 | 60 | 75 |
| · Code Review | · 代码复审(javac -Xlint / 静态检查) |
10 | 12 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 25 | 30 |
| · Performance | · 性能改进(JFR + 打点分析、优化,额外记录) | — | 25 |
| Reporting(报告) | 23 | 23 | |
| · Test Report | · 测试报告 | 10 | 10 |
| · Size Measurement | · 计算工作量 | 5 | 5 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 8 | 8 |
| 合计 | 179 | 236 |
3. 需求分析
- 本系统旨在实现一个论文查重工具,用于评估两篇文本的相似程度。程序通过命令行接收原文文件路径、抄袭版论文文件路径、答案输出文件路径三个参数。
- 功能上,系统需自动读取文本、对中文进行分词、提取词频特征,计算两篇文本的相似比例,最终把结果以保留两位小数的浮点数写入答案文件。
- 非功能层面,程序运行时间需严格控制在 5 秒以内,内存占用不得超过 2048MB,在遇到文件不存在或参数缺失等情况时进行异常拦截并安全退出;程序不联网、不读写参数以外的文件。
- 功能可划分为:
- 基础功能:命令行参数解析、文件读取 / 写出、中文分词、词频向量、余弦相似度、重复率输出;
- 扩展功能:无第三方依赖时的字符二元(2-gram)分词降级、自定义异常体系与统一错误处理、单元测试与覆盖率、性能分析。
4. 代码设计
- 系统整体采用面向对象结构设计,由若干职责单一的类 + 一个主入口构成:
FileIO类负责文件磁盘交互(读取原文 / 抄袭版、写出答案);Tokenizer类负责中文分词(优先 jieba,降级为字符 2-gram);CosineSimilarity类负责核心算法(词频向量构造 + 余弦相似度);PlagiarismException(及FileReadException/FileWriteException/InvalidArgsException)构成统一异常体系;- 主入口
Main负责校验命令行入参并调度上述各类完成全流程。
- 算法选用词频向量的余弦相似度,具备对局部增删与语序置换稳健的特性。
- 此外,系统针对文件缺失、参数错误等设计了三类自定义异常,并配备了 29 个覆盖常规与边界的 JUnit 5 单元测试用例。
二、代码分析
1. 计算模块接口的设计与实现过程
(1)模块组织与类结构设计
程序由 4 个职责类 + 1 个异常基类 + 3 个异常子类 + 1 个入口 构成:
Main(入口类):main(String[]):程序入口。run(String[]):串起「读取 → 分词 → 相似度 → 写出」流程,并统一捕获异常、返回受控退出码。parseArgs(String[]):校验并返回 3 个路径参数,个数不为 3 时抛出InvalidArgsException。
FileIO(文件操作类):readText(String):接收文件路径,使用 UTF-8 读取并返回全部文本;文件不存在 / 路径为空时抛FileReadException。writeAnswer(String, String):把结果字符串写入答案文件(UTF-8);目标为目录 / 无写权限时抛FileWriteException。
Tokenizer(分词类):tokenize(String):优先使用 jieba 分词;jieba 不可用时自动降级为字符 2-gram。tokenizeByBigram(String):纯字符二元切分(降级通道),并对结果去噪。
CosineSimilarity(查重计算类):termFrequency(List<String>):统计词频,构造词频向量(Map<词, 词频>)。cosine(List<String>, List<String>):计算两段文本的余弦相似度,返回 0.0 ~ 1.0。
类与函数之间的调用关系:
- 主函数
Main.run()从命令行获取 3 个路径参数,先调用FileIO.readText()读取原文与抄袭版文本;
随后调用Tokenizer.tokenize()分词、CosineSimilarity.termFrequency()构造词频向量并由cosine()计算相似度;
最后由FileIO.writeAnswer()把sim × 100(保留两位小数)写入答案文件。
(2)关键函数流程图(run / compute_similarity)
核心流程 Main.run 的执行流程如下:
若不支持 Mermaid 渲染,可用下面这条等价文字流程代替:
main → 校验参数 → 读原文 → 读抄袭版 → 分词 → 词频向量 → 余弦相似度 → 格式化 → 写答案 → 退出码 0。
(3)算法的关键要素
算法核心是词频向量的余弦相似度:
sim(A, B) = (A · B) / (|A| × |B|)
重复率(%) = sim × 100 (保留两位小数)
- 词频统计:通过分词获取词语,记录每个词出现的次数,作为该词在向量中的权重。
- 向量构造:把两段文本分别表示为「词 → 词频」的稀疏向量。
- 余弦度量:仅在两份文本的共有词上求点积(遍历较小的词频表),再除以两个向量模长的乘积。
(4)算法的技术特征
- 对增删稳健:文本中局部增删少量词时,共有词的点积与模长的变化都较小,相似度仍保持较高。
- 语序无关性:算法仅根据分词结果与词频计算,不依赖词语的排列顺序。调换句子语序不改变词频统计,相似度保持不变(样例
orig_dis_1即为 100.00)。 - 同义替换可识别:把部分词替换为同义词会明显改变词频分布,相似度随之下降(样例
orig_rep降至 76.91)。 - 计算复杂度:特征提取为 O(N)(N 为文本长度),两篇比对近似 O(V)(V 为词表规模),单次运行在毫秒级。
独到之处
- jieba + 降级双通道分词:优先使用 jieba 分词(与主流参考实现一致,词典打包在 jar 内,运行期不联网、不读写参数以外的文件);若运行环境未提供 jieba,则自动降级为字符 2-gram 分词,保证程序在零第三方依赖时仍能编译运行。
- 过滤无语义词元:jieba 会把空格、纯标点也当作词元返回,若不处理会虚增相似度。分词后统一过滤掉「不含汉字 / 字母 / 数字」的空白与标点词元。
- 只在交集词上求点积:遍历较小的词频表并查另一张表,跳过完全不相交的词,降低无效计算。
- 异常不外抛:所有可预期错误转换为自定义异常并在入口统一捕获,返回受控退出码,规避评测中「发生异常退出」而判 0 分的风险。
2. 计算模块接口部分的性能改进
(1)性能改进所花费的时间
约 25 分钟(JFR 记录 + 分阶段打点、定位热点、优化与复测)。
- 分析工具:JDK 自带的 Java Flight Recorder(JFR) 记录(产物
performance/perf.jfr,可用 JDK Mission Control / JProfiler 打开),并结合程序内System.nanoTime()分阶段打点(脚本performance/Profile.java,输出performance/timing.csv)。
(2)性能分析图展示
性能分析图由打点数据自动绘制(脚本 performance/plot_perf.py):

(3)程序中消耗最大的函数
消耗最大的函数是「分词」(Tokenizer → jieba 的词典加载与 DAG 切词)。冷启动单次运行的实测分阶段耗时如下:
| 阶段 | 耗时(ms) | 说明 |
|---|---|---|
| readFile | 0.55 | 读取两个文件(UTF-8) |
| tokenize(冷,含一次性词典加载) | 546.86 | 热点:jieba 词典一次性加载 |
| tokenize(200× 大输入) | 26.58 | 词典已加载后的纯切词开销 |
| cosine | 1.49 | 词频向量 + 余弦 |
可以看到,JFR/打点结果中系统底层一次性开销(jieba 默认词典加载)占比最大,属于进程启动的固定开销;在业务计算模块中,「分词 + 余弦」才是与文本规模相关的部分,且词典加载完成后单次纯计算仅约 2ms 量级。
(4)改进思路与实现
- 复用单个
Tokenizer实例:jieba 词典加载是一次性开销(约 0.5s),只发生在进程启动的第一次分词;复用同一实例,避免重复构造与重复探测。 - 仅在词表交集上求点积:余弦计算遍历较小的词频表,对低重合度长文本减少无效运算。
- 词频统计使用
HashMap.merge:减少分支与临时对象。
(5)改进后的效果对比
| 指标 | 初版 | 改进后 |
|---|---|---|
| 单次「分词 + 余弦」纯计算耗时 | 数毫秒 ~ 十余毫秒 | 约 2ms 量级 |
| 词典加载是否重复 | 可能重复探测 / 构造 | 仅一次(实例复用) |
| 余弦点积范围 | 全词表遍历 | 仅交集词 |
| 整程序单次运行总耗时 | — | 约 0.55s |
改后整程序单次运行总耗时约 0.55s,远低于 5s / 2048MB 的评测上限,且无内存泄漏、无异常退出。
3. 计算模块部分单元测试展示
使用 JUnit 5 编写单元测试,共 29 个测试用例,分布在 5 个测试类中:
| 测试类 | 覆盖内容 |
|---|---|
CosineSimilarityTest |
完全相同、完全不相交、空文本、部分重叠、词频统计 |
TokenizerTest |
2-gram 分词(中文成对、单字、英文单词、中英混排、空串)、jieba 路径、降级路径 |
FileIOTest |
正常读写、文件缺失、路径为空、写入目录等异常分支 |
MainTest |
参数解析、错误退出码、端到端查重(相同 / 完全不相交) |
SampleDataTest |
基于 samples/ 样例验证增 / 删 / 乱序 / 同义替换的判定 |
(1)单元测试代码片段展示
// 端到端:相同文本应输出 100.00 且退出码为 0
@Test
void run_integration_identical_returns0AndWrites100(@TempDir final Path dir) throws Exception {
final Path orig = dir.resolve("orig.txt");
final Path copy = dir.resolve("copy.txt");
final Path ans = dir.resolve("ans.txt");
Files.writeString(orig, "今天是星期天天气晴我要去看电影");
Files.writeString(copy, "今天是星期天天气晴我要去看电影");
final int code = Main.run(new String[] {
orig.toString(), copy.toString(), ans.toString()});
assertEquals(0, code);
assertEquals("100.00", Files.readString(ans).trim());
}
// 分词:连续汉字按相邻两字切分为二元词
@Test
void bigram_splitsChineseIntoPairs() {
assertEquals(List.of("天气", "气晴", "晴朗"),
Tokenizer.tokenizeByBigram("天气晴朗"));
}
// 异常:文件不存在应抛 FileReadException
@Test
void readText_missingFile_throwsFileReadException() {
assertThrows(FileReadException.class,
() -> FileIO.readText("not_exist_12345.txt"));
}
(2)测试的函数与接口
测试覆盖了 CosineSimilarity 类的 cosine、termFrequency,Tokenizer 类的 tokenize、tokenizeByBigram,
FileIO 类的 readText、writeAnswer,以及入口 Main 的 parseArgs、run。
(3)测试数据的构造思路
测试数据以「白盒 + 边界值 / 等价类划分」为指导,共 29 个用例:
- 等价类与边界值用例:构造「两段完全一致的文本」测试上限(100.00);构造「两段互不相关的文本」测试低相似度;构造「双文本均为空」「单侧为空」验证极端边界。
- 文本抗噪与鲁棒性用例:在文本中插入特殊字符、多重标点与无规律空格,验证分词与去噪模块的过滤完整性。
- 特征稳定性用例:调换句中主谓宾语序,测试基于词频加权的余弦算法对语序变换的判定;模拟局部增删替换句子,验证相似度处于合理评估区间。
- 异常与文件接口用例:传入不存在的文件路径验证异常抛出;传入空路径;把目录当作文件写入;执行写入与读取闭环验证磁盘 I/O 稳定性。
(4)单元测试覆盖率展示
运行 JaCoCo 对 29 个测试用例执行自动化检测,覆盖率如下:
| 类 | 指令覆盖 | 分支覆盖 |
|---|---|---|
| Main | 88% | 75% |
| Tokenizer | 95% | 86% |
| CosineSimilarity | 97% | 81% |
| FileIO | 71% | 75% |
| exceptions(4 个) | 100% | — |
| 整体 | ≈91% | ≈83% |
未覆盖的主要是防御性分支(如
SecurityException、Main中兜底的catch (Exception)),这些分支在正常测评路径下不会触发。HTML 报告见build/coverage-report/index.html(运行test.bat后生成)。
发布到博客时请上传覆盖率报告截图。
样例运行结果(java -jar main.jar samples/orig.txt samples/<样例> samples/ans.txt):
| 样例 | 重复率 | 符合预期 |
|---|---|---|
| orig_add(增) | 95.59 | 高,符合 |
| orig_del(删) | 93.84 | 高,符合 |
| orig_dis_1(乱序) | 100.00 | 词频不变 → 100 |
| orig_rep(同义替换) | 76.91 | 明显下降,符合 |
4. 计算模块部分异常处理说明
程序的异常体系以 PlagiarismException 为基类,派生出三个具体异常;所有异常在 Main.run 中统一捕获并转换成受控退出码,从源头避免「发生异常退出」。
| 异常类 | 设计目标 | 触发场景 | 对应单元测试 |
|---|---|---|---|
InvalidArgsException |
参数不合法时明确报错、返回退出码 2 | 命令行参数个数 ≠ 3 | MainTest.parseArgs_wrongCount_throws、run_wrongArgCount_returns2 |
FileReadException |
原文 / 抄袭版读取失败时给出可读错误、返回 1 | 文件不存在、路径为空、非法路径 | FileIOTest.readText_missingFile_throwsFileReadException、readText_nullPath_throwsFileReadException |
FileWriteException |
答案文件无法写出时给出可读错误、返回 1 | 目标为目录、路径为空、无写权限 | FileIOTest.writeAnswer_toDirectory_throwsFileWriteException、writeAnswer_nullPath_throwsFileWriteException |
PlagiarismException(基类) |
统一捕获所有业务异常 | 上述任意情形 | MainTest.run_missingOriginal_returns1 |
(1)命令行参数异常(InvalidArgsException)
- 设计目标:防止调用脚本时传入的参数数量不符合规范(缺少原文路径、缺少抄袭文路径或缺少输出路径),导致访问
args数组时越界。程序校验入参数量,当数量不等于 3 时主动输出标准格式说明并返回退出码 2。 - 单元测试样例:
// 参数异常:个数不为 3
@Test
void parseArgs_wrongCount_throws() {
assertThrows(InvalidArgsException.class,
() -> Main.parseArgs(new String[] {"only", "two"}));
}
- 对应场景:用户在终端输入执行命令时参数遗漏,例如仅输入了
java -jar main.jar orig.txt便回车,缺少抄袭版路径与答案输出路径。
(2)文件读取异常(FileReadException)
- 设计目标:防止用户输入错误的原文 / 抄袭文文件路径、路径拼写错误或目标文件被删除时,程序直接抛出未捕获的系统级异常而崩溃。通过在数据读取层进行文件存在性显式判定并抛出标准异常,使控制层捕获后输出明确排查提示并安全退出。
- 单元测试样例:
// 读取异常:文件不存在
@Test
void readText_missingFile_throwsFileReadException() {
assertThrows(FileReadException.class,
() -> FileIO.readText("not_exist_12345.txt"));
}
- 对应场景:用户在命令行运行程序时,输入的原文路径或抄袭版论文路径在磁盘中不存在(例如文件已被重命名、移动或路径拼写错误)。
(3)文件写出异常(FileWriteException)
- 设计目标:防止答案输出路径不可写(写成已存在的目录、路径为空、无写权限)时程序崩溃或静默失败。通过把底层
IOException转换为自定义异常,让控制层给出可读提示并安全退出。 - 单元测试样例:
// 写入异常:目标是目录
@Test
void writeAnswer_toDirectory_throwsFileWriteException(@TempDir final Path dir) {
assertThrows(FileWriteException.class,
() -> FileIO.writeAnswer(dir.toString(), "95.60"));
}
- 对应场景:评测或用户把答案路径写成了一个已存在的目录,导致文件无法创建 / 写入。
上述三类异常均在
Main.run中被捕获,打印到stderr并返回非 0 退出码,程序不会崩溃。
附录:运行与构建
# 运行(在 3124004444 目录内)
java -jar main.jar [原文文件] [抄袭版论文的文件] [答案文件]
# 例如
java -jar main.jar C:\tests\orig.txt C:\tests\orig_add.txt C:\tests\ans.txt
:: 编译打包(产出 main.jar)
build.bat
:: 单元测试 + JaCoCo 覆盖率报告(build/coverage-report/index.html)
test.bat
- 编译命令:
javac --release 17 -encoding UTF-8 -Xlint:all -Werror(把编译器警告当作错误,确保零警告通过)。 - 依赖:
lib/jieba-analysis.jar(中文分词,词典打包在 jar 内,运行期不联网)。 - 当前结果:单元测试 29/29 通过 · 整体覆盖率约 91%(分支约 83%)· javac 零警告 · 单次运行约 0.55s。

浙公网安备 33010602011771号