个人项目
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 个人项目 |
| 这个作业的目标 | 设计一个论文查重算法,给出一个原文文件和一个在这份原文上经过了增删改的抄袭版论文的文件,在答案文件中输出其重复率。 |
个人项目:论文查重
作业 GitHub 链接:https://github.com/Siruetto/liuhaobin
代码放在仓库根目录的 3124004477/ 下面,Python 3 写的,入口是 main.py:
python main.py <原文文件> <抄袭版论文文件> <答案文件>
答案文件里只放一个保留两位小数的百分数,89.12 就是重复率 89.12%。只有数字本身,不带百分号,也不写别的文字。
最后跑出来的结果是:flake8 没有警告,45 个单元测试全过,四个源文件的语句和分支覆盖率都是 100%。
一、动手之前的 PSP 预估
写代码之前先估了一遍各阶段要花多少时间(单位:分钟):
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 15 |
| · Estimate | · 估计这个任务需要多少时间 | 15 |
| Development | 开发 | 275 |
| · Analysis | · 需求分析(包括学习新技术) | 30 |
| · Design Spec | · 生成设计文档 | 20 |
| · Design Review | · 设计复审 | 15 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 30 |
| · Coding | · 具体编码 | 90 |
| · Code Review | · 代码复审 | 20 |
| · Test | · 测试 | 60 |
| Reporting | 报告 | 50 |
| · Test Report | · 测试报告 | 20 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 20 |
| 合计 | 340 |
当时觉得 340 分钟差不多能收工,实际用了 495 分钟。差在哪、为什么差,写在第六节。
二、计算模块的设计与实现
重复率到底怎么算
题目给的抄袭版是在原文上"增删改"得到的,所以相似度必须同时对这三种操作敏感。
为什么不直接数两篇里相同字符的个数?因为这个指标有明显漏洞:只要把整段话的语序颠倒一下,字符一个没少,分数却还是很高,显然不合理。这里更合适的是最长公共子序列(LCS)——它统计的是顺序不变、能够被原样保留下来的那部分内容,增、删、改都会让它变小:
重复率 = 2 × LCS(原文, 抄袭版) / (len(原文) + len(抄袭版)) × 100%
算出来的结果是一个 0 到 100 的百分数:0 表示两篇毫无关系,100 表示完全一致。
分母为什么用两篇长度之和,而不是原文长度?因为如果只用原文长度归一化,抄袭者只要在原文后面接一大段无关内容,重复率照样停在 100%。用长度之和以后,增加和删除都会把分数往下拉,看起来正常多了。
计算之前我还会先把空白字符全删掉(空格、制表符、换行、全角空格)。论文排版时换个行不代表内容变了,不处理的话,同一段话可能因为换行位置不同就被判成两段不同的文本。
下面几个例子能看出这个公式的行为(数据都来自单元测试):
| 场景 | 原文 | 抄袭版 | LCS | 重复率 |
|---|---|---|---|---|
| 完全相同 | 重复率测试文本 |
重复率测试文本 |
7 | 100.00% |
| 只增加 | abcdefghij |
abcdefghijABCDE |
10 | 80.00% |
| 只删除 | 0123456789ABCDEFGHIJ |
0123456789 |
10 | 66.67% |
| 只修改 | abcdefghij |
abXdefghYj |
8 | 80.00% |
| 顺序颠倒 | abcd |
dcba |
1 | 25.00% |
| 完全无关 | abc |
xyz |
0 | 0.00% |
课堂下发的样例(orig.txt 和 orig_0.8_*.txt)我也跑了一遍:
| 样例 | 重复率 |
|---|---|
orig_0.8_add.txt,往原文里插字 |
91.54% |
orig_0.8_del.txt,从原文里删字 |
90.00% |
orig_0.8_dis_1.txt,打乱字序 |
97.05% |
orig_0.8_dis_10.txt,打乱字序 |
84.26% |
orig_0.8_dis_15.txt,打乱字序 |
68.37% |
三个打乱版本分数依次往下掉,说明这个口径对字序是敏感的。如果改成只统计字符出现的次数,打乱得最厉害的那份反而会被算成接近 100%,因为字符一个都没少——那显然不是查重想要的结果。
代码拆成了四个文件
| 文件 | 职责 | 关键函数 |
|---|---|---|
main.py |
命令行入口,把整个流程串起来,并把异常翻译成退出码 | main(argv)、compute_rate(原文路径, 抄袭路径) |
similarity.py |
计算模块 | normalize()、lcs_length()、duplication_rate()、format_rate() |
textio.py |
只读两个输入文件、只写一个答案文件,负责编码兼容 | read_text()、write_rate() |
errors.py |
异常层级,每种异常自带退出码 | PlagiarismError 及其 4 个子类 |
依赖是单向的:main → textio → similarity → errors。这么拆最大的好处是计算模块完全不碰文件,所以它的单元测试不用建临时文件,跑一次不到一秒。
位并行 LCS
这部分是我花时间最多的地方。
二维 DP 的写法很好懂,我第一版就是那么写的,小样例跑起来没问题。但它要开一张 n×m 的表,文本一长内存就吃不消,所以第二版换成了滚动数组。内存是降下来了,时间还是 O(n×m)——4000 字要 1.2 秒,照这个速度五万字的论文肯定超时,瓶颈就在 Python 的循环本身。
再后来查到一种位并行(bit-parallel)的算法:把短一点的那篇当成模式串,每个字符用它出现过的位置拼成一个大整数位掩码,于是一整列 DP 的计算就被压成了几次大整数的加减和位运算。
masks = _build_masks(pattern) # 字符 -> 位置位掩码
full_mask = (1 << pattern_len) - 1
vector = full_mask # V 向量,初值全 1
for char in text:
match = masks.get(char, 0)
overlap = vector & match
vector = ((vector + overlap) | (vector - overlap)) & full_mask
return pattern_len - bin(vector).count("1") # V 中 0 的个数就是 LCS 长度
复杂度从 O(n×m) 降到 O(n×m/w),内存只要 O(m/w)。
不过这个公式我不敢直接拿去用,因为它跟教科书上的递推长得完全不一样,光看几行代码没法判断对不对。所以我先另写了个小脚本,用随机串把位并行的结果和朴素 DP 逐条对照,200 组全部一致以后才放进产品代码。这段对照后来留在了测试里(test_matches_plain_dp_on_random_inputs),现在改计算模块还会先跑它一遍。
整体流程
下面是这一版程序的整体流程,从命令行参数进来一直到写出答案:

三、计算模块接口部分的性能改进
Python 里没有 VS 那种性能分析器,我等价用的是标准库的 cProfile + pstats。下面这些数据都能用 python tools/benchmark.py 一键复现(脚本会重新跑一遍三版实现,生成数据表和两张图)。
前前后后写了三版
| 版本 | 做法 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 第一代 | 完整二维 DP 表 | O(n×m) | O(n×m) |
| 第二代 | 滚动数组 DP | O(n×m) | O(n) |
| 第三代(最终采用) | 位并行 + 大整数位掩码 | O(n×m/w) | O(m/w) |
实测耗时
测试数据是"在原文上删 5%、改 5%、增 5%"生成的抄袭版,本机 Windows 10 / Python 3.13.1:
| 实现 | 500 字 | 1000 字 | 2000 字 | 4000 字 |
|---|---|---|---|---|
| 第一代 二维 DP | 0.0264 s | 0.1132 s | 内存过大,未测 | 内存过大,未测 |
| 第二代 滚动数组 DP | 0.0162 s | 0.0707 s | 0.2886 s | 1.2116 s |
| 第三代 位并行 | 0.0002 s | 0.0003 s | 0.0009 s | 0.0023 s |
4000 字规模下位并行比滚动数组快了约 527 倍,这个倍数看着夸张,其实就是把几百万次 Python 循环换成了几百次大整数运算。
大文件的成绩(评测上限 5 秒):
| 原文长度 | 位并行耗时 |
|---|---|
| 20000 字 | 0.0322 s |
| 50000 字 | 0.1661 s |
| 100000 字 | 0.6111 s |
顺带说一下,上面那五个课堂样例每个跑完都是 0.05~0.06 秒,连文件 IO 算进去也离 5 秒上限很远。
性能分析图
对完整流程(读文件 → 归一化 → 计算 → 写答案)跑 3 轮 30000 字的文档,cProfile 出来的热点图是这样的:

三种实现的耗时随输入规模的变化(纵轴是对数坐标):

消耗最大的函数
ncalls tottime cumtime function
3 0.171 0.220 similarity.py:40(lcs_length)
3 0.034 0.042 similarity.py:32(_build_masks)
9 0.018 0.018 {built-in method _io.open}
180087 0.016 0.016 {method 'get' of 'dict' objects}
6 0.001 0.001 {built-in method nt._path_exists}
排第一的一直是 lcs_length,占了绝大部分时间,剩下的几乎都是文件 IO。第二名是它内部调用的 dict.get,一共调了 18 万次。我也想过把掩码表预热成定长数组、省掉这次查表,但小文档上收益不明显,代码还更难读,最后没做。
改进的四个步骤
- 先用二维 DP 把算法跑通,确认结果是对的;
- cProfile 显示时间几乎全耗在内层循环 → 换成滚动数组,先把内存降下来;
- 时间仍然是 O(n×m) → 换成位并行,把整列 DP 压成大整数运算,一次处理 w 位;
- 每换一版都拿随机用例回对朴素 DP,确保只是变快了、没有算错。
四、计算模块接口部分的单元测试
规模和运行方式
| 项目 | 数值 |
|---|---|
| 测试文件 | tests/test_similarity.py、tests/test_textio.py、tests/test_main.py、tests/test_exceptions.py |
| 用例数量 | 45 个 |
| 结果 | 45 passed |
| 核心模块覆盖率 | similarity.py 100%、textio.py 100%、main.py 100%、errors.py 100%(含分支) |
python -m pytest --cov=. --cov-branch --cov-report=term-missing tests -q
覆盖率

几个有代表性的用例
白盒对照:位并行 LCS 必须和教科书 DP 完全一致。
在小字母表(abc / abcdefgh)上随机生成长度 0~24 的串,用固定随机种子保证每次跑出来一样,然后逐个比较两个实现:
def test_matches_plain_dp_on_random_inputs(self):
random.seed(20260911)
for _ in range(200):
alphabet = "abc" if _ % 2 else "abcdefgh"
first = "".join(random.choice(alphabet) for _ in range(random.randint(0, 20)))
second = "".join(random.choice(alphabet) for _ in range(random.randint(0, 24)))
self.assertEqual(
reference_lcs(first, second),
similarity.lcs_length(first, second),
msg="不一致: {!r} vs {!r}".format(first, second),
)
增、删、改、空文档各写了一条。
def test_deletion_only(self):
# LCS = 10, len = 20 + 10 -> 66.66...%
self.assertAlmostEqual(
66.66666666666667,
similarity.duplication_rate("0123456789ABCDEFGHIJ", "0123456789"),
)
def test_both_empty_is_defined_as_identical(self):
self.assertAlmostEqual(100.0, similarity.duplication_rate("", ""), places=6)
端到端:程序只允许创建答案文件。
在临时目录里放两个输入文件,跑完之后把目录列出来,只要多出任何一个文件就说明程序碰了不该碰的东西。这条用例是直接对着评测规则写的——"尝试读写其他文件"按 0 分计。
def test_only_the_answer_file_is_created(self):
self._run([self.original, self.copied, self.answer])
self.assertEqual(
["ans.txt", "orig.txt", "orig_add.txt"], sorted(os.listdir(self.tmpdir))
)
编码兼容:
同一个中文句子分别存成 UTF-8、带 BOM 的 UTF-8、GB18030,都应该能读出来。
def test_reads_gb18030(self):
path = self._write_bytes("gbk.txt", "论文查重".encode("gb18030"))
self.assertEqual("论文查重", textio.read_text(path))
这套测试的短板
最明显的一点是:随机对照用的字母表只有 8 个字符,跟真实中文的字符分布差得很远。所以它证明的是"位并行和朴素 DP 算出来一样",而不是"在真实论文上效果一样"。
五、计算模块接口部分的异常处理说明
所有可预期的失败都继承自 PlagiarismError,每个子类自带退出码。main.py 统一捕获这些异常,打印一句能看懂的错误信息并返回对应的退出码,不让程序带着一堆调用栈崩掉。
| 异常类 | 设计目标(对应场景) | 退出码 | 对应单元测试 |
|---|---|---|---|
ArgumentError |
参数不是 3 个,立刻提示正确用法,避免带着错参数继续跑 | 2 | tests/test_exceptions.py::test_argument_error_scenario_too_few_parameters |
InputFileError |
输入文件不存在 / 路径指向目录 / 系统拒绝打开,错误信息里回显路径 | 3 | ::test_input_file_error_scenario_original_path_is_a_directory、::test_input_file_error_scenario_open_fails |
DecodeError |
文件存在但既不是 UTF-8 也不是 GB18030,明确告知是编码问题而不是给乱码结果 | 4 | ::test_decode_error_scenario_binary_file |
OutputFileError |
计算成功但答案文件写不出去,明确失败发生在写文件这一步 | 5 | ::test_output_file_error_scenario_answer_directory_missing |
兜底 except Exception |
预想不到的错误也不留下调用栈 | 1 | ::test_unexpected_error_is_swallowed_by_fallback |
挑三个贴出来:
def test_decode_error_scenario_binary_file(self):
"""场景: 原文是二进制文件, 两种编码都解不开。期望: 退出码 4。"""
binary = os.path.join(self.tmpdir, "orig.bin")
with open(binary, "wb") as handle:
handle.write(b"\xff\xfe\xff\xff")
code, message = self._run([binary, self.copied, self.answer])
self.assertEqual(DecodeError.exit_code, code)
self.assertIn("编码", message)
def test_input_file_error_scenario_open_fails(self):
"""场景: 文件存在但操作系统拒绝打开(权限/占用)。期望: InputFileError。"""
with mock.patch("builtins.open", side_effect=OSError(13, "Permission denied")):
with self.assertRaises(InputFileError):
textio.read_text(self.original)
def test_output_file_error_scenario_answer_directory_missing(self):
"""场景: 答案文件的父目录不存在。期望: 退出码 5。"""
missing = os.path.join(self.tmpdir, "no_such_dir", "ans.txt")
code, message = self._run([self.original, self.copied, missing])
self.assertEqual(OutputFileError.exit_code, code)
self.assertIn("答案文件", message)
这里有一个地方我犹豫过:原文为空的时候要不要也报错。最后没报,而是按公式返回 0.00(两篇都空返回 100.00)。因为空原文不是评测会构造的场景,一旦报错反而整个测试点直接没了,把结果定义清楚比抛异常更稳。
六、PSP 表格(实现之后的实际耗时)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 15 | 20 |
| · Estimate | · 估计这个任务需要多少时间 | 15 | 20 |
| Development | 开发 | 275 | 410 |
| · Analysis | · 需求分析(包括学习新技术) | 30 | 45 |
| · Design Spec | · 生成设计文档 | 20 | 25 |
| · Design Review | · 设计复审 | 15 | 10 |
| · Coding Standard | · 代码规范 | 10 | 10 |
| · Design | · 具体设计 | 30 | 40 |
| · Coding | · 具体编码 | 90 | 150 |
| · Code Review | · 代码复审 | 20 | 30 |
| · Test | · 测试 | 60 | 100 |
| Reporting | 报告 | 50 | 65 |
| · Test Report | · 测试报告 | 20 | 25 |
| · Size Measurement | · 计算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 20 | 30 |
| 合计 | 340 | 495 |
偏差最大的是具体编码(90 → 150)和测试(60 → 100)。编码多花的时间主要都在位并行那段:公式能查到,但要知道它是不是真的对,只能自己写对照脚本慢慢验;测试多花的时间则是因为异常场景不好构造,比如"文件存在但打不开"这种情形,最后是用 mock 把 open 换成抛异常才测出来的。

浙公网安备 33010602011771号