woshi314

第一次个人编程作业

作业github链接:https://github.com/woshi314/woshi314/tree/main

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS
这个作业要求在哪里 个人项目 - 作业 - 计科24级78班 - 班级博客 - 博客园
这个作业的目标 设计一个基于命令行文件输入输出的论文查重程序,计算原文与抄袭版论文的重复率

一、PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 25
· Estimate 估计这个任务需要多少时间 10 8
Development 开发 210 225
· Analysis 需求分析(包括学习新技术) 35 40
· Design Spec 生成设计文档 15 12
· Design Review 设计复审 10 12
· Coding Standard 代码规范 10 8
· Design 具体设计 25 28
· Coding 具体编码 65 72
· Code Review 代码复审 15 13
· Test 测试(自我测试,修改代码,提交修改) 35 40
Reporting 报告 85 92
· Test Report 测试报告 25 28
· Size Measurement 计算工作量 10 9
· Postmortem & Process Improvement Plan 事后总结,并提出过程改进计划 50 55
合计 600 625

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

2.1 需求拆解

需求:程序从命令行接收三个文件路径(原文、抄袭版、答案),读取两个输入文件,计算重复率,以两位小数的浮点数写入答案文件。

拆解为 5 个可独立实现的步骤:

  1. 校验命令行参数:必须恰好 3 个且非空,在接触文件系统前拦下非法调用
  2. 读取文件:按 UTF-8 读取两个输入文件,兼容带 BOM 的 UTF-8 / UTF-16 编码
  3. 文本预处理:清洗文本(全角转半角、英文统一小写、去标点空白),再切分为可比较的特征
  4. 计算重复率:用 SimHash 文本指纹 + 汉明距离算出 0~1 的重复率
  5. 输出答案:格式化保留两位小数,写入答案文件

2.2 类与函数设计

共 4 个功能类 + 3 个异常类,包结构 cn.itcogon

主要函数 作用
Main maincheckArgsformatRate 程序入口:校验参数、调度"读→算→写"全流程,统一捕获异常
FileUtil readFilewriteFiledecode UTF-8 读写文件;读取时先做 BOM 检测,兼容意外编码
TextProcessor cleansegmenttoBigram 文本清洗、按单字分词(辅助)、生成 bigram 特征(核心)
SimilarityCalculator calculateoverlapRatiosimhashhash64 核心算法:SimHash 指纹、重合度判定、汉明距离求相似度
exception.PaperCheckException 自定义异常基类,把业务异常统一归为一个类型
exception.ArgumentException 命令行参数非法时抛出
exception.FileProcessException 文件不存在、读写失败时抛出

调用关系:Main 依次调用 FileUtil(读写)和 SimilarityCalculator(计算);SimilarityCalculator 依赖 TextProcessor 生成特征;异常类被 MainFileUtil 引用,入口只捕获一次、统一处理。

2.3 流程图

主流程:

flowchart TD A[main 入口] --> B{checkArgs<br>参数校验} B -- 失败 --> E[stderr 打印错误信息<br>正常退出不抛堆栈] B -- 通过 --> C[FileUtil 读取原文与抄袭版] C --> D[SimilarityCalculator<br>计算重复率] D --> F[formatRate 保留两位小数] F --> G[写入答案文件] G --> H[结束] E --> H

相似度计算(SimHash)流程:

flowchart TD A[两篇文本] --> B[TextProcessor.clean 清洗<br>全角转半角 / 去标点 / 统一小写] B --> C[toBigram 生成特征<br>中文相邻两字 + 英文单词] C --> D{两边都为空?} D -- 是 --> E[返回 1.00] D -- 否 --> F{仅一边为空?} F -- 是 --> G[返回 0.00] F -- 否 --> H[统计特征频率] H --> I{特征重合度<br>Jaccard < 0.2?} I -- 是 --> G[返回 0.00] I -- 否 --> J[每个特征 FNV-1a 哈希到 64 位<br>按频率加权累加] J --> K[逐位取符号<br>得到 64 位指纹] K --> L[汉明距离<br>相似度 = 1 - 距离 / 64] L --> M[返回重复率]

2.4 算法的关键

核心算法:SimHash(64 位文本指纹)+ 汉明距离,特征采用字符 bigram。具体四步:

  1. 清洗:正则式遍历把全角字符转半角、英文转小写、标点空白替换为空格。保证"天气晴"和"天气晴!"不会因标点被当成两个不同的文本。
  2. 特征切分:中文按相邻两字切一个特征("天气晴朗"→"天气","气晴","晴朗"),连续英文单词、数字整块保留。相比按单字切,bigram 对"逐字插入干扰字"的抄袭方式敏感得多——实测同一份样例,单字特征给出的重复率是 1.00(干扰字每个只出现一次、权重太小,指纹几乎不变),换成 bigram 后降到 0.91,能真实反映改动量。
  3. 指纹生成:每个特征用自实现的 FNV-1a 64 位哈希映射到 64 个维度,按出现频率加权累加(位为 1 加权重、为 0 减权重),最后每个维度取符号拼成 64 位指纹。指纹与特征出现的先后顺序无关,因此对句子乱序的抄袭版几乎不受影响。
  4. 相似度:两篇指纹异或后统计 1 的个数即汉明距离,相似度 = 1 - 距离 / 64

独到之处:

  • 重合度前置判定:SimHash 的固有缺陷是"完全无关的两篇文本指纹平均只有一半位不同,相似度会落在 0.5 左右"。为此在算指纹前先求两篇特征集合的 Jaccard 重合度,低于 0.2 直接判 0.00(实测完全不同文本输出 0.00),补上了这个短板。
  • 零第三方依赖:分词、哈希(FNV-1a)、指纹全部手写,不引入任何词典或分词库,main.jar 不依赖外部包,在任何 JDK 8+ 环境都能直接运行。
  • 线性复杂度:全文只做两遍扫描(一遍清洗切分、一遍统计频率),对万级字符的论文文本计算耗时在毫秒级,满足评测的 5 秒限制。
  • 边界语义明确:两篇都空视为完全相同(1.00),仅一篇为空视为完全不同(0.00)。

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

3.1 改进耗时

阶段 耗时
安装 JProfiler 并与 IDEA 集成 约 20 分钟
构造大文本、第一次采样、查看 Hot Spots 约 15 分钟
改代码(预设容量、容斥原理、逐字符小写) 约 20 分钟
再跑一次性能对比 + 回归测试 约 15 分钟
合计 约 70 分钟

3.2 分析环境与方法

使用 JProfiler 16.2.1(采样模式,采样间隔 5 ms)对计算模块进行性能分析。为了让采样数据充分,构造了 300 倍于样例的大文本(原文约 8.9 MB、抄袭版约 10.5 MB),并编写 PerfMain 入口循环执行 20 次查重算法,取稳定运行阶段的数据。

3.3 性能分析结果

CPU 调用树如下,SimilarityCalculator.calculate 占据了 99.5% 的 CPU 时间(12,264 ms / 12,329 ms),是绝对的性能瓶颈:

调用树

进一步展开热点方法表(按自身时间排序),前四名全部与集合操作相关:

热点

排名 方法 自身时间 占比 调用来源
1 java.util.HashMap.put 2,655 ms 21% countFrequency(SimHash 词频统计)
2 TextProcessor.toBigram 2,418 ms 19% calculate
3 java.util.HashSet.<init> 2,413 ms 19% overlapRatio(Jaccard 重合度)
4 java.util.ArrayList.add 2,404 ms 19% toBigram 内部

四个热点合计占 78%,本质上是同一个问题:在 300 万元素量级下,ArrayList / HashMap / HashSet 反复动态扩容,每次扩容都要拷贝整个底层数组。此外 String.toLowerCase(5%)和 StringBuilder.setLength(6%)也有一定开销。

3.4 改进思路与具体措施

针对上述热点,做了三项优化:

措施一:预设集合初始容量,消除动态扩容

  • toBigramnew ArrayList<>()new ArrayList<>(cleaned.length()):bigram 数量不超过字符数,一次性分配到位。
  • countFrequencynew HashMap<>()new HashMap<>((int)(grams.size() / 0.75) + 1):按加载因子 0.75 精确计算所需容量。

这里有一个值得记录的细节:最初误写成 grams.size() * 2,结果 HashMap 会把容量向上取到最近的 2 的幂(8,388,608),分配了过大的 table 数组,反而因内存占用和 CPU 缓存失效导致性能下降 6%。修正为 /0.75 后才达到预期效果。这说明预设容量不是越大越好,必须匹配加载因子。

措施二:用容斥原理直接计算并集,省去一个 HashSet

overlapRatio 中构建了三个 HashSet(setAsetBunion),其中 union = new HashSet<>(setA); union.addAll(setB) 会再做一次全量遍历和构建。改进后用容斥原理 |A∪B| = |A| + |B| - |A∩B| 直接算出并集大小,省去第三个 HashSet 的构建和一次 300 万元素的遍历。

措施三:逐字符转小写,省去整串 toLowerCase

clean 方法最后对整串清洗结果调用 toString().toLowerCase(),对 300 万字符的字符串做一次全量转换。改进后在遍历字符时对英文字符直接调用 Character.toLowerCase(c),省去最后一次大字符串操作。

3.5 改进前后对比

在同等预热状态下(文件缓存已热、JIT 已预热),用 PerfMain 循环 20 次测得:

指标 改进前 改进后 变化
20 次总耗时 10,579 ms 8,300 ms -21.5%
单次平均耗时 528 ms 415 ms -113 ms

改进后单次查重(300 倍大文本,约 300 万字符)仅需 415 ms,远低于评测要求的 5 秒上限。三项优化中,预设集合容量贡献最大,容斥原理次之,逐字符转小写主要降低了内存分配压力。

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

使用 JUnit 4 + JaCoCo,共 41 个测试用例,分 4 个测试类。

4.1 测试代码

分词与特征部分(测 TextProcessor 的 bigram 特征,这是算法输入质量的关键):

@Test
public void testToBigramChinese() {
    List<String> grams = TextProcessor.toBigram("天气晴朗");
    assertEquals(3, grams.size());
    assertEquals("天气", grams.get(0));
    assertEquals("气晴", grams.get(1));
    assertEquals("晴朗", grams.get(2));
}

@Test
public void testToBigramPunctuationBreaks() {
    // 标点会断开 bigram,逗号两边不组合
    List<String> grams = TextProcessor.toBigram("今天,天气");
    assertTrue(grams.contains("今天"));
    assertTrue(grams.contains("天气"));
    assertTrue(!grams.contains("天天"));
}

@Test
public void testToBigramEmptyAndNull() {
    assertTrue(TextProcessor.toBigram("").isEmpty());
    assertTrue(TextProcessor.toBigram(null).isEmpty());
}

相似度部分(测 SimilarityCalculator.calculate 在各篡改方式下的输出):

@Test
public void testSampleOfRequirement() {
    String original = "今天是星期天,天气晴,今天晚上我要去看电影。";
    String copy = "今天是周天,天气晴朗,我晚上要去看电影。";
    double rate = SimilarityCalculator.calculate(original, copy);
    assertTrue("同义改写后的重复率应大于0.5,实际为" + rate, rate > 0.5);
}

@Test
public void testAddNoiseChars() {
    // 逐字插入干扰字(add 型抄袭)
    String original = "一位真正的作家永远只为内心写作,只有内心才会真实地告诉他。";
    String copy = "一位真正丽的作家永远医只为内心写腥作,只怖有惠内陆心才会真实地告诉他。";
    double rate = SimilarityCalculator.calculate(original, copy);
    assertTrue("插入干扰字后重复率应大于0.7,实际为" + rate, rate > 0.7);
}

@Test
public void testShuffledOrder() {
    // 句子乱序:SimHash 指纹与顺序无关
    String original = "第一句。第二句。第三句。第四句。第五句。第六句。第七句。第八句。";
    String copy = "第五句。第二句。第八句。第一句。第四句。第七句。第三句。第六句。";
    double rate = SimilarityCalculator.calculate(original, copy);
    assertTrue("乱序后重复率应大于0.8,实际为" + rate, rate > 0.8);
}

端到端(测 Main.main,顺便验证答案文件格式):

@Test
public void testMainEndToEnd() throws Exception {
    Path original = tempFolder.newFile("orig.txt").toPath();
    Path copy = tempFolder.newFile("copy.txt").toPath();
    Path answer = tempFolder.getRoot().toPath().resolve("ans.txt");

    Files.write(original, "今天是星期天,天气晴,今天晚上我要去看电影。"
            .getBytes(StandardCharsets.UTF_8));
    Files.write(copy, "今天是周天,天气晴朗,我晚上要去看电影。"
            .getBytes(StandardCharsets.UTF_8));

    Main.main(new String[]{original.toString(), copy.toString(), answer.toString()});

    assertTrue("答案文件应被创建", Files.exists(answer));
    String result = new String(Files.readAllBytes(answer), StandardCharsets.UTF_8);
    assertTrue("答案格式应为两位小数,实际为:" + result,
            result.matches("0\\.\\d{2}|1\\.00"));
}

4.2 测试函数与构造思路

被测函数 用例数 数据怎么凑的
TextProcessor.clean 4 对着清洗的每个分支凑:全角转半角、英文小写、标点变空格、null 入参
TextProcessor.segment / toBigram 7 中文正常切分、英文数字整块、标点断开 bigram、空串与 null 提前返回
SimilarityCalculator.calculate 11 按方法的判定分支:完全相同(=1)、完全不同(=0)、两边都空(=1)、一边空(=0),再叠加四种篡改方式——同义改写、插入干扰字、删减、乱序,外加对称性、结果越界钳制、单字差异
FileUtil.readFile / writeFile 11 读写往返、UTF-8 中文不乱码、父目录自动创建、文件不存在、路径是文件夹、空路径、null、UTF-8 BOM、UTF-16 BOM
Main.checkArgs / formatRate / main 8 参数个数 1/2/3/0、null、空串、合法参数、格式化 0.856/0.8/1.0/0.0、端到端两次(含嵌套目录与文件不存在)

思路是白盒法:先读源码把每个 if 分支列出来,一个分支给一组数据,保证每条路径都被走到;再补一层边界(空文件、null、越界值),最后用端到端用例验证整条主流程和输出格式。41 个用例覆盖了 4 个核心类的所有公开方法。

4.3 测试覆盖率

使用 IDEA 内置的 Coverage 工具(基于 JaCoCo)对全部 41 个测试用例运行覆盖率统计,结果如下:

idea_coverage

整体行覆盖率 87%(138/157)、分支覆盖率 83%(115/138)。各核心类的覆盖率均处于较高水平:

行覆盖率 分支覆盖率 说明
TextProcessor 98% (54/55) 88% (48/54) 未覆盖行为空文本/null 的提前返回分支
SimilarityCalculator 95% (47/49) 91% (33/36) 未覆盖分支为结果越界钳制的边界情况
Main 100% (12/12) 90% (9/10) 全部行覆盖,仅剩一个异常分支未触发
FileUtil 88% (22/25) 69% (25/36) 未覆盖部分为真实环境中难以稳定复现的 IO 异常分支
PerfMain 0% (0/11) 0% (0/2) 性能分析专用入口,不参与评测,无对应单元测试

未覆盖的指令主要集中在 FileUtil 的 IO 异常分支(写入失败、权限不足等真实环境难以稳定复现的场景)和异常类的部分构造方法,属于可接受的测试盲区。

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

程序共定义 3 个异常类,都继承自 RuntimeException,位于 cn.itcogon.exception 包。统一由 Main 入口捕获一次、打印错误信息后正常退出,不向 JVM 抛出堆栈(评测规则要求程序不能"异常退出")。

5.1 PaperCheckException(异常基类)

  • 设计目标:作为所有业务异常的公共父类型,让入口只需 catch 这一次。以后需要新增异常类型时继承它即可,Main 的捕获逻辑不用改动。
  • 对应场景:不会直接被抛出,它是下面两个异常的共同类型。

5.2 ArgumentException(命令行参数异常)

  • 设计目标:在接触文件系统之前拦截非法调用——参数个数不是 3 个、参数为 null 或空字符串时抛出,避免把"参数没传对"误报成"文件不存在"。
  • 对应场景java -jar main.jar C:\tests\orig.txt(只传了 1 个参数,缺少抄袭版和答案路径)。
  • 测试
@Test
public void testCheckArgsWrongCount() {
    assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[]{"a.txt"}));
    assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[]{"a.txt", "b.txt"}));
    assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[0]));
}

@Test
public void testCheckArgsNull() {
    assertThrows(ArgumentException.class, () -> Main.checkArgs(null));
}

@Test
public void testCheckArgsEmptyString() {
    assertThrows(ArgumentException.class, () -> Main.checkArgs(new String[]{"", "b.txt", "c.txt"}));
}

5.3 FileProcessException(文件读写异常)

  • 设计目标:文件相关的所有错误统一走这个类型——文件不存在、路径指向文件夹、路径为空、读取或写入失败。异常信息中带上具体路径,方便定位是哪个文件出了问题。
  • 对应场景:原文路径写错,比如实际文件叫 orig.txt 却传成了 org.txt
  • 测试
@Test(expected = FileProcessException.class)
public void testReadNotExistFile() {
    FileUtil.readFile(tempFolder.getRoot().getAbsolutePath() + "\\no-such-file.txt");
}

@Test(expected = FileProcessException.class)
public void testReadDirectoryAsFile() {
    FileUtil.readFile(tempFolder.getRoot().getAbsolutePath());
}

@Test(expected = FileProcessException.class)
public void testReadEmptyPath() {
    FileUtil.readFile("");
}

5.4 入口统一处理

Main.main 中对整个流程包一层 try-catch:任何异常只打印提示信息到 stderr,程序正常返回。对应测试用"文件不存在"的端到端场景验证程序不会把异常传播到外层:

@Test
public void testMainFileNotExist() {
    // main 内部已捕获异常,这里验证调用不会把异常抛到外层
    Main.main(new String[]{
            tempFolder.getRoot().getAbsolutePath() + "\\no-such.txt",
            tempFolder.getRoot().getAbsolutePath() + "\\no-such2.txt",
            tempFolder.getRoot().getAbsolutePath() + "\\ans.txt"
    });
    // 到达这里说明 main 正常返回,没有异常传播
    assertTrue(true);
}

posted on 2026-09-15 21:41  woshi314  阅读(10)  评论(0)    收藏  举报

导航