第一次个人编程作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | <完成论文查重项目> |
本次作业的GitHub链接:cxyu-333/3224004190

1. 算法
1.1 算法选择
根据题目要求我查询了网上资料,这个论文查重本质上应该算是一个文本相似度检测问题。
有以下几种算法可以选择:
| 算法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 编辑距离(Levenshtein) | 计算将一个字符串变为另一个所需的最少单字符编辑操作次数 | 能精确衡量两个字符串的差异程度 | 时间复杂度 O(m×n),长文本计算极慢 | 短字符串比较(如单词拼写检查) |
| 余弦相似度 | 将文本转化为词频向量,计算向量夹角余弦值 | 能捕捉全局主题相似性 | 需要分词和构建词表,忽略局部语序 | 文档主题分类、推荐系统 |
| SimHash | 将文本映射为固定长度指纹,通过海明距离比较 | 计算速度快,适合海量文本去重 | 对局部修改不敏感,精度较低 | 搜索引擎网页去重 |
| 最长公共子序列(LCS) | 找出两个序列的最长公共子序列 | 能反映文本的结构相似性 | 时间复杂度 O(m×n),长文本性能差 | 代码版本对比(如 git diff) |
| N-gram + Jaccard | 将文本切分为 N-gram 片段集合,用 Jaccard 系数衡量集合重叠度 | 实现简单、无需分词、对局部修改敏感、计算效率高 | 对完全打乱顺序的文本效果有限 | 文本查重、抄袭检测 |
经过对比,我选择了N-gram + Jaccard 相似度来完成这次作业。理由如下:
- 实现简洁:不需要外部词典或分词工具,只是字符级操作,代码量比较少。
- 适合中文场景:字符级 N-gram 适合中文(无需分词),bigram(n=2)能保留相邻字符的顺序信息。
- 对局部修改敏感:N-gram 的滑动窗口可以捕捉到文本中的局部的相似片段,即使抄袭者只是做了少量增删改,也能检测到相似性。
1.2 算法关键步骤
输入:原文文本 origText,抄袭版文本 copyText,N-gram 粒度 n
步骤1:预处理 —— 去除两个文本中的所有空白字符(空格、换行、制表符) 步骤2:N-gram 切分 —— 用长度为 n 的滑动窗口对文本进行切分,得到两个字符串集合 set1 和 set2 步骤3:计算交集大小 —— 遍历较小的集合,逐个检查是否存在于较大集合中,统计交集元素个数 步骤4:计算并集大小 —— 利用公式 |A∪B| = |A| + |B| - |A∩B| 步骤5:计算 Jaccard 相似度 —— 相似度 = 交集大小 / 并集大小
输出:[0.0, 1.0] 范围内的 double 值,保留两位小数
1.3 算法独到之处(优化后)
- 零拷贝交集计算:常规实现会通过
new HashSet<>(set1)创建集合副本再调用retainAll求交集,本程序改为直接遍历较小集合进行contains查找并计数,避免了集合拷贝,内存占用减半 - 公式法求并集:常规实现会通过
addAll显式计算并集集合,本程序利用集合论公式|A∪B| = |A| + |B| - |A∩B|直接计算并集大小,减少一次集合操作 - 手动空白字符过滤:常规实现使用
replaceAll("\\s+", "")正则表达式去除空白字符,本程序改用Character.isWhitespace()逐字符判断,避免正则编译和执行开销
2. 整体架构设计
2.1 架构风格:
采用分层管道式架构,程序按"输入 → 处理 → 输出"的线性流程执行。
2.2 系统架构图:

2.3 模块职责:
| 模块 | 类名 | 职责 |
|---|---|---|
| 入口模块 | Main | 解析命令行参数,串联"读文件 → 算相似度 → 写结果"主流程 |
| 文件工具模块 | FileUtil | 封装文件读写操作,屏蔽底层 I/O 细节 |
| 算法模块 | SimilarityCalculator | 实现 N-gram 切分与 Jaccard 相似度计算核心逻辑 |
2.4 核心算法流程图

2.5 类设计:
2.5.1 Main(主程序入口)
- 为程序唯一入口,接收 3 个命令行参数:<原文文件> <抄袭版文件 <答案文件>
- 参数校验:若参数数量不等于 3,打印用法提示并退出(exit(1))
- 异常处理:捕获 IOException,打印错误信息并退出
2.5.2 FileUtil(文件工具类)
| 方法 | 功能 | 输入 | 输出 |
|---|---|---|---|
| readFile | 读取指定路径文件的全部内容为字符串 | 文件路径 | UTF-8 解码后的文本字符串 |
| writeAnswer | 将相似度结果格式化后写入文件 | 文件路径 + 相似度值 | 保留两位小数的字符串文件 |
- readFile 使用 Files.readAllBytes() 一次性读入,适用于中小规模文本文件
- writeAnswer 使用 String.format("%.2f", similarity) 保留两位小数精度
2.5.3 SimilarityCalculator(相似度计算器)
| 方法 | 可见性 | 功能 |
|---|---|---|
| calculateSimilarity | public / static | 计算两篇文本的 Jaccard 相似度,返回 [0.0, 1.0] 范围内的 double 值 |
| generateNGramSet | private / static | 将文本按 N-gram 方式切分为 Set 集合,内部辅助方法 |
3. 性能
3.1 性能分析
使用idea内置Profiler工具进行性能测试。
指标:cpu time
测试方法:编写了PerformanceTest类来循环调用calculateSImilarity方法10000次,使Profiler 能够采集到足够的性能数据。
测试文件:test/orig_0.8_add.txt和test/orig.txt
package com.cxyu333.checker;
import java.io.IOException;
public class PerformanceTest {
public static void main(String[] args) throws IOException {
String baseDir = "3224004190/";
String origText = FileUtil.readFile(baseDir + "test/orig.txt");
String copyText = FileUtil.readFile(baseDir + "test/orig_0.8_add.txt");
int iterations = 10000;
System.out.println("开始性能测试,循环 " + iterations + " 次...");
long startTime = System.currentTimeMillis();
for (int i = 0; i < iterations; i++) {
SimilarityCalculator.calculateSimilarity(origText, copyText, 2);
}
long endTime = System.currentTimeMillis();
System.out.println("总耗时: " + (endTime - startTime) + " ms");
System.out.println("平均每次: " + (endTime - startTime) * 1.0 / iterations + " ms");
}
}

从火焰图可以看出,程序的 CPU 时间主要集中在 calculateSimilarity 方法内部,其中 generateNGramSet 和交集/并集计算(HashSet.<init>、addAll、retainAll)占据了绝大部分宽度,是主要的性能瓶颈。

从 Call Tree 的 CPU Time 数据可以得出以下结论:
| 方法 | 耗时 (ms) | 占比 | 分析 |
|---|---|---|---|
PerformanceTest.main |
39,884 | 98.7% | 程序总耗时 |
calculateSimilarity |
39,835 | 98.6% | 几乎全部时间花在相似度计算上 |
generateNGramSet |
19,144 | 47.4% | 最大瓶颈:N-gram 生成 |
HashSet.add |
15,333 | 38.0% | 集合插入操作耗时最多 |
String.substring |
2,270 | 5.6% | 字符串截取 |
String.replaceAll |
987 | 2.4% | 正则表达式去除空白字符 |
HashSet.<init> |
8,344 | 20.7% | 为计算交集拷贝整个集合 |
AbstractCollection.addAll |
7,609 | 18.8% | 为计算并集再次拷贝集合 |
AbstractCollection.retainAll |
4,726 | 11.7% | 计算交集 |
3.2 性能瓶颈总结:
瓶颈一:generateNGramSet 方法(47.4%)
该方法负责将文本切分为 N-gram 集合并存入 HashSet。其中 HashSet.add 占 38.0%,是因为每次滑动窗口都创建一个新的 String 对象并计算哈希值插入集合,当文本较大时会产生大量临时对象,带来较高的 CPU 和 GC 开销。此外,使用 replaceAll("\\s+", "") 正则表达式去除空白字符也存在不必要的开销。
瓶颈二:交集/并集计算时的集合拷贝(合计约 39.5%)
原实现中,为了计算交集和并集,分别通过 new HashSet<>(set1) 创建了 set1 的两个完整拷贝,然后对拷贝执行 retainAll 和 addAll 操作。这导致大量不必要的内存分配和数据拷贝,HashSet.<init> 和 addAll 合计耗时占比约 39.5%。
3.3 优化方向
| 瓶颈 | 优化方案 | 预期效果 |
|---|---|---|
generateNGramSet 中 replaceAll 正则开销 |
改用 StringBuilder + Character.isWhitespace() 逐字符判断 |
减少正则编译和执行开销 |
| 交集/并集计算时的集合拷贝 | 改为遍历较小集合,逐个检查是否存在于较大集合中,直接计数交集大小;并集大小通过公式 |A∪B| = |A| + |B| - |AB| 计算 |
消除集合拷贝,内存占用减半 |
3.4 进行优化
package com.cxyu333.checker;
import java.util.HashSet;
import java.util.Set;
public class SimilarityCalculator {
/**
* 计算两个文本重复率
* @param origText 原文
* @param copyText 抄袭版
* @return 重复率,保留两位小数
*/
public static double calculateSimilarity(String origText, String copyText,int n) {
//1. N-gram划分
Set<String> set1 = generateNGramSet(origText, n);
Set<String> set2 = generateNGramSet(copyText, n);
//边界情况:切出来集合为空,返回0
if (set1.isEmpty() && set2.isEmpty()) {
return 1.0;
}
if (set1.isEmpty() || set2.isEmpty()) {
return 0.0;
}
//2. Jaccard 算交并比
// //2.1 计算交集大小
// Set<String> intersection = new HashSet<>(set1);
// intersection.retainAll(set2);
// //2.2 计算并集大小
// Set<String> union = new HashSet<>(set1);
// union.addAll(set2);
// //2.3 返回交并比
// return (double) intersection.size() / union.size();
//优化后代码,遍历较小集合计算交集,避免拷贝整个集合
Set<String> smaller = set1.size() <= set2.size() ? set1 : set2;
Set<String> larger = set1.size() > set2.size() ? set1 : set2;
int intersectionCount = 0;
for (String ngram : smaller) {
if (larger.contains(ngram)) {
intersectionCount++;
}
}
int unionCount = set1.size() + set2.size() - intersectionCount;
return (double) intersectionCount / unionCount;
}
/**
* N-gram划分方法,将文本按每n个字符滑动切分,返回Set集合
*/
private static Set<String> generateNGramSet(String text, int n) {
Set<String> ngramSet = new HashSet<>();
// //去除空白字符(空格、换行等)
// String cleanText = text.replaceAll("\\s+", "");
// //滑动窗口切分
// for (int i = 0; i <= cleanText.length() - n; i++){
// String ngram = cleanText.substring(i, i + n);
// ngramSet.add(ngram);
// }
// 用String Builder替代正则表达式去除空白字符
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (!Character.isWhitespace(c)) {
sb.append(c);
}
}
int len = sb.length();
for (int i = 0; i <= len - n; i++) {
ngramSet.add(sb.substring(i, i + n));
}
return ngramSet;
}
}
3.5 优化后


对代码进行上面的优化后,重新使用 Profiler 采集性能数据,对比结果如下:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 总耗时 | 39,884 ms | 24,879 ms | 下降 37.6% |
| 集合拷贝开销(HashSet. |
15,953 ms (39.5%) | 555 ms (2.2%) | 下降 96.5% |
| 交集计算(retainAll) | 4,726 ms (11.7%) | 已消除 | 消除 |
| 正则去空白(replaceAll) | 987 ms (2.4%) | 已消除 | 消除 |
4. 测试
本项目使用JUnit4作为单元测试框架,对 SimilarityCalculator、FileUtil、Main 三个核心类进行单元测试。
4.1 测试用例
共设计了 18 个测试用例,覆盖正常功能、边界情况、异常处理三大类:
| 编号 | 测试类 | 测试方法 | 测试场景 | 预期结果 |
|---|---|---|---|---|
| TC-01 | SimilarityCalculatorTest | testIdenticalTexts | 两文本完全相同 | 相似度 = 1.0 |
| TC-02 | SimilarityCalculatorTest | testCompletelyDifferentTexts | 两文本完全不同 | 相似度 = 0.0 |
| TC-03 | SimilarityCalculatorTest | testPartiallySimilarTexts | 两文本部分相同 | 0 < 相似度 < 1 |
| TC-04 | SimilarityCalculatorTest | testSingleCharTexts | 文本长度小于 n | 返回 1.0(两空集) |
| TC-05 | SimilarityCalculatorTest | testOneEmptyOneNonEmpty | 一空一非空 | 返回 0.0 |
| TC-06 | SimilarityCalculatorTest | testBothEmptyStrings | 两文本均为空 | 返回 1.0 |
| TC-07 | SimilarityCalculatorTest | testWhitespaceHandling | 含空格文本 | 去除空格后比较 |
| TC-08 | SimilarityCalculatorTest | testNewlineHandling | 含换行符文本 | 去除换行后比较 |
| TC-09 | SimilarityCalculatorTest | testTrigram | n=3 trigram | 相同文本返回 1.0 |
| TC-10 | SimilarityCalculatorTest | testUnigram | n=1 unigram | 相似度 > 0.5 |
| TC-11 | SimilarityCalculatorTest | testTextLengthEqualsN | 文本长度恰好等于 n | 返回 1.0 |
| TC-12 | SimilarityCalculatorTest | testOnlyWhitespace | 仅空白字符 | 返回 1.0 |
| TC-13 | SimilarityCalculatorTest | testEnglishText | 英文文本 | 相同返回 1.0 |
| TC-14 | SimilarityCalculatorTest | testCaseSensitive | 大小写敏感 | 大小写不同结果不同 |
| TC-F01 | FileUtilTest | testReadFile | 正常读取文件 | 内容非空 |
| TC-F02 | FileUtilTest | testWriteAnswer | 正常写入文件 | 写入内容正确 |
| TC-F03 | FileUtilTest | testWriteAnswerPrecision | 写入精度 | 保留两位小数 |
| TC-F04 | FileUtilTest | testReadNonExistentFile | 读取不存在的文件 | 抛出 IOException |
| TC-M01 | MainTest | testNoArguments | 无命令行参数 | 参数数量不等于 3 |
| TC-M02 | MainTest | testTooManyArguments | 参数过多(4个) | 参数数量不等于 3 |
| TC-M03 | MainTest | testCorrectArgumentCount | 参数正确(3个) | 参数数量等于 3 |
| TC-M04 | MainTest | testFileNotFound | 文件不存在 | 抛出 IOException |
测试成功截图:

4.2 SimilarityCalculator 核心算法测试
部分代码展示
/**
* TC-01: 两文本完全相同,相似度应为 1.0
*/
@Test
public void testIdenticalTexts() {
double result = SimilarityCalculator.calculateSimilarity("你好世界", "你好世界", 2);
assertEquals(1.0, result, 0.001);
}
/**
* TC-02: 两文本完全不同,相似度应为 0.0
*/
@Test
public void testCompletelyDifferentTexts() {
double result = SimilarityCalculator.calculateSimilarity("你好世界", "他好宇宙", 2);
assertEquals(0.0, result, 0.001);
}
/**
* TC-03: 两文本部分相同,相似度应在 (0, 1) 之间
*/
@Test
public void testPartiallySimilarTexts() {
double result = SimilarityCalculator.calculateSimilarity("abcdef", "abcxyz", 2);
assertTrue("相似度应在0到1之间", result > 0.0 && result < 1.0);
}
构造测试文本思路:
- 相同文本:传入相同字符串,验证相似度1.0
- 完全不同文本:传入无共同字符字符串,验证相似度0.0
- 部分相同文本:相似度0 1之间
- 边界情况:空字符串、单字符、纯空白字符,验证边界处理逻辑
- 空白字符处理:文本中插入空格、换行符,验证预处理是否正确去除空白
4.3 FileUtil文件操作测试
核心代码
/**
* TC-F01: 正常读取文件
*/
@Test
public void testReadFile() throws IOException {
String content = FileUtil.readFile("src/test/orig.txt");
assertNotNull("读取内容不应为null", content);
assertFalse("读取内容不应为空", content.isEmpty());
}
/**
* TC-F02: 正常写入文件
*/
@Test
public void testWriteAnswer() throws IOException {
FileUtil.writeAnswer(TEST_WRITE_PATH, 0.85);
String content = FileUtil.readFile(TEST_WRITE_PATH);
assertEquals("0.85", content);
}
/**
* TC-F03: 写入精度测试(保留两位小数)
*/
@Test
public void testWriteAnswerPrecision() throws IOException {
FileUtil.writeAnswer(TEST_WRITE_PATH, 0.123456);
String content = FileUtil.readFile(TEST_WRITE_PATH);
assertEquals("0.12", content);
}
/**
* TC-F04: 读取不存在的文件应抛出异常
*/
@Test(expected = IOException.class)
public void testReadNonExistentFile() throws IOException {
FileUtil.readFile("src/test/not_exist_file.txt");
}
构造测试数据的思路:
- 使用老师提供的测试文件
orig.txt作为真实文件测试读取 - 写入临时文件后读取验证内容一致性
- 传入不存在的路径验证异常处理
4.4 测试覆盖率

4.5 自动化测试
本项目使用 Maven + JUnit 4 实现单元测试自动化,任何人只需一条命令即可运行全部测试:
通过命令行进入到\cxyu-333\3224004190目录再输入mvn test即可自动完成所有测试。
自动化测试成功截图:

4.6 测试设计自我评价
本项目的单元测试主要采用以下白盒测试方法:
1. 语句覆盖
确保 calculateSimilarity 和 generateNGramSet 中的每一条语句都被至少一个测试用例执行到。
例如:TC-01(相同文本)覆盖了正常计算路径,TC-06(两空串)覆盖了 set1.isEmpty() && set2.isEmpty() 分支。
2. 分支覆盖
针对代码中的每个 if/else 分支设计测试用例:
| 分支条件 | True 分支用例 | False 分支用例 |
|---|---|---|
set1.isEmpty() && set2.isEmpty() |
TC-06(两空串) | TC-01(正常文本) |
set1.isEmpty() || set2.isEmpty() |
TC-05(一空一非空) | TC-01(正常文本) |
set1.size() <= set2.size() |
TC-03(set1较小) | TC-03(交换参数顺序) |
!Character.isWhitespace(c) |
TC-07(含空格) | TC-01(无空格) |
larger.contains(ngram) |
TC-03(部分相同) | TC-02(完全不同) |
3. 边界值分析
针对 N-gram 切分的边界条件设计用例:
- 文本长度 = 0(空串)→ TC-05、TC-06
- 文本长度 = 1(小于 n)→ TC-04
- 文本长度 = n(恰好等于 n)→ TC-11
- 文本仅含空白字符 → TC-12
已覆盖的场景:
- 正常功能:相同文本、完全不同、部分相同
- 边界情况:空串、单字符、纯空白、长度恰好等于 n
- 预处理:空格、换行符的去除
- 不同 N-gram 粒度:n=1、n=2、n=3
- 文件操作:正常读写、精度验证、文件不存在异常
- 参数校验:参数不足、参数过多、参数正确
可能不足的方面:
- 未测试超大文本(如 10MB 以上)的性能和内存表现
- 未测试特殊字符(emoji、Unicode 特殊符号)的处理
总体评价: 当前 22 个测试用例覆盖了程序的主要功能路径和边界条件,分支覆盖率和语句覆盖率均较高,能够满足本程序的基本测试要求。对于课程作业要求的至少 10 个测试点,是满足的。
5. 异常处理说明
5.1 参数数量异常
设计目标:防止用户在未提供足够的参数时程序静默失败。
触发场景:用户运行java -jar main.jar未传入任何参数。
处理方式:打印用法提示 用法:java -jar main.jar <原文文件> <抄袭版文件> <答案文件>,以退出码 1 退出。
测试用例:TC-M01(无参数)、TC-M02(参数过多)。
// Main.java 中的参数校验
if (args.length != 3) {
System.out.println("用法:java -jar main.jar <原文文件> <抄袭版文件> <答案文件>");
System.exit(1);
}
5.2 文件读写异常(IOException)
设计目标: 当文件不存在、无权限读取、磁盘错误等情况时,程序应报告错误而非崩溃。
触发场景:
- 文件路径不存在
- 文件被其他程序占用
- 磁盘I/O 错误
处理方式: 捕获IOException,打印错误信息,以退出码 1 退出。
测试用例:TC-F04(读取不存在的文件)、TC-M04(文件不存在)
// Main.java 中的异常捕获
try {
String origText = FileUtil.readFile(origFile);
String copyText = FileUtil.readFile(copyFile);
// ...
} catch (IOException e) {
System.out.println("文件读写错误:" + e.getMessage());
System.exit(1);
}
5.3 空文本边界异常
设计目标: 当文本为空或仅含空白字符时,N-gram 切分结果为空集,需特殊处理避免除零错误。
触发场景:
- 传入空文件
- 文件内容仅为空格/换行
处理方式: 两空集返回 1.0(视为相同),一空一非空返回 0.0。
测试用例: TC-04(单字符)、TC-05(一空一非空)、TC-06(两空串)、TC-12(纯空白)
// SimilarityCalculator.java 中的边界处理
if (set1.isEmpty() && set2.isEmpty()) {
return 1.0; // 两空集视为相同
}
if (set1.isEmpty() || set2.isEmpty()) {
return 0.0; // 一空一非空视为完全不同
}
5.4 N-gram 切分越界防护
设计目标: 当文本去除空白后长度小于 n 时,滑动窗口无法切分,需确保不产生数组越界。
触发场景: 文本去除空白后仅剩 1 个字符,但 n=2。
处理方式: generateNGramSet 中循环条件 i <= len - n,当 len < n 时循环不执行,返回空集合,由上层边界处理逻辑处理。
对应测试用例: TC-04(单字符文本,长度小于 n)
6. 补充
6.1 PSP表格

6.2 已将编译好的程序发布到Github仓库中的releases


浙公网安备 33010602011771号