第一次个人编程作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693
这个作业的目标 <完成论文查重项目>

本次作业的GitHub链接:cxyu-333/3224004190

image

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 系统架构图:

image

2.3 模块职责:

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

2.4 核心算法流程图

image

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");
    }
}


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

image
从 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 优化后

image

image

对代码进行上面的优化后,重新使用 Profiler 采集性能数据,对比结果如下:

指标 优化前 优化后 提升幅度
总耗时 39,884 ms 24,879 ms 下降 37.6%
集合拷贝开销(HashSet. + addAll) 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

测试成功截图:
image

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 测试覆盖率

image

4.5 自动化测试

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

自动化测试成功截图:
image

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表格

image

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

image

posted @ 2026-09-15 18:00  cxyu333  阅读(10)  评论(0)    收藏  举报