第一次个人编程作业

第一次个人编程作业

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 需求拆解

把需求拆成四个可独立测试的动作:

  1. 把字节变成字符——文件可能是 UTF-8、带 BOM 的 UTF-8,也可能是 GBK;
  2. 把字符变成可比较的序列——标点、空白、大小写、全角半角都属于「无关差异」;
  3. 给两段序列打分——算法核心,输出 [0, 1] 的重复率;
  4. 把分数写成答案——两位小数,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(抽象基类)、LcsSimilarityMatchedBlockSimilaritySimilarityCalculatorSimilarityReport 两个相似度策略、加权融合、结果载体
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 只能反映「骨架还在」,却看不出「有一整段原样照抄」。所以我加了第二个维度——抄袭版中有多大比例的字符落在与原文完全一致的连续片段里

  1. 建锚点索引:一次扫描原文,建立「k 元片段 → 出现位置」的哈希表,k 随文本长度自适应(k = clamp(长度 / 10, 2, 6),短文本用 2 保证能找到锚点,长文本用 6 避免「我们」「因此」这类高频片段造成虚假匹配);
  2. 自左向右扫描抄袭版:遇到尚未被覆盖的位置就查索引,从候选位置向右延伸成极长公共子串;
  3. 统计覆盖:用标记位记下已被覆盖的位置,块与块天然不重叠,累加长度再除以抄袭版长度。
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 独到之处

  1. 零依赖不是「图省事」,而是风险规避决策。作业里「读写其他文件即 0 分」这一条把最主流的分词库方案直接排除,算法因此必须建立在不依赖词典的分字基础上——字符级匹配与位并行 LCS 正好满足。
  2. 两个互补维度而不是一个相似度。单一指标总会被某一类抄袭手法骗过:只做 n-gram 会被「改标点、调语序」虚高,只做 LCS 会被「整段照搬 + 其余重写」低估。加权融合后两者互为约束。
  3. 把「对照实现」留在仓库里tools/reference.py 里那份朴素 DP 只做两件事:给位并行实现对拍、给性能实验做基线。优化因此有可量化的事实依据,而不是「感觉快了」。
  4. 参数自适应。锚点长度随文本长度变化;超过 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 秒上限根本过不去。这就是第一个、也是最大的瓶颈。

image

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」,这会让覆盖率虚高。

改进后分三步:

  1. 先用一次 O(n) 扫描为原文建立「k 元片段 → 最多 4 个出现位置」的哈希索引;
  2. 自左向右扫描抄袭版:若当前位置还没被任何匹配块覆盖,就查索引拿到候选位置,向右延伸成极长公共子串(多个候选取延伸最远的那个;延伸时先按 64 字符整块比较,再逐字符收尾);
  3. 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

image

测量方法说明:耗时与内存分两轮测。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)

image

最大的消耗在 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.pysys.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%

image

4.4 对这些测试用例的评价

能覆盖到的

  • 白盒覆盖:行覆盖率 100%,说明每条语句至少被执行过一次;边界值(空输入、长度为 0 的锚点、位向量上限)都有专门用例;
  • 正确性验证:最大的风险点是位并行 LCS 那个并不直观的位运算公式,用 900 组随机对拍把它钉死了;
  • 规格验证:答案文件格式、退出码、命令行调用方式,都照着评测说明写了用例;
  • 回归保护:单调性用例保证以后调整权重时,不会把「改得越多分数越高」这种低级错误放进去。

还不足以覆盖到的

  • 真实论文语料的语义改写(同义词替换、主被动改写)无法在单元测试里构造,只能拿课堂上发的样例集人工回归;
  • 覆盖率统计的是覆盖率而非分支覆盖率,if/else 两条分支都被执行过,不代表每种组合都被验证过;
  • 缺少分词带来的误差没有客观基准可比对——我拿不到官方答案,只能保证样例值是 0.79,与大家常用的 0.80 接近。

五、计算模块部分异常处理说明

异常体系的设计目标:让「输入数据的问题」与「程序自身的缺陷」区分开——前者给出人能看懂的中文提示,后者才让它冒泡(便于修 bug)。所有可预期失败都继承 PlagiarismErrormain.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 元片段都在原文里」等价于「合并后的长片段也在原文里」——这个推断是错的(原文有 abcbcd 不等于有 abcd),导致覆盖率偏高。改成「每个锚点独立延伸 + 贪心选不重叠的块」之后才正确。教训是:能用贪心拆开解决的问题,就不要去做组合推断
  • 低估了「测试与调试」环节的耗时。今后的 PSP 预估里,测试应按照编码耗时的 50% 起步。

下次改进

  1. 先写测试骨架再写实现(这次核心算法是这样做的,IO 层不是);
  2. 每做完一个模块立刻跑一次 pylint,而不是等最后集中改;
  3. 性能基准脚本应在第一次跑通功能时就写好,这样每次改动都能看到耗时曲线的变化。

附:完整代码目录

学号/
├── 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
posted @ 2026-09-15 23:38  chobin321  阅读(6)  评论(0)    收藏  举报