第一次个人编程作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 完成一个论文查重程序 |
我的作业链接:https://github.com/mangnolia913/magnoliia913/tree/main/3224004191
一、PSP 表格(预估用时)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 30 |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | 260 |
| · Analysis | · 需求分析(包括学习新技术) | 40 |
| · Design Spec | · 生成设计文档 | 20 |
| · Design Review | · 设计复审 | 15 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 30 |
| · Coding | · 具体编码 | 80 |
| · Code Review | · 代码复审 | 25 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 40 |
| Reporting | 报告 | 60 |
| · Test Report | · 测试报告 | 20 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| · 合计 | 350 |
二、计算模块接口的设计与实现过程
2.1 算法选型
综合考虑算法的时间复杂度、实现难度以及对中文文本的适应性,本系统最终选择字符 2-gram + Jaccard 相似度作为论文查重的核心算法。相比编辑距离和 LCS,字符 n-gram 的计算效率更高,能够避免长文本处理时的时间和内存开销;相比词频余弦相似度,不需要依赖中文分词库,降低了系统实现的复杂度;相比 SimHash,Jaccard 相似度能够更直观、准确地反映两段文本的局部字符重合程度。在 n-gram 的选择上,最终采用 2-gram,既能够保留一定的字符上下文信息,又不会像 3-gram 那样导致短文本之间的特征交集过少,因此在计算效率和相似度准确性之间取得了较好的平衡。
2.2 核心公式
Jaccard(A, B) = |A ∩ B| / |A ∪ B|
其中 A、B 分别为原文和抄袭版文本的 2-gram 集合。
示例:
原文: 今天是星期天
2-gram:{今天, 天是, 是星, 星期, 期天}
抄袭版:今天是周天
2-gram:{今天, 天是, 是周, 周天}
交集 = {今天, 天是} = 2
并集 = {今天, 天是, 是星, 星期, 期天, 是周, 周天} = 7
相似度 = 2 / 7 ≈ 0.29
2.3 代码组织
3224004191/
├── main.py # 命令行入口
├── similarity.py # 核心算法
├── requirements.txt
├── tests/
│ ├── test_similarity.py # 核心算法单元测试
│ ├── test_main.py # 命令行端到端测试
│ └── data/ # 测试样例
└── docs/ # PSP、性能分析、覆盖率截图
模块职责:
| 文件 | 职责 | 对外接口 |
|---|---|---|
similarity.py |
预处理、n-gram、Jaccard、文件读写 | read_text, write_answer, preprocess, get_ngrams, similarity |
main.py |
解析命令行参数、调用核心、异常兜底 | main() |
tests/ |
单元测试与端到端测试 | pytest 用例 |
模块关系图:
main.py
├── read_text(orig_path)
├── read_text(copy_path)
├── similarity(orig_text, copy_text)
│ ├── preprocess(text)
│ └── get_ngrams(text, ngram_size=2)
└── write_answer(ans_path, score)
2.4 关键函数说明
preprocess(text):用预编译正则[^\w]去除标点、空白、换行,保留中文、字母、数字get_ngrams(text, ngram_size=2):用zip滑窗切分字符 n-gram,返回集合similarity(text1, text2):分别计算两个集合,返回 Jaccard 相似度read_text(path)/write_answer(path, score):文件读写,保留两位小数
2.5 算法独到之处
- 无第三方依赖:核心算法只用标准库
re、sys - 中文友好:按字符切分,无需分词库
- O(n) 时间、O(n) 空间:远低于 5 秒 / 2048MB 限制
- 边界处理完善:两篇空返回 1.00,一篇空返回 0.00
- 对插入噪声鲁棒:逐字插入干扰只会破坏部分 2-gram
2.6 真实样例结果
用老师提供的样例测试:
| 样例 | 干扰方式 | 相似度 |
|---|---|---|
orig_0.8_dis_1.txt |
1% 字符扰乱 | 0.8478 |
orig_0.8_del.txt |
删除字符 | 0.6073 |
orig_0.8_dis_10.txt |
10% 字符扰乱 | 0.5771 |
orig_0.8_add.txt |
逐字插入 | 0.5137 |
orig_0.8_dis_15.txt |
15% 字符扰乱 | 0.3521 |
干扰越多,相似度越低,算法区分度良好。
说明:文件名中的
0.8表示保留了约 80% 的原始字符,但逐字插入干扰会破坏 2-gram 的相邻关系,导致大量特征断裂,所以输出值低于 0.8。这正说明算法对“逐字插入”这种抄袭方式敏感。
三、计算模块接口部分的性能改进
3.1 性能分析工具说明
本作业使用 Python 实现,采用 Python 官方性能分析工具 cProfile 替代,能定位耗时最长的函数、统计调用次数、生成可视化报告。
3.2 性能分析图

3.3 消耗最大的函数
从 cProfile 输出可见:
_io.open(文件 IO)耗时 0.026s,属于不可避免的系统开销get_ngrams(算法核心)耗时 0.005s,属于算法瓶颈
3.4 改进思路与效果
原实现(切片推导):
return {text[i:i + ngram_size] for i in range(len(text) - ngram_size + 1)}
优化实现(zip 滑窗):
return {''.join(t) for t in zip(*(text[i:] for i in range(ngram_size)))}
优化原理:原写法每次切片都创建一个新字符串;新写法用 zip 并行迭代 n 个子串,只在最后拼一次,减少中间对象。
对比测试(50 倍数据,约 50 万字符):
| 版本 | 耗时 | 相似度 |
|---|---|---|
| 优化前(切片推导) | 0.264 秒 | 0.5138 |
| 优化后(zip 滑窗) | 0.255 秒 | 0.5138 |
| 提升 | 约 3.4% | — |
结论:当前算法在 50 万字符下耗时 0.26 秒,远低于 5 秒上限。优化收益有限,说明瓶颈主要在集合去重与正则替换,而非 n-gram 提取。
四、计算模块部分单元测试展示
4.1 测试函数与思路
单元测试位于 tests/test_similarity.py,共 17 个用例,覆盖三类场景:
正常路径(6 个):
- 完全相同文本 → 1.00
- 完全无关文本 → < 0.1
- 少量增删改 → > 0.3
- 仅标点不同 → > 0.9
- 仅空白不同 → > 0.9
- 英文大小写敏感 → 0 < score < 1
边界路径(5 个):
- 原文为空 → 0.00
- 抄袭版为空 → 0.00
- 两篇都为空 → 1.00
- 文本长度小于 ngram_size → 空集合
- 预处理去除标点 → 验证清洗结果
异常路径(3 个):
- 文件不存在 → 抛
FileNotFoundError - 写答案保留两位小数 → 0.856 →
"0.86" - 四舍五入 → 0.854 →
"0.85"
真实数据集成测试(3 个):
origvsorig_0.8_add→ 0.3 < score < 0.9origvsorig_0.8_dis_1→ > 0.7origvsorig_0.8_dis_15→ < 0.6
4.2 部分测试代码
def test_identical_text_returns_one():
"""完全相同的文本,相似度应为 1.00"""
assert similarity("今天是星期天,天气晴", "今天是星期天,天气晴") == pytest.approx(1.0)
def test_small_modification():
"""少量增删改,相似度应大于 0.3(短文本 2-gram 敏感,阈值放宽)"""
score = similarity(
"今天是星期天,天气晴,今天晚上我要去看电影。",
"今天是周天,天气晴朗,我晚上要去看电影。"
)
assert score > 0.3
def test_empty_original():
"""原文为空,相似度应为 0"""
assert similarity("", "今天天气晴") == 0.0
def test_both_empty():
"""两篇都为空,视为完全一致"""
assert similarity("", "") == 1.0
def test_read_text_file_not_found():
"""读取不存在的文件应抛 FileNotFoundError"""
with pytest.raises(FileNotFoundError):
read_text("not_exist_file_xyz.txt")
命令行端到端测试位于 tests/test_main.py,共 5 个用例,通过 subprocess 调用 main.py:
def test_cli_normal(tmp_path):
"""正常调用:应生成两位小数的答案文件"""
orig = tmp_path / "orig.txt"
copy = tmp_path / "copy.txt"
ans = tmp_path / "ans.txt"
orig.write_text("今天是星期天,天气晴。", encoding="utf-8")
copy.write_text("今天是周天,天气晴朗。", encoding="utf-8")
ret = subprocess.run(
[sys.executable, str(MAIN), str(orig), str(copy), str(ans)],
capture_output=True, text=True
)
assert ret.returncode == 0
assert re.fullmatch(r"\d+\.\d{2}", ans.read_text(encoding="utf-8"))
4.3 测试运行结果

22 个用例全部通过。
4.4 覆盖率截图

报告显示核心模块 similarity.py 的语句覆盖率达到 100%,即该模块的每一行可执行代码都在测试中被执行到,无遗漏分支;配合 22 个测试用例全部通过,说明算法逻辑、边界情况和异常处理都得到了充分验证。main.py 未出现在覆盖率报告中,是因为它通过 subprocess 被调用,coverage 默认不跟踪子进程,但其功能已由 5 个命令行端到端测试完整覆盖。总体而言,覆盖率截图客观证明了测试用例设计完整、代码质量可靠。
五、计算模块部分异常处理说明
| 异常 | 设计目标 | 测试样例 | 错误场景 |
|---|---|---|---|
FileNotFoundError |
文件不存在时给出明确错误提示,返回非 0 退出码 | test_read_text_file_not_found |
用户输入的文件路径不存在 |
UnicodeDecodeError |
文件编码不匹配时提示 | test_cli_file_not_found |
读取非 UTF-8 编码文件 |
| 参数数量错误 | 参数不足 3 个时打印用法,返回非 0 退出码 | test_cli_missing_args |
用户少传文件路径 |
| 空文本 | 避免除零错误,返回 0.00 或 1.00 | test_empty_original / test_both_empty |
原文或抄袭版为空文件 |
| 输出路径不可写 | 提示答案文件路径错误 | test_write_answer_two_decimal |
答案目录无写权限 |
| 兜底异常 | 捕获所有未知异常,保证程序不崩溃 | test_cli_file_not_found |
任何预料外的异常 |
异常处理代码示例(main.py):
try:
orig_text = read_text(orig_path)
copy_text = read_text(copy_path)
score = similarity(orig_text, copy_text)
write_answer(ans_path, score)
print(f"Similarity: {score:.2f}")
return 0
except FileNotFoundError as exc:
print(f"Error: file not found - {exc}", file=sys.stderr)
return 1
except UnicodeDecodeError as exc:
print(f"Error: encoding failed - {exc}", file=sys.stderr)
return 1
except Exception as exc: # pylint: disable=broad-exception-caught
# 兜底捕获:保证任何异常都返回非 0 退出码,避免程序崩溃
print(f"Error: {exc}", file=sys.stderr)
return 1
异常测试样例:
def test_cli_missing_args():
"""参数不足:应返回非 0 退出码并提示 Usage"""
ret = subprocess.run(
[sys.executable, str(MAIN)],
capture_output=True, text=True
)
assert ret.returncode != 0
assert "Usage" in ret.stdout or "Usage" in ret.stderr
def test_cli_file_not_found(tmp_path):
"""文件不存在:应返回非 0 退出码并提示错误"""
ans = tmp_path / "ans.txt"
ret = subprocess.run(
[sys.executable, str(MAIN), "no_such.txt", "no_such2.txt", str(ans)],
capture_output=True, text=True
)
assert ret.returncode != 0
assert "not found" in ret.stderr.lower() or "error" in ret.stderr.lower()
六、代码质量分析
使用 pylint 对 similarity.py 和 main.py 进行代码质量分析。
初始评分:7.55/10,存在 12 条警告:
C0304文件末尾缺换行C0103变量名f、n、s1、s2、e不符合 snake_caseW0718兜底捕获过宽的Exception
修复措施:
| 警告 | 修复方式 |
|---|---|
C0304 Final newline missing |
文件末尾补空行 |
C0103 变量名 f |
改为 file_reader / file_writer |
C0103 参数名 n |
改为 ngram_size |
C0103 变量名 s1 / s2 |
改为 ngrams1 / ngrams2 |
C0103 变量名 e |
改为 exc |
W0718 兜底捕获 |
加注释说明设计意图,配 # pylint: disable |
修复后评分:10.00/10,无任何警告。
如图所示:


七、PSP 表格(实际用时)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 260 | 300 |
| · Analysis | · 需求分析(包括学习新技术) | 40 | 35 |
| · Design Spec | · 生成设计文档 | 20 | 20 |
| · Design Review | · 设计复审 | 15 | 15 |
| · Coding Standard | · 代码规范 | 10 | 10 |
| · Design | · 具体设计 | 30 | 40 |
| · Coding | · 具体编码 | 80 | 110 |
| · Code Review | · 代码复审 | 25 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 40 | 40 |
| Reporting | 报告 | 60 | 65 |
| · Test Report | · 测试报告 | 20 | 25 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 30 |
| · 合计 | 350 | 390 |
浙公网安备 33010602011771号