第一次个人编程作业

第一次个人编程作业

这个作业属于哪个课程 首页 - 计科24级78班 - 广东工业大学 - 班级博客 - 博客园
这个作业要求在哪里 个人项目 - 作业 - 计科24级78班 - 班级博客 - 博客园
这个作业的目标 独立完成一次完整的软件工程作业

语言 / 环境:Java(JDK 17)。入口为打包后的 main.jar,运行方式:
java -jar main.jar [原文文件] [抄袭版论文的文件] [答案文件]

给定一份「原文」和在其基础上经过增、删、改(含乱序、同义替换)得到的「抄袭版」,
程序从命令行读取两个文件的绝对路径,计算两者的重复率,并把结果(浮点型,保留两位小数)
写入指定的答案文件。


一、准备

1. 我的 GitHub 项目链接

https://github.com/gigittty/paper-plagiarism

仓库根目录下为学号文件夹 3124004444;编译好的可执行程序 main.jar 同时发布在仓库的 Releases(v1.0) 中。

2. PSP 表格

PSP2.1(个人开发流程)预估与实际耗时如下(单位:分钟):

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning(计划) / /
· Estimate · 估计这个任务需要多少时间 8 8
Development(开发) 148 180
· Analysis · 需求分析(包括学习新技术:jieba、余弦相似度、JUnit5) 15 20
· Design Spec · 生成设计文档 10 12
· Design Review · 设计复审 8 8
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 5 5
· Design · 具体设计(类 / 函数设计、算法选型) 15 18
· Coding · 具体编码 60 75
· Code Review · 代码复审(javac -Xlint / 静态检查) 10 12
· Test · 测试(自我测试,修改代码,提交修改) 25 30
· Performance · 性能改进(JFR + 打点分析、优化,额外记录) 25
Reporting(报告) 23 23
· Test Report · 测试报告 10 10
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 8 8
合计 179 236

3. 需求分析

  • 本系统旨在实现一个论文查重工具,用于评估两篇文本的相似程度。程序通过命令行接收原文文件路径、抄袭版论文文件路径、答案输出文件路径三个参数。
  • 功能上,系统需自动读取文本、对中文进行分词、提取词频特征,计算两篇文本的相似比例,最终把结果以保留两位小数的浮点数写入答案文件。
  • 非功能层面,程序运行时间需严格控制在 5 秒以内,内存占用不得超过 2048MB,在遇到文件不存在或参数缺失等情况时进行异常拦截并安全退出;程序不联网、不读写参数以外的文件
  • 功能可划分为:
    • 基础功能:命令行参数解析、文件读取 / 写出、中文分词、词频向量、余弦相似度、重复率输出;
    • 扩展功能:无第三方依赖时的字符二元(2-gram)分词降级、自定义异常体系与统一错误处理、单元测试与覆盖率、性能分析。

4. 代码设计

  • 系统整体采用面向对象结构设计,由若干职责单一的类 + 一个主入口构成:
    • FileIO 类负责文件磁盘交互(读取原文 / 抄袭版、写出答案);
    • Tokenizer 类负责中文分词(优先 jieba,降级为字符 2-gram);
    • CosineSimilarity 类负责核心算法(词频向量构造 + 余弦相似度);
    • PlagiarismException(及 FileReadException / FileWriteException / InvalidArgsException)构成统一异常体系;
    • 主入口 Main 负责校验命令行入参并调度上述各类完成全流程。
  • 算法选用词频向量的余弦相似度,具备对局部增删语序置换稳健的特性。
  • 此外,系统针对文件缺失、参数错误等设计了三类自定义异常,并配备了 29 个覆盖常规与边界的 JUnit 5 单元测试用例。

二、代码分析

1. 计算模块接口的设计与实现过程

(1)模块组织与类结构设计

程序由 4 个职责类 + 1 个异常基类 + 3 个异常子类 + 1 个入口 构成:

  • Main(入口类):
    • main(String[]):程序入口。
    • run(String[]):串起「读取 → 分词 → 相似度 → 写出」流程,并统一捕获异常、返回受控退出码。
    • parseArgs(String[]):校验并返回 3 个路径参数,个数不为 3 时抛出 InvalidArgsException
  • FileIO(文件操作类):
    • readText(String):接收文件路径,使用 UTF-8 读取并返回全部文本;文件不存在 / 路径为空时抛 FileReadException
    • writeAnswer(String, String):把结果字符串写入答案文件(UTF-8);目标为目录 / 无写权限时抛 FileWriteException
  • Tokenizer(分词类):
    • tokenize(String):优先使用 jieba 分词;jieba 不可用时自动降级为字符 2-gram。
    • tokenizeByBigram(String):纯字符二元切分(降级通道),并对结果去噪。
  • CosineSimilarity(查重计算类):
    • termFrequency(List<String>):统计词频,构造词频向量(Map<词, 词频>)。
    • cosine(List<String>, List<String>):计算两段文本的余弦相似度,返回 0.0 ~ 1.0。

类与函数之间的调用关系

classDiagram class Main { +main(String[]) void +run(String[]) int +parseArgs(String[]) String[] } class FileIO { +readText(String) String +writeAnswer(String, String) void } class Tokenizer { +tokenize(String) List~String~ +tokenizeByBigram(String)$ List~String~ } class CosineSimilarity { +cosine(List~String~, List~String~)$ double +termFrequency(List~String~)$ Map } class PlagiarismException class FileReadException class FileWriteException class InvalidArgsException Main --> FileIO Main --> Tokenizer Main --> CosineSimilarity PlagiarismException <|-- FileReadException PlagiarismException <|-- FileWriteException PlagiarismException <|-- InvalidArgsException FileIO ..> FileReadException FileIO ..> FileWriteException
  • 主函数 Main.run() 从命令行获取 3 个路径参数,先调用 FileIO.readText() 读取原文与抄袭版文本;
    随后调用 Tokenizer.tokenize() 分词、CosineSimilarity.termFrequency() 构造词频向量并由 cosine() 计算相似度;
    最后由 FileIO.writeAnswer()sim × 100(保留两位小数)写入答案文件。

(2)关键函数流程图(run / compute_similarity)

核心流程 Main.run 的执行流程如下:

flowchart TD A["main(args)"] --> B{"参数个数 = 3 ?"} B -- 否 --> C["抛 InvalidArgsException<br/>返回退出码 2"] B -- 是 --> D["FileIO.readText(原文路径)"] D --> E["FileIO.readText(抄袭版路径)"] E --> F["Tokenizer.tokenize<br/>(jieba 优先 / 2-gram 降级)"] F --> G["CosineSimilarity.termFrequency<br/>构造词频向量"] G --> H["CosineSimilarity.cosine<br/>仅在交集词上求点积"] H --> I["String.format(%.2f, sim × 100)"] I --> J["FileIO.writeAnswer(答案路径)<br/>返回退出码 0"]

若不支持 Mermaid 渲染,可用下面这条等价文字流程代替:
main → 校验参数 → 读原文 → 读抄袭版 → 分词 → 词频向量 → 余弦相似度 → 格式化 → 写答案 → 退出码 0

(3)算法的关键要素

算法核心是词频向量的余弦相似度

sim(A, B) = (A · B) / (|A| × |B|)
重复率(%) = sim × 100     (保留两位小数)
  • 词频统计:通过分词获取词语,记录每个词出现的次数,作为该词在向量中的权重。
  • 向量构造:把两段文本分别表示为「词 → 词频」的稀疏向量。
  • 余弦度量:仅在两份文本的共有词上求点积(遍历较小的词频表),再除以两个向量模长的乘积。

(4)算法的技术特征

  • 对增删稳健:文本中局部增删少量词时,共有词的点积与模长的变化都较小,相似度仍保持较高。
  • 语序无关性:算法仅根据分词结果与词频计算,不依赖词语的排列顺序。调换句子语序不改变词频统计,相似度保持不变(样例 orig_dis_1 即为 100.00)。
  • 同义替换可识别:把部分词替换为同义词会明显改变词频分布,相似度随之下降(样例 orig_rep 降至 76.91)。
  • 计算复杂度:特征提取为 O(N)(N 为文本长度),两篇比对近似 O(V)(V 为词表规模),单次运行在毫秒级。

独到之处

  1. jieba + 降级双通道分词:优先使用 jieba 分词(与主流参考实现一致,词典打包在 jar 内,运行期不联网、不读写参数以外的文件);若运行环境未提供 jieba,则自动降级为字符 2-gram 分词,保证程序在零第三方依赖时仍能编译运行。
  2. 过滤无语义词元:jieba 会把空格、纯标点也当作词元返回,若不处理会虚增相似度。分词后统一过滤掉「不含汉字 / 字母 / 数字」的空白与标点词元。
  3. 只在交集词上求点积:遍历较小的词频表并查另一张表,跳过完全不相交的词,降低无效计算。
  4. 异常不外抛:所有可预期错误转换为自定义异常并在入口统一捕获,返回受控退出码,规避评测中「发生异常退出」而判 0 分的风险。

2. 计算模块接口部分的性能改进

(1)性能改进所花费的时间

约 25 分钟(JFR 记录 + 分阶段打点、定位热点、优化与复测)。

  • 分析工具:JDK 自带的 Java Flight Recorder(JFR) 记录(产物 performance/perf.jfr,可用 JDK Mission Control / JProfiler 打开),并结合程序内 System.nanoTime() 分阶段打点(脚本 performance/Profile.java,输出 performance/timing.csv)。

(2)性能分析图展示

性能分析图由打点数据自动绘制(脚本 performance/plot_perf.py):

profile_result

(3)程序中消耗最大的函数

消耗最大的函数是「分词」Tokenizer → jieba 的词典加载与 DAG 切词)。冷启动单次运行的实测分阶段耗时如下:

阶段 耗时(ms) 说明
readFile 0.55 读取两个文件(UTF-8)
tokenize(冷,含一次性词典加载) 546.86 热点:jieba 词典一次性加载
tokenize(200× 大输入) 26.58 词典已加载后的纯切词开销
cosine 1.49 词频向量 + 余弦

可以看到,JFR/打点结果中系统底层一次性开销(jieba 默认词典加载)占比最大,属于进程启动的固定开销;在业务计算模块中,「分词 + 余弦」才是与文本规模相关的部分,且词典加载完成后单次纯计算仅约 2ms 量级。

(4)改进思路与实现

  1. 复用单个 Tokenizer 实例:jieba 词典加载是一次性开销(约 0.5s),只发生在进程启动的第一次分词;复用同一实例,避免重复构造与重复探测。
  2. 仅在词表交集上求点积:余弦计算遍历较小的词频表,对低重合度长文本减少无效运算。
  3. 词频统计使用 HashMap.merge:减少分支与临时对象。

(5)改进后的效果对比

指标 初版 改进后
单次「分词 + 余弦」纯计算耗时 数毫秒 ~ 十余毫秒 约 2ms 量级
词典加载是否重复 可能重复探测 / 构造 仅一次(实例复用)
余弦点积范围 全词表遍历 仅交集词
整程序单次运行总耗时 约 0.55s

改后整程序单次运行总耗时约 0.55s远低于 5s / 2048MB 的评测上限,且无内存泄漏、无异常退出。

3. 计算模块部分单元测试展示

使用 JUnit 5 编写单元测试,共 29 个测试用例,分布在 5 个测试类中:

测试类 覆盖内容
CosineSimilarityTest 完全相同、完全不相交、空文本、部分重叠、词频统计
TokenizerTest 2-gram 分词(中文成对、单字、英文单词、中英混排、空串)、jieba 路径、降级路径
FileIOTest 正常读写、文件缺失、路径为空、写入目录等异常分支
MainTest 参数解析、错误退出码、端到端查重(相同 / 完全不相交)
SampleDataTest 基于 samples/ 样例验证增 / 删 / 乱序 / 同义替换的判定

(1)单元测试代码片段展示

// 端到端:相同文本应输出 100.00 且退出码为 0
@Test
void run_integration_identical_returns0AndWrites100(@TempDir final Path dir) throws Exception {
    final Path orig = dir.resolve("orig.txt");
    final Path copy = dir.resolve("copy.txt");
    final Path ans  = dir.resolve("ans.txt");
    Files.writeString(orig, "今天是星期天天气晴我要去看电影");
    Files.writeString(copy, "今天是星期天天气晴我要去看电影");
    final int code = Main.run(new String[] {
            orig.toString(), copy.toString(), ans.toString()});
    assertEquals(0, code);
    assertEquals("100.00", Files.readString(ans).trim());
}

// 分词:连续汉字按相邻两字切分为二元词
@Test
void bigram_splitsChineseIntoPairs() {
    assertEquals(List.of("天气", "气晴", "晴朗"),
            Tokenizer.tokenizeByBigram("天气晴朗"));
}

// 异常:文件不存在应抛 FileReadException
@Test
void readText_missingFile_throwsFileReadException() {
    assertThrows(FileReadException.class,
            () -> FileIO.readText("not_exist_12345.txt"));
}

(2)测试的函数与接口

测试覆盖了 CosineSimilarity 类的 cosinetermFrequencyTokenizer 类的 tokenizetokenizeByBigram
FileIO 类的 readTextwriteAnswer,以及入口 MainparseArgsrun

(3)测试数据的构造思路

测试数据以「白盒 + 边界值 / 等价类划分」为指导,共 29 个用例:

  • 等价类与边界值用例:构造「两段完全一致的文本」测试上限(100.00);构造「两段互不相关的文本」测试低相似度;构造「双文本均为空」「单侧为空」验证极端边界。
  • 文本抗噪与鲁棒性用例:在文本中插入特殊字符、多重标点与无规律空格,验证分词与去噪模块的过滤完整性。
  • 特征稳定性用例:调换句中主谓宾语序,测试基于词频加权的余弦算法对语序变换的判定;模拟局部增删替换句子,验证相似度处于合理评估区间。
  • 异常与文件接口用例:传入不存在的文件路径验证异常抛出;传入空路径;把目录当作文件写入;执行写入与读取闭环验证磁盘 I/O 稳定性。

(4)单元测试覆盖率展示

运行 JaCoCo 对 29 个测试用例执行自动化检测,覆盖率如下:

指令覆盖 分支覆盖
Main 88% 75%
Tokenizer 95% 86%
CosineSimilarity 97% 81%
FileIO 71% 75%
exceptions(4 个) 100%
整体 ≈91% ≈83%

未覆盖的主要是防御性分支(如 SecurityExceptionMain 中兜底的 catch (Exception)),这些分支在正常测评路径下不会触发。HTML 报告见 build/coverage-report/index.html(运行 test.bat 后生成)。
发布到博客时请上传覆盖率报告截图。

样例运行结果java -jar main.jar samples/orig.txt samples/<样例> samples/ans.txt):

样例 重复率 符合预期
orig_add(增) 95.59 高,符合
orig_del(删) 93.84 高,符合
orig_dis_1(乱序) 100.00 词频不变 → 100
orig_rep(同义替换) 76.91 明显下降,符合

4. 计算模块部分异常处理说明

程序的异常体系以 PlagiarismException 为基类,派生出三个具体异常;所有异常在 Main.run 中统一捕获并转换成受控退出码,从源头避免「发生异常退出」。

异常类 设计目标 触发场景 对应单元测试
InvalidArgsException 参数不合法时明确报错、返回退出码 2 命令行参数个数 ≠ 3 MainTest.parseArgs_wrongCount_throwsrun_wrongArgCount_returns2
FileReadException 原文 / 抄袭版读取失败时给出可读错误、返回 1 文件不存在、路径为空、非法路径 FileIOTest.readText_missingFile_throwsFileReadExceptionreadText_nullPath_throwsFileReadException
FileWriteException 答案文件无法写出时给出可读错误、返回 1 目标为目录、路径为空、无写权限 FileIOTest.writeAnswer_toDirectory_throwsFileWriteExceptionwriteAnswer_nullPath_throwsFileWriteException
PlagiarismException(基类) 统一捕获所有业务异常 上述任意情形 MainTest.run_missingOriginal_returns1

(1)命令行参数异常(InvalidArgsException

  • 设计目标:防止调用脚本时传入的参数数量不符合规范(缺少原文路径、缺少抄袭文路径或缺少输出路径),导致访问 args 数组时越界。程序校验入参数量,当数量不等于 3 时主动输出标准格式说明并返回退出码 2。
  • 单元测试样例
// 参数异常:个数不为 3
@Test
void parseArgs_wrongCount_throws() {
    assertThrows(InvalidArgsException.class,
            () -> Main.parseArgs(new String[] {"only", "two"}));
}
  • 对应场景:用户在终端输入执行命令时参数遗漏,例如仅输入了 java -jar main.jar orig.txt 便回车,缺少抄袭版路径与答案输出路径。

(2)文件读取异常(FileReadException

  • 设计目标:防止用户输入错误的原文 / 抄袭文文件路径、路径拼写错误或目标文件被删除时,程序直接抛出未捕获的系统级异常而崩溃。通过在数据读取层进行文件存在性显式判定并抛出标准异常,使控制层捕获后输出明确排查提示并安全退出。
  • 单元测试样例
// 读取异常:文件不存在
@Test
void readText_missingFile_throwsFileReadException() {
    assertThrows(FileReadException.class,
            () -> FileIO.readText("not_exist_12345.txt"));
}
  • 对应场景:用户在命令行运行程序时,输入的原文路径或抄袭版论文路径在磁盘中不存在(例如文件已被重命名、移动或路径拼写错误)。

(3)文件写出异常(FileWriteException

  • 设计目标:防止答案输出路径不可写(写成已存在的目录、路径为空、无写权限)时程序崩溃或静默失败。通过把底层 IOException 转换为自定义异常,让控制层给出可读提示并安全退出。
  • 单元测试样例
// 写入异常:目标是目录
@Test
void writeAnswer_toDirectory_throwsFileWriteException(@TempDir final Path dir) {
    assertThrows(FileWriteException.class,
            () -> FileIO.writeAnswer(dir.toString(), "95.60"));
}
  • 对应场景:评测或用户把答案路径写成了一个已存在的目录,导致文件无法创建 / 写入。

上述三类异常均在 Main.run 中被捕获,打印到 stderr 并返回非 0 退出码,程序不会崩溃。


附录:运行与构建

# 运行(在 3124004444 目录内)
java -jar main.jar [原文文件] [抄袭版论文的文件] [答案文件]
# 例如
java -jar main.jar C:\tests\orig.txt C:\tests\orig_add.txt C:\tests\ans.txt
:: 编译打包(产出 main.jar)
build.bat
:: 单元测试 + JaCoCo 覆盖率报告(build/coverage-report/index.html)
test.bat
  • 编译命令:javac --release 17 -encoding UTF-8 -Xlint:all -Werror把编译器警告当作错误,确保零警告通过)。
  • 依赖:lib/jieba-analysis.jar(中文分词,词典打包在 jar 内,运行期不联网)。
  • 当前结果:单元测试 29/29 通过 · 整体覆盖率约 91%(分支约 83%)· javac 零警告 · 单次运行约 0.55s
posted @ 2026-09-13 21:05  gigittty  阅读(15)  评论(0)    收藏  举报