作业 GitHub 仓库:https://github.com/ccjfd/3124004430

这个作业属于哪个课程 软件工程
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702
这个作业的目标 使用 Java 完成支持命令行文件输入输出的论文查重程序。
学号 3124004430

一、PSP 表格

表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 25
· Estimate 估计这个任务需要多少时间 10 10
Development 开发 430 480
· Analysis 需求分析(学习 jieba 分词、余弦相似度) 60 70
· Design Spec 生成设计文档 30 35
· Design Review 设计复审 15 15
· Coding Standard 代码规范 15 15
· Design 具体设计 40 45
· Coding 具体编码 150 170
· Code Review 代码复审 30 30
· Test 测试(自我测试,修改代码,提交修改) 90 100
Reporting 报告 120 135
· Test Report 测试报告 60 70
· Size Measurement 计算工作量 10 10
· Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 50 55
合计 570 640

二、计算模块接口的设计与实现过程

2.1 整体流程

2.2 类与职责划分

项目共 7 个主类 + 2 个自定义异常类,遵循单一职责原则,每层都可以独立单元测试:

表格

职责
Main 命令行入口,解析 3 个参数(原文 / 抄袭版 / 答案路径),约定退出码(0 成功 / 1 参数错误 / 2 运行异常)
DuplicateChecker 流程组装层,串联 读文件→分词→向量化→算相似度
TextReader 文件读取。先按 UTF-8 严格解码,失败回退 GBK,自动剔除 UTF-8 BOM
Tokenizer 基于 jieba(SEARCH 模式)分词,过滤空白 token,英文统一转小写
VectorBuilder 把词列表转换为 Map<String, Integer> 词频向量
SimilarityCalculator 计算余弦相似度,结果钳位到 [0,1]
FileReadException 文件不存在 / 不可读时抛出
EmptyContentException 文本为空、无法计算相似度时抛出

2.3 算法关键与独到之处

算法核心:把两篇论文分别表示为词频向量,用余弦相似度度量重复率。分母为两向量模长的乘积,分子为点积。设两篇文档词数分别为 n1、n2,时间复杂度为 O (n1 + n2)。

三个独到设计:

  1. 点积只遍历较小向量:在较小向量上迭代、到较大向量中 get 查找公共词,避免对全词表做集合求交,常数因子更小。
  2. 编码自适应:测试文本可能来自不同编辑器,编码不统一。TextReader 用严格模式 UTF-8 解码(非法字节立即抛错而非静默替换),失败后回退 GBK,保证任何来源的 txt 都能正确读取。
  3. 空白 token 过滤:分词后剔除纯空白 token,避免换行 / 空格数量差异干扰重复率;保留标点参与统计,使标点改动也被计入。

已知局限(改进方向):词频向量与语序无关,"我打了他" 和 "他打了我" 会得到相同结果。可改进为字符 n-gram 或 SimHash以引入位置信息。

三、计算模块接口部分的性能改进

3.1 性能测量

用真实测试文本(约 1 万字中文)实测,程序端到端耗时约 1.7 ~ 2.3 秒,其中:

表格

阶段 耗时 占比
JVM 启动 ~0.2s ~10%
jieba 词典加载 ~0.5s ~25%(最大瓶颈)
分词(两篇文档) ~0.9s ~50%
词频统计 + 余弦计算 <0.05s ~2%

3.2 改进思路

  • 分词器实例进程内共享(已实现):Tokenizer 中用 static final 单例持有 JiebaSegmenter,词典只加载一次;
  • 点积遍历较小向量、模长用平方和累加(已实现);
  • 若还需提速:可关闭 jieba 的 HMM 新词发现、用 JMH 定位热点后针对性优化;
  • 内存方面:两篇文档的词表按需构建,占用量远低于 2048MB 限制,5 秒时限内可稳定给出答案。

四、计算模块部分单元测试展示

测试框架 JUnit 5,共 19 个测试用例,全部通过,采用白盒方式按 "纯函数层 → 流程层 → 命令行层" 构造:

表格

测试类 测试内容 构造思路
TestTokenizer 分词不含空白 token;空文本抛 EmptyContentException;英文转小写 白盒:检查过滤边界与归一化逻辑
TestVectorAndCosine 词频累加正确;相同向量 = 1.0;无公共词 = 0.0;部分重叠∈(0,1) 边界值三角法:上界 / 下界 / 中间态
TestTextReader UTF-8 正常读取;GBK 兼容读取;文件不存在抛异常 用 @TempDir 临时目录分别写两种编码的文件
TestCheckerFlow 相同文本 = 1.00;题目样例 > 0.5;完全不同文本 < 0.1;空文件抛异常 题目样例 + 极端样本对照
TestFileIOAndMain 完整流程答案格式为 \d.\d {2};参数错误退出码 1;缺文件 / 空文件退出码 2;%.2f 格式化 验证退出码契约与输出格式契约

用 mvn test(JaCoCo 插件)统计覆盖率,报告在 target/site/jacoco/index.html:

表格

指标 覆盖率
指令覆盖 81%
行覆盖 80%
分支覆盖 71%

image

测试设计评价:19 个用例覆盖了分词边界、向量计算边界、双编码读取、空输入、缺失文件、参数错误等主要路径,行覆盖 80%。未覆盖部分主要是 Main 的 System.exit 分支与 GBK 回退的深层分支,属于防御性代码;若追求更高覆盖可补充非法字节序列的构造测试。总体能满足程序功能测试要求。

五、计算模块部分异常处理说明

表格

异常 设计目标 错误场景 对应单元测试
FileReadException 文件不存在或不可读时给出明确中文提示,而不是抛出底层 IOException 栈 评测命令中传入了错误路径:java -jar main.jar C:\notexist.txt ... readNotExists、mainMissingFile
EmptyContentException 任一文件为空(或分词后无有效内容)时提前拦截,避免输出误导性结果或除零错误 答案前忘了写内容:type nul > empty.txt 后把 empty.txt 当作抄袭版传入 tokenizeEmptyThrows、emptyFileThrows、mainEmptyFile
参数个数错误 打印用法提示并以退出码 1 结束 评测机传参不足 3 个 mainWrongArgs

退出码约定:0 成功 / 1 参数错误 / 2 运行期异常,评测机可据此判断程序是否正常退出。

六、实测结果

用提供的测试文本:

表格

测试点 输出
orig_0.8_add.txt(噪声增字版) 1.00
orig_0.8_del.txt(删字版) 0.99
orig_0.8_dis_1.txt(乱序扰动 1) 1.00
orig_0.8_dis_10.txt(乱序扰动 10) 1.00
orig_0.8_dis_15.txt(乱序扰动 15) 0.99

结果符合词频余弦模型的预期:对增删改不敏感、与语序无关;全部测试点均在 5 秒内返回。

七、事后总结与改进计划

本次作业预估总耗时 570 分钟,实际总耗时 640 分钟,比预估多出 70 分钟。

差距主要原因:

  1. 一开始对jieba-analysisJava 分词库 API 不熟,踩坑较多,比如词典加载重复、分词结果出现大量空白符号,调试花了不少时间;
  2. 文件编码兼容处理比预想麻烦,Windows 下 GBK、UTF-8 带 BOM 多种文本要兼容,反复构造测试文件验证;
  3. 单元测试和异常分支编写、JaCoCo 覆盖率调试花费时间超预期,很多边界场景(空文件、非法路径)要单独写测试用例。

本次收获:

  1. 学会按 PSP 流程拆分任务,提前预估时间,开发、测试、文档分开,不是写完代码再一次性补报告;
  2. 理解了余弦相似度查重的优缺点:简单高效,但无法识别语序改动;
  3. 学会单元测试、代码覆盖率工具,提前捕获边界异常,而不是等到程序上线报错。

后续改进计划:

  1. 算法层面:后续可以升级 SimHash 算法,加入文本语序信息,解决词频向量无法区分句子颠倒的缺陷;
  2. 性能层面:预加载 jieba 词典做全局单例,减少重复加载开销;关闭 HMM 新词识别,进一步提升分词速度;
  3. 工程层面:增加日志模块,方便定位异常;补充更多极端测试用例,提升分支覆盖率;
  4. 开发流程:下次开发前先写单元测试(TDD),先定义接口,再写实现,减少后期返工。