第一次个人编程作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 完成个人编程任务 |
GitHub连接:https://github.com/yangyangyang3124/yangyangyang3124/tree/main/3124004145
一、PSP 表格(预估)
在开始实现程序之前,我对程序各模块的开发时间做了如下估计:
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 600 | 600 |
| · Estimate | · 估计这个任务需要多少时间 | 600 | 600 |
| Development | 开发 | 480 | 460 |
| · Analysis | · 需求分析(包括学习新技术) | 30 | 45 |
| · Design Spec | · 生成设计文档 | 30 | 25 |
| · Design Review | · 设计复审 | 15 | 15 |
| · Coding Standard | · 代码规范 | 15 | 15 |
| · Design | · 具体设计 | 35 | 40 |
| · Coding | · 具体编码 | 180 | 180 |
| · Code Review | · 代码复审 | 25 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 120 | 105 |
| Reporting | 报告 | 105 | 110 |
| · Test Report | · 测试报告 | 30 | 40 |
| · Size Measurement | · 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 60 | 55 |
| 合计 | 585 | 570 |
二、计算模块接口的设计与实现过程
1. 代码组织
整个程序位于 plagiarism 包下,共 10 个源文件,按职责分为四层:
Main.java —— 入口层:接收命令行参数、统一异常兜底
CliArguments.java —— 参数层:解析并校验三个命令行参数
PlagiarismChecker.java —— 流程层:读文件 → 算相似度 → 写答案
SimilarityCalculator.java —— 算法层(接口):定义相似度算法抽象
CosineSimilarityCalculator.java —— 算法层(实现):余弦相似度实现
TextNormalizer.java —— 工具层:文本归一化与 n-gram 切分
PlagiarismException.java —— 异常层:异常基类
InvalidArgumentException.java —— 异常层:参数非法异常
FileReadException.java —— 异常层:读文件失败异常
FileWriteException.java —— 异常层:写文件失败异常
类之间的关系:Main 依赖 CliArguments 与 PlagiarismChecker;PlagiarismChecker 持有 SimilarityCalculator 接口引用(依赖倒置,算法可替换),默认注入 CosineSimilarityCalculator;CosineSimilarityCalculator 依赖 TextNormalizer;三类具体异常均继承自 PlagiarismException。
关键函数:
| 类 | 函数 | 作用 |
|---|---|---|
CliArguments |
parse(String[]) |
校验参数个数与空值,返回封装好的路径 |
PlagiarismChecker |
check(...) |
查重主流程,串联读、算、写三步 |
TextNormalizer |
normalize(String) |
去除标点空白、保留中英文数字、英文转小写 |
TextNormalizer |
termFrequency(String, int) |
滑动窗口统计 n-gram 词频 |
CosineSimilarityCalculator |
compute(String, String) |
计算两段文本的余弦相似度 |
2. 流程图
3. 算法关键与独到之处
算法核心:字符 bigram(二元组)词频向量的余弦相似度。
- 归一化:去除标点与空白,只保留中文汉字、英文字母与数字,英文统一转小写;
- 切分:把文本按字符滑动切分为二元组(长度不足 2 的文本退化为单个 unigram);
- 向量化:统计各二元组的出现次数,构造词频向量;
- 计算:
相似度 = (A·B) / (|A| × |B|),即两个词频向量的余弦值。
之所以选择余弦相似度,是因为它对文本整体的增删改比较鲁棒(不要求顺序完全一致),而且结果天然落在 [0, 1] 区间,可以直接作为重复率输出,无需额外的阈值映射。
独到之处:
- 归一化预处理:标点、空白、英文大小写这些"非实质"差异被提前消除,结果只反映内容本身,例如
Hello World与hello world判为相同; - 极短文本退化 unigram:避免单字符或极短文本因切不出二元组而被误判为重复率 0;
- 算法可替换:通过
SimilarityCalculator接口抽象算法,未来换用 SimHash、Jaccard 等只需新增实现类,符合开闭原则; - 零外部依赖:纯 JDK 实现,方便评测环境直接
java -jar运行。
三、计算模块接口部分的性能改进
改进耗时:约30 分钟。
改进前的实现:termFrequency 先调用 ngrams() 把整段文本的所有二元组物化成一个 List<String>,再遍历这个列表逐个统计词频。这样做有两点浪费:一是产生了 n 个临时 substring 对象并全部存入列表,二是对文本做了两遍遍历。
改进思路:把"先切分、再统计"合并为滑动窗口单遍扫描——窗口从左向右滑动,每滑到一个位置就 substring 出当前二元组,直接 HashMap.merge 累加词频,不再保留中间列表。改进后:
- 时间复杂度保持 O(n),但由两遍降为一遍,常数更低;
- 空间复杂度从 O(n + m) 降为 O(m)(m 为不同二元组数),省去了中间 List。
性能分析图:使用 JDK 自带的 Java Flight Recorder(JFR)对程序做 CPU 采样(共 921 个采样点),各函数的自耗时占比如下图所示:

程序中消耗最大的函数:分析结果显示,真正的性能瓶颈并不是字符串处理,而是 HashMap 的插入与查找操作:HashMap.getNode(17.6%)、HashMap$HashIterator.nextNode(17.4%)、HashMap.merge(16.9%)、HashMap.resize(16.4%)、HashMap.putVal(7.9%),HashMap 相关操作合计约 78%。它们来自两个核心方法——TextNormalizer.termFrequency(用 HashMap.merge 累加词频并触发扩容)和 CosineSimilarityCalculator.compute(遍历 key 集合并逐次 getOrDefault 查找)。这说明滑动窗口优化已消除中间 List 的开销,剩余瓶颈集中在词频表的哈希运算上,属于该算法路线的固有成本。
四、计算模块部分单元测试展示
1. 测试框架
由于评测环境不引入 JUnit 等外部依赖,我实现了一个极简的自带测试运行器 TestRunner,用"名称 + 代码块"描述用例,运行后统计通过/失败数量并以退出码反馈,便于接入 CI 与覆盖率统计。
2. 测试用例代码(部分)
算法层测试 SimilarityCalculatorTest(节选):
runner.test("相同文本返回 1.0", () ->
TestRunner.assertEquals(1.0, calculator.compute("今天天气很好", "今天天气很好"),
1e-9, "完全相同的文本应返回 1.0"));
runner.test("完全无关文本返回 0.0", () ->
TestRunner.assertEquals(0.0, calculator.compute("人工智能", "北京天气"),
1e-9, "无共同 bigram 的文本应返回 0.0"));
runner.test("作业示例相似度落在 (0,1) 区间", () -> {
double sim = calculator.compute(
"今天是星期天,天气晴,今天晚上我要去看电影。",
"今天是周天,天气晴朗,我晚上要去看电影。");
TestRunner.assertTrue(sim > 0.0 && sim < 1.0,
"作业示例相似度应严格介于 0 与 1 之间,实际=" + sim);
});
runner.test("英文大小写不敏感", () ->
TestRunner.assertEquals(1.0, calculator.compute("Hello World", "hello world"),
1e-9, "英文应大小写不敏感"));
流程层测试 PlagiarismCheckerTest(节选):
runner.test("完整流程:完全相同输出 1.00", () -> {
checker.check(original.toString(), plagiarized.toString(), answer.toString());
String content = Files.readString(answer).trim();
TestRunner.assertEquals("1.00", content, "完全相同应输出 1.00");
});
runner.test("答案格式为两位小数", () -> {
checker.check(original.toString(), plagiarized.toString(), answer.toString());
String content = Files.readString(answer).trim();
TestRunner.assertTrue(content.matches("\\d\\.\\d{2}"), "答案应为两位小数,实际=" + content);
});
3. 测试的函数与构造测试数据的思路
- 测试覆盖
CosineSimilarityCalculator.compute、TextNormalizer.normalize / ngrams、PlagiarismChecker.check、CliArguments.parse四个核心函数; - 构造数据的思路采用等价类划分 + 边界值:完全一致、完全无关、作业样例、空文本对空文本、空文本对非空文本、带标点空白、英文大小写、单字符、部分重复、
null输入、对称性等类别,各自取一个代表数据,确保程序能正确处理各种情况; - 最终共 22 个测试用例全部通过。
4. 测试运行结果
运行测试运行器 TestRunner,22 个测试用例全部通过,输出如下:


5. 测试覆盖率
使用 JaCoCo 对单元测试做覆盖率统计,各项覆盖率指标如下:
- 类覆盖率(Class)88%(覆盖 8 / 共 9)
- 方法覆盖率(Method)76%(覆盖 19 / 共 25)
- 行覆盖率(Line)77%(覆盖 70 / 共 90)
- 分支覆盖率(Branch)83%(覆盖 62 / 共 74)
核心逻辑类覆盖率较高:PlagiarismChecker、CosineSimilarityCalculator、TextNormalizer 以及 4 个异常类均在 93% 以上(其中 PlagiarismChecker 与异常类达到 100%);CliArguments 因仅覆盖部分方法与分支路径而覆盖率偏低。
覆盖率报告截图如下:

五、计算模块部分异常处理说明
程序定义了统一的异常体系,共 4 个类,每个异常都有明确的设计目标与对应的单元测试。
1. PlagiarismException(基类)
- 设计目标:作为所有业务异常的父类,统一异常体系,使
Main顶层只需捕获这一个类型即可兜底所有已知错误。 - 对应场景:上层统一 catch 的兜底点。
2. InvalidArgumentException
- 设计目标:拦截非法的命令行参数(个数不为 3 或路径为空),在程序读文件之前就失败,避免带错误参数继续运行。
- 错误场景:用户少传/多传参数,或传入空路径。
- 单元测试样例:
runner.test("参数个数错误抛出 InvalidArgumentException", () ->
TestRunner.assertThrows(InvalidArgumentException.class,
() -> CliArguments.parse(new String[]{"a", "b"}),
"参数个数不为 3 应抛出 InvalidArgumentException"));
3. FileReadException
- 设计目标:把底层的
IOException统一包装成可读的业务异常,明确错误是"读取输入文件失败"。 - 错误场景:原文或抄袭版文件不存在、无读取权限。
- 单元测试样例:
runner.test("读取不存在的原文抛出 FileReadException", () ->
TestRunner.assertThrows(FileReadException.class,
() -> checker.check(tempDir.resolve("none.txt").toString(),
plagiarized.toString(), answer.toString()),
"原文不存在应抛出 FileReadException"));
4. FileWriteException
- 设计目标:明确错误是"写入答案文件失败",与读文件失败区分开,便于定位。
- 错误场景:答案路径指向目录、或所在目录不可写。
- 单元测试样例:
runner.test("答案路径为目录时抛出 FileWriteException", () ->
TestRunner.assertThrows(FileWriteException.class,
() -> checker.check(original.toString(), plagiarized.toString(), tempDir.toString()),
"答案路径为目录应抛出 FileWriteException"));

浙公网安备 33010602011771号