第一次个人编程作业
第一次个人编程作业
GitHub 仓库地址:https://github.com/chobin321/chobin321
零、题目要求与约束
给定原文文件与抄袭版论文文件,计算重复率并把答案写入答案文件,三个文件路径都由命令行参数给出:
python main.py <原文文件> <抄袭版文件> <答案文件>
答案文件中的结果为浮点型,精确到小数点后两位。评测还附带几条硬约束,它们直接决定了我的技术选型:
| 约束 | 我的应对 |
|---|---|
| 5 秒内给出答案 | 核心算法必须是接近线性的复杂度,不能是 O(n·m) 的朴素动态规划 |
| 占用内存不超过 2048 MB | 全程只保留哈希表与位向量,不缓存全文的二维矩阵 |
| 不能异常退出 | 所有可预期失败都翻译成受控异常,边界数据不抛异常 |
| 不能读写其他文件 | 运行时零第三方依赖(下面详细解释这条为什么重要) |
| 不能联网、不能妨碍评测 | 代码中不存在任何 socket / subprocess / system 调用 |
关于最后两条,我做了一次取舍:中文查重最流行的做法是 jieba 分词 + 词频余弦相似度,但 jieba 在首次导入时会去读自己包目录下的词典文件。虽然那属于库的内部资源,但作业明确写了「尝试读写其他文件」以 0 分计,我不想让一个非必要依赖把整个作业置于风险中;而且评测机若是离线环境,pip install 失败还会让程序直接起不来。所以最终选择纯标准库实现,requirements.txt 里不需要任何安装项。
一、PSP 表格(开发前的预估)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 480 | 620 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 90 |
| · Design Spec | · 生成设计文档 | 30 | 40 |
| · Design Review | · 设计复审 | 20 | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 20 | 15 |
| · Design | · 具体设计 | 60 | 70 |
| · Coding | · 具体编码 | 180 | 240 |
| · Code Review | · 代码复审 | 40 | 45 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 70 | 100 |
| Reporting | 报告 | 90 | 120 |
| · Test Report | · 测试报告 | 30 | 40 |
| · Size Measurement | · 计算工作量 | 20 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 40 | 60 |
| 合计 | 600 | 765 |
(预估时我以为「查重算法」是现成的,真正动手才发现难点在性能和边界上,实际耗时比预估多了 27%,详见最后一节的事后总结。)
二、计算模块接口的设计与实现过程
2.1 需求拆解
把需求拆成四个可独立测试的动作:
- 把字节变成字符——文件可能是 UTF-8、带 BOM 的 UTF-8,也可能是 GBK;
- 把字符变成可比较的序列——标点、空白、大小写、全角半角都属于「无关差异」;
- 给两段序列打分——算法核心,输出 [0, 1] 的重复率;
- 把分数写成答案——两位小数,UTF-8 编码。
2.2 代码组织
我按「一个动作一个类」的方式组织代码,全部放在 src 包中:
| 文件 | 类 / 函数 | 职责 |
|---|---|---|
main.py |
parse_arguments / main |
参数个数校验、异常兜底、退出码 |
src/checker.py |
PlagiarismChecker |
门面:读文件 → 归一化 → 打分 → 写答案 |
src/normalizer.py |
Normalizer |
NFKC 折叠、转小写、剔除标点空白 |
src/file_io.py |
SourceReader / AnswerWriter |
多编码探测读取、两位小数写出 |
src/similarity.py |
SimilarityStrategy(抽象基类)、LcsSimilarity、MatchedBlockSimilarity、SimilarityCalculator、SimilarityReport |
两个相似度策略、加权融合、结果载体 |
src/exceptions.py |
PlagiarismError 及其 4 个子类 |
异常体系 |
关系上是一个很规整的门面 + 策略模式:
main.py
│ 传入三个文件路径
▼
SourceReader ──► PlagiarismChecker ──► AnswerWriter
│
▼
Normalizer(归一化)
│
▼
SimilarityCalculator(加权融合)
│ │
LcsSimilarity MatchedBlockSimilarity
│ │
└──────┬───────┘
▼
SimilarityReport(rate, components)
这样切分带来两个直接好处:
- 两个相似度策略都能被单独构造输入测试(不必真的去读文件),覆盖率因此容易做到 100%;
- 融合权重、锚点长度这些参数集中在类属性里,第三节做性能实验时改一处即可。
SimilarityCalculator 还预留了一个降级机制:每个策略都有 supports(source, suspect),不适合时自动退出,剩余策略的权重重新归一化,而不是让程序崩掉。
2.3 关键算法
(1)归一化
def normalize(self, text: str) -> str:
if not text:
return ""
folded = self.full_width_to_half_width(text).lower()
return "".join(char for char in folded if char.isalnum())
unicodedata.normalize("NFKC", ...) 一步搞定全角转半角与兼容字符展开,isalnum() 留下的正好是「汉字 + 字母 + 数字」。作业给出的样例:
今天是星期天,天气晴,今天晚上我要去看电影。 → 今天是星期天天气晴今天晚上我要去看电影
今天是周天,天气晴朗,我晚上要去看电影。 → 今天是周天天气晴朗我晚上要去看电影
(2)LCS 相似度(权重 0.7)
打分公式采用 Dice 形式:
S_lcs = 2 × |LCS(A, B)| / (|A| + |B|)
它天然满足三条直觉:完全相同是 1、完全无关接近 0、少量增删改只会小幅掉分。关键在怎么快速算出 LCS 长度——朴素动态规划是 O(n·m),3000 字的文章就要 1 秒多,5 万字直接超时(详见第三节)。
我用的是 Hyyrö 的位并行(bit-parallel)算法:把较短串的每个字符映射成一个比特掩码,用 Python 的任意精度整数一次性处理整行,每读入一个字符只做几次大整数位运算。O(n·m) 次 Python 循环被压缩成 n 次大整数运算,而大整数运算是在 C 层实现的。
@staticmethod
def lcs_length(first: str, second: str) -> int:
"""返回两个字符串的最长公共子序列长度(位并行实现)。"""
if len(first) > len(second):
first, second = second, first
length = len(first)
if length == 0:
return 0
masks: Dict[str, int] = {}
for index, char in enumerate(first):
masks[char] = masks.get(char, 0) | (1 << index)
full_mask = (1 << length) - 1
state = full_mask
for char in second:
matched = state & masks.get(char, 0)
state = (state + matched) | (state - matched)
# state 中为 0 的比特个数即为最长公共子序列长度
return length - bin(state & full_mask).count("1")
位运算的公式并不直观,所以我在单元测试里用教科书动态规划做对照组随机对拍:3 组种子 × 300 对随机字符串,位并行结果必须与 DP 完全一致(tests/test_similarity.py::test_lcs_matches_dynamic_programming_reference)。
(3)匹配块覆盖率(权重 0.3)
只用 LCS 有个盲点:抄写者如果把整段照搬、其余全部重写,LCS 只能反映「骨架还在」,却看不出「有一整段原样照抄」。所以我加了第二个维度——抄袭版中有多大比例的字符落在与原文完全一致的连续片段里:
- 建锚点索引:一次扫描原文,建立「k 元片段 → 出现位置」的哈希表,k 随文本长度自适应(
k = clamp(长度 / 10, 2, 6),短文本用 2 保证能找到锚点,长文本用 6 避免「我们」「因此」这类高频片段造成虚假匹配); - 自左向右扫描抄袭版:遇到尚未被覆盖的位置就查索引,从候选位置向右延伸成极长公共子串;
- 统计覆盖:用标记位记下已被覆盖的位置,块与块天然不重叠,累加长度再除以抄袭版长度。
def score(self, source: str, suspect: str) -> float:
if not source or not suspect:
return 0.0
covered = sum(high - low for low, high in self.matched_blocks(source, suspect))
return min(1.0, covered / len(suspect))
(4)加权融合
rate = 0.7 × S_lcs + 0.3 × S_block
两个分量的分工很明确:
| 场景 | S_lcs | S_block | 融合结果 |
|---|---|---|---|
| 完全一致 | 1.00 | 1.00 | 1.00 |
| 少量增删改(作业样例) | 0.78 | 0.82 | 0.79 |
| 只改标点 | 1.00 | 1.00 | 1.00 |
| 换一种说法重写 | 明显下降 | 接近 0 | 明显下降 |
| 两篇无关文章 | 0.05 ~ 0.11 | 0 | 0.04 ~ 0.08 |
为了确认「改得越多、分数越低」不是错觉,我用同一个语料生成器固定 2000 字的文本、逐步提高随机改写的字符比例,得到的重复率是一条单调曲线(这就是 python -m tools.benchmark 输出的第 4 部分):
| 随机改写字符比例 | 5% | 10% | 20% | 30% | 50% |
|---|---|---|---|---|---|
| 判定重复率 | 0.93 | 0.86 | 0.70 | 0.56 | 0.36 |
2.4 独到之处
- 零依赖不是「图省事」,而是风险规避决策。作业里「读写其他文件即 0 分」这一条把最主流的分词库方案直接排除,算法因此必须建立在不依赖词典的分字基础上——字符级匹配与位并行 LCS 正好满足。
- 两个互补维度而不是一个相似度。单一指标总会被某一类抄袭手法骗过:只做 n-gram 会被「改标点、调语序」虚高,只做 LCS 会被「整段照搬 + 其余重写」低估。加权融合后两者互为约束。
- 把「对照实现」留在仓库里。
tools/reference.py里那份朴素 DP 只做两件事:给位并行实现对拍、给性能实验做基线。优化因此有可量化的事实依据,而不是「感觉快了」。 - 参数自适应。锚点长度随文本长度变化;超过 12 万字符时 LCS 策略会自动退出、把权重让给块覆盖率,而不是硬算到超时。
三、计算模块接口部分的性能改进
3.1 用什么工具
作业提到 VS 2017 / JProfiler 的 Profiling Tools,这两个是 C++ / Java 生态的工具,Python 这边的对应物是标准库自带的 cProfile + pstats(想在图形界面里看,可以用 VS 2017 的 Python 性能探查器,原理一致)。我在仓库里放了一个可复现的基准脚本:
python -m tools.benchmark
它会依次完成:① 对拍朴素 DP 与位并行 LCS 的耗时;② 测量端到端流水线的时间和内存;③ 运行 cProfile 并输出热点函数;④ 输出改写比例与判定重复率的对照表。
3.2 第一轮:找出瓶颈
第一版我是照着教科书写的动态规划 LCS,与优化后的位并行版本对比:
文本长度(字符) 动态规划(s) 位并行(s) 加速比
------------------------------------------------
500 0.0248 0.0002 144.91
1000 0.1041 0.0004 272.75
2000 0.4332 0.0009 461.29
3000 0.9739 0.0019 522.25
10000 — 0.0141 —
20000 — 0.0444 —
50000 — 0.2286 —
DP 的耗时随规模呈平方级增长:3000 字 0.97 秒,按平方外推 5 万字要 250 秒以上,评测的 5 秒上限根本过不去。这就是第一个、也是最大的瓶颈。

3.3 改进一:LCS 从动态规划改为位并行
思路在 2.3 节已经写过:把较短串映射到比特掩码上,用 Python 的任意精度整数做整行运算。改进效果随文本变长而放大(因为省掉的正是 O(n·m) 里的那个 m):
- 500 字:145 倍
- 3000 字:522 倍
- 5 万字:0.23 秒,已经不需要跟 DP 比了(按平方外推要 250 秒以上)
另一个副作用是空间:DP 版本为了提速通常要保留二维矩阵(O(n·m) 个整数),位并行版本每个字符只占 1 个比特,5 万字的位向量才 6 KB。
3.4 改进二:匹配块从暴力扫描改为哈希索引 + 单次自左向右扫描
块覆盖率最初的写法是「对抄袭版的每个位置,去原文里 find 一遍,再把相邻的锚点合并成长块」。它有两个问题:
- 每个锚点都要扫一遍原文,最坏 O(n²);
- 合并相邻锚点时有一个隐含的错误假设——「原文里有
abc、也有bcd」并不等于「原文里有abcd」,这会让覆盖率虚高。
改进后分三步:
- 先用一次 O(n) 扫描为原文建立「k 元片段 → 最多 4 个出现位置」的哈希索引;
- 自左向右扫描抄袭版:若当前位置还没被任何匹配块覆盖,就查索引拿到候选位置,向右延伸成极长公共子串(多个候选取延伸最远的那个;延伸时先按 64 字符整块比较,再逐字符收尾);
- 用
bytearray记录已被覆盖的位置,块与块天然不重叠,覆盖率就是「被标记的位置数 ÷ 抄袭版长度」。
改动之后复杂度从 O(n²) 降到 O(n),而且结果不再依赖扫描顺序,稳定可复现。这一步的效果在 3.7 节的热点函数图里看得很直接:_index_anchors 与 _extend_right 合计只占 13 ms,而改造前它们是最主要的开销之一。
3.5 改进三:超长文本自动降级
位向量的长度等于较短文本的长度:5 万字的位向量是 6 KB,12 万字是 15 KB。但位并行 LCS 的耗时仍随规模线性增长,实测数据:
| 文本长度 | LCS 参与计算 | 降级后(只用块覆盖率) |
|---|---|---|
| 10 万字符 | 0.85 s | — |
| 20 万字符 | 3.5 s(已逼近上限) | 0.19 s |
| 50 万字符 | — | 0.51 s |
所以 LcsSimilarity 设了 MAX_BIT_LENGTH = 120_000:超过它就让出计算权,融合器通过 supports() 判断后把权重重新归一化给块覆盖率。
这是一个可用性优先的取舍:12 万字符已经远超「一篇论文」的规模(30 页的中文论文通常也就 3~5 万字),此时宁可分数粗一点,也不能踩到 5 秒上限。代价是阈值两侧的分数会有跳变(同一对 20 万字的文本,LCS 参与时是 0.70,降级后是 0.49),这一点我明确写进了类的文档字符串,避免以后自己踩坑。
3.6 改进后的结果
端到端流水线(除读写文件外的全部计算:归一化 + 两个策略 + 加权融合):
| 文本长度(字符) | 耗时(秒) | 峰值内存(MB) |
|---|---|---|
| 1000 | 0.001 | 0.07 |
| 5000 | 0.007 | 0.33 |
| 10000 | 0.019 | 0.66 |
| 20000 | 0.052 | 1.32 |
| 50000 | 0.277 | 3.32 |
| 100000 | 0.850 | 6.64 |

测量方法说明:耗时与内存分两轮测。
tracemalloc会记录每一笔内存分配,开着它计时会把耗时放大 3~4 倍,所以计时那一轮不启用它,内存那一轮才启用。这个坑我是对着两份数据对不上号时才发现的。
10 万字的文章 0.85 秒完成、内存 6.6 MB,距离 5 秒 / 2048 MB 的评测上限有一个数量级以上的余量。
3.7 消耗最大的函数
两段 20000 字文本的 cProfile 结果(python -m tools.benchmark 的第 3 部分):
179510 function calls in 0.105 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.054 0.054 0.059 0.059 src/similarity.py:80(lcs_length)
65683 0.010 0.000 0.010 0.000 {method 'get' of 'dict' objects}
36960 0.008 0.000 0.012 0.012 src/normalizer.py:27(<genexpr>)
1 0.007 0.007 0.011 0.011 src/similarity.py:179(_index_anchors)
967 0.006 0.000 0.008 0.008 src/similarity.py:191(_extend_right)
2 0.005 0.002 0.016 0.008 {method 'join' of 'str' objects}
2 0.004 0.002 0.004 0.004 {built-in method unicodedata.normalize}
1 0.004 0.004 0.025 0.025 src/similarity.py:158(matched_blocks)

最大的消耗在 LcsSimilarity.lcs_length:独自占了 54 ms,而整段流水线是 105 ms,超过一半。这符合预期——它就是那个逐字符推进的循环,第二名的 dict.get 也是同一个循环内部的哈希查找。做完位并行优化之后它已经是「每次循环只做 4 次大整数运算」的形态,继续优化的空间只剩减少 Python 层的循环次数(例如把 masks.get 提前绑定成局部变量),收益在毫秒级,性价比不高,所以到此为止。
值得一提的是,_index_anchors 与 _extend_right 合计只有 13 ms——这正是 3.4 节改进的效果:它们从「平方级」变成了「线性 + 常数」。
四、计算模块部分单元测试展示
4.1 运行方式
python -m pytest # 49 个用例
python -m tools.coverage_report # 行覆盖率
4.2 测试用例设计
共 49 个用例,按「核心算法 → 归一化 → 文件 IO → 端到端」四层组织。
核心算法层(tests/test_similarity.py,18 个)
| 用例 | 测什么 |
|---|---|
test_lcs_matches_dynamic_programming_reference[1..3] |
随机对拍:3 组种子 × 300 对随机串,位并行 LCS 必须等于动态规划结果 |
test_lcs_scores_one_for_identical_texts |
完全相同 → 1.0 |
test_lcs_scores_zero_for_empty_input |
空输入 → 0.0,不抛异常 |
test_lcs_sample_score_is_stable |
样例题的 LCS 长度为 14、相似度固定为 28/36 |
test_block_coverage_is_full_for_copied_text |
整段照搬 → 覆盖率 1.0 |
test_block_coverage_is_zero_for_unrelated_text |
两篇无关文章 → 覆盖率 0.0 |
test_block_coverage_ignores_punctuation_only_differences |
只改标点 → 覆盖率 1.0 |
test_anchor_size_grows_with_text_length |
锚点长度自适应的三个边界(2 / 3 / 6) |
test_calculator_sample_rate_matches_expected_range |
样例融合结果落在 0.79 ± 0.02 |
test_calculator_degrades_when_strategy_unsupported |
降级路径:LCS 退出后权重重归一化 |
test_calculator_output_is_never_out_of_range |
输出恒在 [0, 1] |
test_abstract_strategy_requires_a_concrete_score |
抽象基类未实现时报 NotImplementedError |
test_block_strategy_returns_empty_for_empty_input |
空输入下块列表为空 |
test_block_strategy_tolerates_degenerate_anchor_configuration |
锚点长度被配成 0 时不能除零崩溃 |
test_report_describe_contains_every_component |
报告文本包含全部分量 |
test_similarity_decreases_as_edits_increase |
单调性:完全不改 > 轻微改写 > 大幅改写 |
归一化层(tests/test_normalizer.py,6 个):标点空白、全角半角、英文大小写、符号 emoji、空串与纯标点、字符顺序不变。
文件 IO 层(tests/test_file_io.py,10 个):UTF-8 BOM、GB18030、文件不存在、传入目录、空路径、无法解码的字节流、两位小数格式、整数与零的格式、答案目录不存在、读取时 OSError 被包装。
端到端层(tests/test_checker.py,15 个):完全相同的文件对、作业样例、无关文件、空文件、只改标点、参数个数错误、空参数、递归上限兜底、正常返回 0、从 sys.argv 取参、按评测方式启动子进程、以 __main__ 身份执行、文件不存在的退出码、20000 字的耗时上限、依赖注入。
其中两个用例是专门为了「像评测机一样运行」而写的:
def test_script_entry_point_behaves_like_the_grader_command(tmp_path: Path) -> None:
"""按评测方式 ``python main.py a b c`` 启动子进程,校验退出码与答案格式。"""
source = _write(tmp_path / "orig.txt", "今天是星期天,天气晴,今天晚上我要去看电影。")
suspect = _write(tmp_path / "orig_add.txt", "今天是周天,天气晴朗,我晚上要去看电影。")
answer = tmp_path / "ans.txt"
result = subprocess.run(
[sys.executable, str(MAIN_SCRIPT), source, suspect, str(answer)],
capture_output=True, text=True, encoding="utf-8", check=False,
)
assert result.returncode == EXIT_SUCCESS, result.stderr
assert answer.read_text(encoding="utf-8").strip() == "0.79"
4.3 覆盖率
Python 生态常用的覆盖率工具是 coverage / pytest-cov,但既然运行时依赖是「零」,测试工具我也用了零依赖的做法:tools/coverage_report.py 用 sys.settrace 记录执行过的行,用 compile() + code.co_lines() 求可执行行集合,两者相除得到行覆盖率。
$ python -m tools.coverage_report
................................................. [100%]
文件 已覆盖 可执行 覆盖率
------------------------------------------------------
main.py 39 39 100.0%
src/__init__.py 2 2 100.0%
src/checker.py 29 29 100.0%
src/exceptions.py 30 30 100.0%
src/file_io.py 44 44 100.0%
src/normalizer.py 12 12 100.0%
src/similarity.py 154 154 100.0%
------------------------------------------------------
合计 310 310 100.0%

4.4 对这些测试用例的评价
能覆盖到的:
- 白盒覆盖:行覆盖率 100%,说明每条语句至少被执行过一次;边界值(空输入、长度为 0 的锚点、位向量上限)都有专门用例;
- 正确性验证:最大的风险点是位并行 LCS 那个并不直观的位运算公式,用 900 组随机对拍把它钉死了;
- 规格验证:答案文件格式、退出码、命令行调用方式,都照着评测说明写了用例;
- 回归保护:单调性用例保证以后调整权重时,不会把「改得越多分数越高」这种低级错误放进去。
还不足以覆盖到的:
- 真实论文语料的语义改写(同义词替换、主被动改写)无法在单元测试里构造,只能拿课堂上发的样例集人工回归;
- 覆盖率统计的是行覆盖率而非分支覆盖率,
if/else两条分支都被执行过,不代表每种组合都被验证过; - 缺少分词带来的误差没有客观基准可比对——我拿不到官方答案,只能保证样例值是 0.79,与大家常用的 0.80 接近。
五、计算模块部分异常处理说明
异常体系的设计目标:让「输入数据的问题」与「程序自身的缺陷」区分开——前者给出人能看懂的中文提示,后者才让它冒泡(便于修 bug)。所有可预期失败都继承 PlagiarismError,main.py 在入口统一兜底,保证不会出现未捕获的堆栈,因为评测里「发生异常退出」要扣分。
| 异常 | 触发场景 | 设计目标 |
|---|---|---|
ArgumentError |
命令行参数个数不是 3、参数为空串 | 在触碰文件系统之前就失败,避免把「命令敲错」误报成「文件不存在」 |
SourceFileError |
文件不存在、路径指向目录、无读权限、读取时 OSError | 携带出错路径与原因,错误信息自解释 |
TextDecodeError |
所有候选编码(utf-8-sig / utf-8 / gb18030)都解不开 | 中英文混杂的作业文件编码五花八门,必须给出「试过哪些编码」 |
AnswerWriteError |
答案目录不存在、目标是目录、无写权限 | 写答案是最后一步,失败要说清是「写不进去」而不是「算错了」 |
每种异常对应的单元测试:
1)ArgumentError——参数个数不对
def test_main_reports_usage_error_for_wrong_argument_count() -> None:
"""参数个数不符合规范时返回用法错误码。"""
assert main(["only-one-argument"]) == EXIT_USAGE_ERROR
assert main([]) == EXIT_USAGE_ERROR
场景:手敲命令时漏了一个参数。程序不抛堆栈,打印用法并以 1 退出。
2)SourceFileError——文件不存在 / 传进来一个目录
def test_missing_file_raises_source_file_error(tmp_path: Path) -> None:
with pytest.raises(SourceFileError) as info:
SourceReader().read(str(tmp_path / "nope.txt"))
assert "文件不存在" in str(info.value)
def test_directory_input_raises_source_file_error(tmp_path: Path) -> None:
with pytest.raises(SourceFileError) as info:
SourceReader().read(str(tmp_path)) # 直接传目录
assert "目录" in str(info.value)
场景:路径拼错,或者把评测数据的目录当成了文件。两种情况都能识别,不会一个报「不存在」、另一个抛出莫名其妙的 IsADirectoryError。
3)TextDecodeError——编码全都对不上
def test_undecodable_bytes_raise_text_decode_error(tmp_path: Path) -> None:
path = tmp_path / "broken.txt"
path.write_bytes(b"\xff\xfe\x82\x81\xff\xff")
with pytest.raises(TextDecodeError) as info:
SourceReader().read(str(path))
assert "候选编码" in str(info.value)
场景:文件是二进制,或使用了冷门编码。提示里会列出尝试过的编码顺序,方便定位。
4)AnswerWriteError——答案写不进去
def test_unwritable_answer_path_raises(tmp_path: Path) -> None:
"""目标目录不存在时给出受控异常而不是裸 OSError。"""
with pytest.raises(AnswerWriteError):
AnswerWriter().write(str(tmp_path / "no_such_dir" / "ans.txt"), 0.5)
场景:答案路径的上级目录不存在。裸的 FileNotFoundError 会让人误以为是输入文件的问题,包装之后语义就清楚了。
一个刻意的例外设计:EmptyTextError 我最终没有让它抛出来。理由是——如果评测给出的某个测试点是空文件或纯标点,抛异常就意味着「异常退出」,该测试点直接判定不通过;而此时正确答案其实是 0.00。所以归一化后为空时,程序把它当作数据边界而不是程序错误:
if not source_normalized or not suspect_normalized:
return SimilarityReport(rate=0.0, components={})
对应的测试用例:
def test_empty_file_yields_zero_without_crashing(tmp_path: Path) -> None:
"""空文件属于边界数据,答 0.00 而不是异常退出。"""
...
assert rate == 0.0
assert answer.read_text(encoding="utf-8").strip() == "0.00"
六、PSP 表格(开发后的实际耗时)
见第一节表格的「实际耗时」列,合计 765 分钟,比预估的 600 分钟多 27%。
七、运行示例与提交记录
7.1 运行示例
$ python main.py samples\orig.txt samples\orig_add.txt ans.txt
重复率 0.79 已写入 ans.txt
$ type ans.txt
0.79
7.2 代码质量
$ python -m flake8 -j 1 .
(无输出,0 告警)
$ python -m pylint main.py src tests tools
Your code has been rated at 10.00/10
7.3 提交记录
按「基本功能 → 扩展功能」拆分,每个功能点通过测试后提交一次:
| 顺序 | Commit message | 内容 |
|---|---|---|
| 1 | chore: 初始化项目骨架与目录结构 |
目录、requirements.txt、.gitignore |
| 2 | feat: 完成文件读取与多编码探测 |
src/file_io.py + 单元测试 |
| 3 | feat: 完成文本归一化 |
src/normalizer.py + 单元测试 |
| 4 | feat: 实现 LCS 相似度基本功能 |
动态规划版 LcsSimilarity |
| 5 | feat: 增加匹配块覆盖率策略与加权融合 |
MatchedBlockSimilarity + SimilarityCalculator |
| 6 | feat: 打通命令行主流程 |
main.py + PlagiarismChecker |
| 7 | perf: LCS 改为位并行实现,5 万字耗时降低两个数量级 |
3.3 节的性能改进 |
| 8 | perf: 匹配块改为哈希索引 + 单次扫描 |
3.4 节的性能改进 |
| 9 | feat: 完善异常体系与边界数据处理 |
src/exceptions.py + 空文件兜底 |
| 10 | test: 补齐 49 个单元测试与覆盖率采集脚本 |
tests/、tools/coverage_report.py |
| 11 | style: 消除 flake8 与 pylint 全部告警 |
格式化、文档字符串、f-string |
| 12 | docs: 补充 README 与性能基准脚本 |
文档 |
八、事后总结与过程改进
做对了的:
- 动手写核心算法之前先用脚本做原型验证——位并行 LCS 的公式我是先跟动态规划对拍通过了才往工程里搬的,省下大量调试时间;
- 把「对照实现」和「基准脚本」留在仓库里,优化前后有可量化的数据,而不是凭感觉说「快了」;
- 一开始就把「不能用分词库」这个约束想清楚,避免了中途推翻重做。
踩过的坑:
- 第一版块覆盖率用「锚点合并」的写法,以为「相邻两个 k 元片段都在原文里」等价于「合并后的长片段也在原文里」——这个推断是错的(原文有
abc、bcd不等于有abcd),导致覆盖率偏高。改成「每个锚点独立延伸 + 贪心选不重叠的块」之后才正确。教训是:能用贪心拆开解决的问题,就不要去做组合推断。 - 低估了「测试与调试」环节的耗时。今后的 PSP 预估里,测试应按照编码耗时的 50% 起步。
下次改进:
- 先写测试骨架再写实现(这次核心算法是这样做的,IO 层不是);
- 每做完一个模块立刻跑一次
pylint,而不是等最后集中改; - 性能基准脚本应在第一次跑通功能时就写好,这样每次改动都能看到耗时曲线的变化。
附:完整代码目录
学号/
├── main.py
├── requirements.txt
├── README.md
├── pytest.ini / setup.cfg / .pylintrc / .gitignore
├── src/
│ ├── __init__.py
│ ├── checker.py
│ ├── exceptions.py
│ ├── file_io.py
│ ├── normalizer.py
│ └── similarity.py
├── tests/
│ ├── test_checker.py
│ ├── test_file_io.py
│ ├── test_normalizer.py
│ └── test_similarity.py
├── tools/
│ ├── benchmark.py
│ ├── coverage_report.py
│ └── reference.py
└── samples/
├── orig.txt
└── orig_add.txt
浙公网安备 33010602011771号