第一次个人编程作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 完成个人项目“论文查重” |
作业github链接:https://github.com/gdutmi/3124004105
一、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 10 | 10 |
| Estimate | 估计这个任务需要多少时间 | 10 | 10 |
| Development | 开发 | 110 | 220 |
| Analysis | 需求分析 (包括学习新技术) | 10 | 20 |
| Design Spec | 生成设计文档 | 10 | 20 |
| Design Review | 设计复审 | 5 | 20 |
| Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 10 | 30 |
| Design | 具体设计 | 20 | 40 |
| Coding | 具体编码 | 25 | 40 |
| Code Review | 代码复审 | 10 | 20 |
| Test | 测试(自我测试,修改代码,提交修改) | 20 | 30 |
| Reporting | 报告 | 60 | 60 |
| Test Repor | 测试报告 | 20 | 20 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 20 | 20 |
| 合计 | 180 | 290 |
二、计算模块接口的设计
- 关键函数及其作用
read_file(filepath)
作用:负责文件读取的 I/O 操作。接收一个文件路径,尝试以 UTF-8 编码读取文件内容并返回字符串。
write_result(filepath, similarity)
作用:负责结果输出的 I/O 操作。接收输出路径和相似度浮点数,将结果格式化后写入文件。
preprocess_text(text)
作用:文本清洗(数据预处理)。利用正则表达式去除文本中的标点符号(如逗号、句号)、空格、换行符等非内容字符。
calculate_similarity(text1, text2)
作用:核心算法函数。计算两段文本的 Jaccard 相似度。
main()
作用:程序的主入口和调度中心。负责解析命令行参数,并按顺序调用上述功能函数。
2.函数与函数之间的关系
整个程序的执行流程是线性的(流水线式),main() 函数充当了控制器的角色:
启动:main() 首先运行,解析命令行参数,获取原文路径、抄袭版路径和输出路径。
数据输入:main() 调用 read_file() 两次,分别获取原文和抄袭版的原始文本内容。
数据清洗:main() 将原始文本传递给 preprocess_text(),得到去除标点和空格的纯净文本。
核心计算:main() 将清洗后的两段文本传递给 calculate_similarity(),得到相似度浮点数。
数据输出:main() 将相似度结果传递给 write_result(),写入指定文件。
结束:打印提示信息,程序结束。
3.算法独到之处
使用 set 是此算法的效率关键。它将时间复杂度从 O(N^2) 降低到接近 O(N),因为集合的查找和交集运算非常快。
三、计算模块接口部分的性能改进

文本预处理 preprocess_text 是最大瓶颈,累计耗时占总时间的 55.6%,其中正则引擎 re.sub 占 0.013 s;其次是相似度计算 calculate_similarity,占 25.9%。
第一次改进:
re.sub 每次都要启动正则引擎,直觉上认为用 Python 层的字符过滤(生成器 + isascii() / isalnum() 判断)能避开正则引擎的开销,理论上应更快。结果:cProfile 采样显示总耗时从 0.027 s 劣化到 0.240 s,慢了约 8.9 倍。
第二次改进:
保留 re.sub 的预处理方案(它已是最优),转而优化 calculate_similarity。原实现在计算并集时使用 set1.union(set2),会额外创建一个新集合对象。可以改用 len(A) + len(B) - len(A∩B) 直接计算并集大小,避免构建第三个集合,节省内存与时间。结果:calculate_similarity 的累计耗时从 0.007 s 降至 0.006 s,程序总耗时从 0.027 s 降至 0.018 s,整体提速约 33%。

四、计算模块部分单元测试展示
- 单元测试代码
点击查看代码
"""check_plagiarism.py 的单元测试。"""
import os
import sys
import tempfile
import unittest
from io import StringIO
from unittest.mock import patch
import check_plagiarism as cp
class TestPreprocessText(unittest.TestCase):
"""测试 preprocess_text:文本预处理。"""
def test_mixed_chinese_english_digit(self):
"""中英数混合:只保留中文、字母、数字。"""
result = cp.preprocess_text("你好,World!123 世界。")
self.assertEqual(result, "你好World123世界")
def test_only_punctuation(self):
"""纯标点:结果为空串。"""
result = cp.preprocess_text(",。!?、;:''【】")
self.assertEqual(result, "")
def test_empty_string(self):
"""空串:返回空串。"""
result = cp.preprocess_text("")
self.assertEqual(result, "")
def test_space_and_newline_removed(self):
"""空格与换行:被移除。"""
result = cp.preprocess_text("abc def\nghi\tjkl")
self.assertEqual(result, "abcdefghijkl")
class TestCalculateSimilarity(unittest.TestCase):
"""测试 calculate_similarity:Jaccard 相似度。"""
def test_identical_text(self):
"""完全相同:相似度 1.0。"""
similarity = cp.calculate_similarity("abcdef", "abcdef")
self.assertAlmostEqual(similarity, 1.0)
def test_completely_different(self):
"""完全不同:相似度 0.0。"""
similarity = cp.calculate_similarity("abc", "xyz")
self.assertAlmostEqual(similarity, 0.0)
def test_partial_overlap(self):
"""部分重叠:交集 / 并集。"""
# 交集 {a,b} 大小 2,并集 {a,b,c,d} 大小 4,结果 2/4 = 0.5
similarity = cp.calculate_similarity("abc", "abd")
self.assertAlmostEqual(similarity, 0.5)
def test_both_empty(self):
"""两段都为空:返回 0.0。"""
similarity = cp.calculate_similarity("", "")
self.assertEqual(similarity, 0.0)
def test_one_empty(self):
"""一段为空:返回 0.0。"""
similarity = cp.calculate_similarity("abc", "")
self.assertEqual(similarity, 0.0)
class TestReadFile(unittest.TestCase):
"""测试 read_file:文件读取。"""
def test_read_normal_file(self):
"""正常读取:内容正确。"""
with tempfile.NamedTemporaryFile(
mode='w', suffix='.txt', delete=False, encoding='utf-8'
) as tmp:
tmp.write("测试内容 hello")
tmp_path = tmp.name
try:
content = cp.read_file(tmp_path)
self.assertEqual(content, "测试内容 hello")
finally:
os.unlink(tmp_path)
def test_read_missing_file_exits(self):
"""文件不存在:程序打印错误并退出。"""
with self.assertRaises(SystemExit) as ctx:
cp.read_file("不存在的文件_xyz.txt")
self.assertEqual(ctx.exception.code, 1)
class TestWriteResult(unittest.TestCase):
"""测试 write_result:结果写入。"""
def test_write_result_format(self):
"""正常写入:保留两位小数。"""
with tempfile.TemporaryDirectory() as tmpdir:
out_path = os.path.join(tmpdir, "out.txt")
cp.write_result(out_path, 0.876)
with open(out_path, 'r', encoding='utf-8') as f:
self.assertEqual(f.read(), "0.88")
def test_write_result_to_invalid_path_exits(self):
"""路径不可写:程序打印错误并退出。"""
with self.assertRaises(SystemExit) as ctx:
cp.write_result("/不存在的目录_xyz/out.txt", 0.5)
self.assertEqual(ctx.exception.code, 1)
class TestMain(unittest.TestCase):
"""测试 main:命令行入口。"""
def test_main_wrong_arg_count_exits(self):
"""参数数量不足:打印用法并退出。"""
with patch.object(sys, 'argv', ['check_plagiarism.py']):
with self.assertRaises(SystemExit) as ctx:
cp.main()
self.assertEqual(ctx.exception.code, 1)
def test_main_normal_flow(self):
"""正常流程:两段相同文本,输出相似度 1.00。"""
with tempfile.TemporaryDirectory() as tmpdir:
orig = os.path.join(tmpdir, "orig.txt")
copy = os.path.join(tmpdir, "copy.txt")
out = os.path.join(tmpdir, "out.txt")
with open(orig, 'w', encoding='utf-8') as f:
f.write("今天天气真好")
with open(copy, 'w', encoding='utf-8') as f:
f.write("今天天气真好")
with patch.object(sys, 'argv', ['check_plagiarism.py', orig, copy, out]):
cp.main()
with open(out, 'r', encoding='utf-8') as f:
self.assertEqual(f.read(), "1.00")
if __name__ == "__main__":
unittest.main()
- 测试函数说明
| 测试类 | 测试函数 | 测试的函数 | 构造的测试数据 | 预期结果 | 测试类型 |
|---|---|---|---|---|---|
TestPreprocessText |
test_mixed_chinese_english_digit |
preprocess_text |
"你好,World!123 世界。" |
"你好World123世界" |
正常 |
TestPreprocessText |
test_only_punctuation |
preprocess_text |
",。!?、;:''【】" |
"" |
边界 |
TestPreprocessText |
test_empty_string |
preprocess_text |
"" |
"" |
边界 |
TestPreprocessText |
test_space_and_newline_removed |
preprocess_text |
"abc def\nghi\tjkl" |
"abcdefghijkl" |
正常 |
TestCalculateSimilarity |
test_identical_text |
calculate_similarity |
"abcdef" vs "abcdef" |
1.0 |
正常 |
TestCalculateSimilarity |
test_completely_different |
calculate_similarity |
"abc" vs "xyz" |
0.0 |
边界 |
TestCalculateSimilarity |
test_partial_overlap |
calculate_similarity |
"abc" vs "abd" |
0.5 |
正常 |
TestCalculateSimilarity |
test_both_empty |
calculate_similarity |
"" vs "" |
0.0 |
边界 |
TestCalculateSimilarity |
test_one_empty |
calculate_similarity |
"abc" vs "" |
0.0 |
边界 |
TestReadFile |
test_read_normal_file |
read_file |
临时文件内容 "测试内容 hello" |
返回 "测试内容 hello" |
正常 |
TestReadFile |
test_read_missing_file_exits |
read_file |
路径 "不存在的文件_xyz.txt" |
抛出 SystemExit,码 1 |
异常 |
TestWriteResult |
test_write_result_format |
write_result |
写 0.876 到临时文件 |
文件内容 "0.88" |
正常 |
TestWriteResult |
test_write_result_to_invalid_path_exits |
write_result |
路径 "/不存在的目录_xyz/out.txt" |
抛出 SystemExit,码 1 |
异常 |
TestMain |
test_main_wrong_arg_count_exits |
main |
sys.argv = ['check_plagiarism.py'] |
抛出 SystemExit,码 1 |
异常 |
TestMain |
test_main_normal_flow |
main |
两个内容相同的临时文件 "今天天气真好" |
输出文件为 "1.00" |
正常(端到端) |
- 覆盖率截图
![dae7f5a86faf264fd59de8a9752006ed]()
五、计算模块部分异常处理说明
| 异常 | 场景 | 设计目标 |
|---|---|---|
FileNotFoundError |
输入文件路径不存在 | 提示文件不存在,避免程序崩溃 |
PermissionError |
文件无权限读取或写入 | 提示权限错误 |
IsADirectoryError |
传入的路径是目录而非文件 | 提示路径类型错误 |
SystemExit(参数不足时主动触发) |
命令行参数数量不等于 4 | 提示正确用法并退出 |
OSError(统一捕获) |
其他文件系统相关异常 | 统一捕获,避免异常退出 |
异常测试样例
test_read_missing_file_exits ... 读取文件 不存在的文件_xyz.txt 失败: [Errno 2] No such file or directory: '不存在的文件_xyz.txt' ok
六、实际 PSP 时间
见上文。
七、总结
通过本次项目,完整实践了软件工程的基本流程:
需求分析 → 设计 → 编码 → 代码质量检查 → 单元测试 → 性能优化 → 文档撰写
我还获得收获:
- 函数必须有类型注解和 docstring;
- 精确捕获异常,避免 except Exception;
- 掌握 unittest 框架,覆盖正常、边界、异常三类路径;
- 使用 coverage 生成 HTML 可视化覆盖率报告;
- 熟练 git add / commit / push;
- 处理 rejected 冲突:git pull --rebase;

浙公网安备 33010602011771号