第一次个人编程作业
https://github.com/your-username/your-repo
第一次个人编程作业:论文查重
一、PSP 表格
| PSP2.1 阶段 | 任务内容 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | 估计任务需要多少时间 | 30 | 25 |
| Development | 开发 | 300 | 360 |
| · Analysis | 需求分析(包括学习新技术) | 60 | 80 |
| · Design Spec | 生成设计文档 | 40 | 50 |
| · Design Review | 设计复审 | 20 | 15 |
| · Coding Standard | 代码规范 | 15 | 10 |
| · Design | 具体设计 | 50 | 60 |
| · Coding | 具体编码 | 80 | 120 |
| · Code Review | 代码复审 | 20 | 15 |
| · Test | 测试(自测、改bug) | 15 | 10 |
| Test Report | 测试报告 | 20 | 20 |
| Size Measurement | 计算工作量 | 10 | 5 |
| Postmortem | 事后总结 | 20 | 15 |
| 合计 | 380 | 420 |
二、计算模块接口的设计与实现过程
2.1 整体架构设计
本项目采用面向对象的设计思想,将论文查重系统拆分为两个核心类:
- TextProcessor(文本处理器):负责文件读取、文本预处理、n-gram 生成
- PlagiarismCalculator(查重计算器):负责相似度计算和重复率评估
整体调用流程如下:
main.cpp (入口)
↓
PlagiarismCalculator::calculatePlagiarismRate()
├── TextProcessor::readFile() ← 读取原文文件
├── TextProcessor::readFile() ← 读取抄袭版文件
├── TextProcessor::preprocess() ← 预处理原文
├── TextProcessor::preprocess() ← 预处理抄袭版
├── TextProcessor::generateNGrams() ← 生成原文3-gram
├── TextProcessor::generateNGrams() ← 生成抄袭版3-gram
└── calculateJaccardSimilarity() ← 计算Jaccard相似度
↓
writeResult() → 输出到答案文件
2.2 核心算法:3-gram + Jaccard 相似度
算法选择思路:
论文查重的核心是衡量两段文本的相似程度。常见的文本相似度算法有:
- 余弦相似度(Cosine Similarity)
- Jaccard 相似度
- SimHash
- 编辑距离(Levenshtein Distance)
考虑到中文文本的特点和性能要求,我选择了 3-gram + Jaccard 相似度 方案,原因如下:
-
3-gram(三元语法):将连续的三个字符作为一个"指纹"单元,能有效捕捉中文文本的局部语义特征。相比单字比较,3-gram 对语序变化和局部修改的敏感度更高。
-
Jaccard 相似度:公式为
J(A,B) = |A∩B| / |A∪B|,即两个集合交集大小除以并集大小。这个指标直观地反映了两段文本的"重叠程度",适合查重场景。
2.3 关键函数说明
1. 文本预处理 preprocess()
预处理阶段去除所有无意义的字符,只保留:
- 基本汉字(U+4E00 ~ U+9FFF)
- 英文字母(统一转小写)
- 数字
这样可以消除标点、空格、换行等对相似度计算的干扰,让查重结果更准确地反映文本内容的重复程度。
2. 生成 n-gram generateNGrams()
将预处理后的文本按 UTF-8 字符边界切分,然后用滑动窗口生成所有长度为 n 的子串集合。默认 n=3。
例如:"今天天气好" → {今天天, 今天气, 天天气, 天气好}
3. 计算 Jaccard 相似度 calculateJaccardSimilarity()
遍历较小的集合,统计与另一个集合的交集数量,然后计算 Jaccard 系数。优化点是遍历较小的集合,减少查找次数。
2.4 独到之处
-
UTF-8 字符安全处理:正确处理中文 UTF-8 多字节字符,按字符边界切分 n-gram,避免出现半个汉字的情况。
-
精确的汉字范围过滤:只保留基本汉字区(U+4E00~U+9FFF),有效过滤中文标点、全角符号等干扰字符。
-
效率优化:计算交集时遍历较小的集合,将时间复杂度从 O(|A|+|B|) 优化为 O(min(|A|,|B|))。
三、计算模块接口的性能改进
3.1 性能分析思路
初始版本的算法直接遍历两个集合的所有元素计算交集,时间复杂度为 O(|A| × |B|)。对于长文本(比如几万字的论文),3-gram 集合大小可能达到数万个,双重遍历的效率较低。
3.2 改进方案
改进前:双重循环遍历两个集合
for (auto& a : setA) {
for (auto& b : setB) {
if (a == b) intersection++;
}
}
时间复杂度:O(|A| × |B|)
改进后:利用哈希集合的 O(1) 查找,遍历较小集合
for (const auto& gram : smallerSet) {
if (largerSet.count(gram)) intersection++;
}
时间复杂度:O(min(|A|, |B|))
3.3 性能提升效果
以一篇约 5000 字的论文为例:
- 3-gram 集合大小约 5000 个
- 改进前:约 25,000,000 次比较操作
- 改进后:约 5,000 次哈希查找操作
性能提升约 5000 倍,完全满足 5 秒内出结果的要求。
3.4 内存占用分析
使用 unordered_set 存储 n-gram,对于 5000 个 3-gram(每个约 9 字节),内存占用约几十 KB,远低于 2048MB 的限制。
四、计算模块单元测试展示
4.1 测试框架设计
本次单元测试共设计了 12 个测试用例(超过要求的 10 个),覆盖以下场景:
| 编号 | 测试用例 | 测试函数 | 预期结果 |
|---|---|---|---|
| 1 | 完全相同文本 | testIdenticalText | 重复率 100% |
| 2 | 完全不同文本 | testCompletelyDifferentText | 重复率接近 0% |
| 3 | 部分抄袭文本 | testPartialPlagiarism | 中等相似度 |
| 4 | 两个空文本 | testBothEmpty | 重复率 100% |
| 5 | 一个空一个非空 | testOneEmpty | 重复率 0% |
| 6 | 标点不同内容相同 | testDifferentPunctuation | 相似度 >90% |
| 7 | 英文大小写不敏感 | testCaseInsensitive | 预处理后相同 |
| 8 | 短文本不崩溃 | testShortText | 正常运行不报错 |
| 9 | 纯标点文本 | testOnlyPunctuation | 预处理后为空 |
| 10 | 长文本部分重叠 | testLongTextPartialOverlap | 中高相似度 |
| 11 | 文件不存在 | testFileNotFound | 抛出异常 |
| 12 | 空白字符去除 | testWhitespaceRemoval | 无空白字符残留 |
4.2 测试代码片段
// 测试用例1:完全相同的文本,重复率应为100%
void testIdenticalText() {
writeTestFile("test_orig_1.txt", "今天天气很好,我们去公园玩吧。");
writeTestFile("test_plag_1.txt", "今天天气很好,我们去公园玩吧。");
double rate = PlagiarismCalculator::calculatePlagiarismRate("test_orig_1.txt", "test_plag_1.txt");
TEST_ASSERT(std::abs(rate - 100.0) < 0.01, "测试1: 完全相同文本→100%");
}
// 测试用例11:文件不存在,应抛出异常
void testFileNotFound() {
bool exceptionThrown = false;
try {
PlagiarismCalculator::calculatePlagiarismRate("nonexistent.txt", "nonexistent.txt");
} catch (const FileOpenException& e) {
exceptionThrown = true;
}
TEST_ASSERT(exceptionThrown, "测试11: 文件不存在抛出异常");
}
4.3 测试结果
===== 论文查重程序单元测试 =====
[PASS] 测试1: 完全相同文本→100%
[PASS] 测试2: 完全不同文本→接近0%
[PASS] 测试3: 部分抄袭→中等相似度
[PASS] 测试4: 两个空文本→100%
[PASS] 测试5: 一空一非空→0%
[PASS] 测试6: 标点不同但内容相同→>90%
[PASS] 测试7: 英文大小写不敏感
[PASS] 测试8: 短文本不崩溃
[PASS] 测试9: 纯标点预处理后为空
[PASS] 测试10: 长文本部分重叠→中高相似度
[PASS] 测试11: 文件不存在抛出异常
[PASS] 测试12: 空白字符被去除
===== 测试结果汇总 =====
通过: 12 个
失败: 0 个
总计: 12 个
测试覆盖率分析:12 个测试用例覆盖了正常流程、边界条件、异常场景三大类,基本覆盖了核心算法的所有分支路径。白盒测试角度,包括了:
- 预处理函数的所有分支(汉字保留、ASCII保留、其他过滤)
- n-gram 生成的边界(短文本返回空集合)
- Jaccard 计算的三种情况(都空、一空一非空、都非空)
- 文件读取的异常情况
五、计算模块异常处理说明
5.1 异常设计目标
本程序设计了完善的异常处理机制,确保程序在遇到错误输入时不会崩溃,而是给出明确的错误提示。主要异常类型包括:
5.2 异常类型与场景
1. FileOpenException(文件打开异常)
- 设计目标:当指定的原文或抄袭版文件不存在、无法读取时,抛出此异常,告知用户文件路径有误或文件不可访问。
- 错误场景:用户输入了错误的文件路径,或者文件被其他程序锁定无法读取。
- 单元测试样例:
void testFileNotFound() { bool exceptionThrown = false; try { PlagiarismCalculator::calculatePlagiarismRate("nonexistent.txt", "nonexistent.txt"); } catch (const FileOpenException& e) { exceptionThrown = true; } TEST_ASSERT(exceptionThrown, "文件不存在抛出异常"); }
2. 参数数量异常(命令行参数错误)
- 设计目标:当用户没有提供正确数量的命令行参数时,提示正确的用法说明。
- 错误场景:用户只传了两个参数,或者参数顺序错误。
- 处理方式:输出用法说明并返回错误码 1。
3. 输出文件写入异常
- 设计目标:当答案文件路径无效(如目录不存在、权限不足)时,抛出异常。
- 错误场景:用户指定的输出路径在不存在的目录下。
- 处理方式:捕获
std::exception,输出错误信息并返回错误码 3。
4. 未知异常兜底
- 设计目标:捕获所有未预料到的异常,确保程序不会直接崩溃退出。
- 错误场景:内存不足、 unexpected runtime error 等。
- 处理方式:捕获
...,输出"未知异常发生"并返回错误码 4。
5.3 错误码设计
| 错误码 | 含义 |
|---|---|
| 0 | 正常退出 |
| 1 | 命令行参数数量错误 |
| 2 | 文件打开错误 |
| 3 | 其他运行时异常 |
| 4 | 未知异常 |
这种设计让调用者(如评测脚本)可以通过返回值快速判断错误类型,便于自动化测试和问题排查。
六、总结
本次个人编程作业实现了一个基于 3-gram + Jaccard 相似度的论文查重系统。通过 PSP 流程管理,我对整个开发过程有了更清晰的认识:
-
预估与实际的差距:实际编码时间比预估多了 40 分钟,主要花在了 UTF-8 字符处理的调试上,这也提醒我在今后的开发中要充分考虑编码问题。
-
单元测试的价值:12 个测试用例帮助我发现了中文标点被误识别为汉字的问题,如果没有测试,这个 bug 可能会导致查重结果不准。
-
性能意识:通过优化交集计算方式,将时间复杂度从 O(n²) 降到了 O(n),虽然对于小规模数据差别不大,但这是一个良好的编程习惯。

浙公网安备 33010602011771号