第一次个人编程作业

论文查重 —— 软件工程课程作业

作业GitHub仓库链接:<https://github.com/akcow/3124004180>

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702
这个作业的目标 设计一个论文查重算法,给出一个原文文件和一个在这份原文上经过了增删改的抄袭版论文的文件,在答案文件中输出其重复率。

一、 PSP 估计表

PSP2.1(个人开发流程)预估与实际耗时如下(单位:分钟):

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning(计划) / /
· Estimate · 估计这个任务需要多少时间 20 26
Development(开发) 195 247
· Analysis · 需求分析(包括学习新技术:jieba、余弦相似度、JUnit5) 20 25
· Design Spec · 生成设计文档 15 10
· Design Review · 设计复审 15 20
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 10 10
· Design · 具体设计(类/函数设计、算法选型) 45 60
· Coding · 具体编码 40 50
· Code Review · 代码复审(javac -Xlint / 静态检查) 10 10
· Test · 测试(自我测试,修改代码,提交修改) 10 12
· Performance · 性能改进(JFR + 打点分析、优化,额外记录) 40 50
Reporting(报告) 30 40
· Test Report · 测试报告 15 20
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 10 15
合计 245 313

二、计算模块接口的设计与实现

2.1 代码组织

工程拆成 4 个模块 + 1 个入口:

main.py          ─ 命令行入口,只做编排
preprocess.py    ─ 文本归一化(只保留汉字)
io_utils.py      ─ 编码自适应的文件读写
similarity.py    ─ 计算模块(LCS + 重复率)

对外接口(计算模块)

similarity.plagiarism_rate(original: str, plagiarized: str, metric: str = "orig_base") -> float
similarity.similarity_report(original: str, plagiarized: str, metric: str = "orig_base") -> SimilarityReport

这两个签名在开发全过程中保持不变,性能改进只换内部实现——这是"接口部分性能改进"的语义前提。

2.2 调用关系

flowchart TD main["main.py 命令行入口"] --> read["io_utils.read_text_file"] read --> norm["preprocess.normalize"] norm --> report["similarity.similarity_report"] report --> feas{"is_exact_feasible 预算判断?"} feas -- "预算内" --> lcs["lcs_length 收口点"] feas -- "超预算" --> chunk["chunked_common_length"] lcs --> bit["lcs_length_bitparallel"] lcs -. "对拍参照" .-> dp["lcs_length_dp"] chunk --> lcs report --> write["io_utils.write_answer"]

数据类(只有两个,避免冗余):

  • DecodedText:一个文本文件的解码结果(正文、编码、字节数)
  • SimilarityReport:一次相似度计算的完整结果(重复率、共通长度、分母、口径、是否估计)

2.3 关键函数流程

flowchart TD A["读入原文与抄袭版"] --> B["归一化:只保留汉字"] B --> C{"长度乘积不超过 2 亿?"} C -- "是" --> D["位并行 LCS(O(n*m/64))"] C -- "否" --> E["分块对齐估计"] D --> F["重复率 = LCS / 原文长度"] E --> F F --> G["写出答案:两位小数"]

2.4 算法关键

重复率定义

重复率 = LCS(原文, 抄袭版) / len(原文)

语义是「原文中有多大比例的内容,在抄袭版里被原样、按序保留了下来」。

LCS 基线:经典 O(n·m) 动态规划,每个单元格都要执行一次 Python 层循环,
解释器开销成为瓶颈。

位并行 LCS(核心改进):把较短串的 m 个字符分别映射到大整数的第 i 位,
对单个字符的"整列更新"合并为 (state + u) | (state - u) 一次大整数运算
等价于同时处理 64 位。复杂度降到 O(n·m/64)。

分块对齐估计(兜底):当 len(a)×len(b) 超过 2×10⁸ 时按长度比例把原文切块,
在抄袭版对应窗口内做精确 LCS 再累加。

不能用固定步长抽字符:抄袭版经过增删后,第 k 个字符对应的早已不是
原文第 k 个字符,按位置抽样会把两串的对应关系打乱(实测真实值 0.90 会被
算成 0.40)。这是一个被实测推翻的错误设计被纠正过来的反面教训。

2.5 独到之处

  1. 接口冻结:性能改进只换内部实现,对外接口签名始终不变。
  2. 位并行用 Python 大整数当位向量,运行时仍是纯 Python,无需引入 C 扩展。
  3. 分块估计来自实测推翻的错误设计——保留了"为什么不能这样做"的教训,避免回归。
  4. 编码自适应回退链 + 永不在 IO 路径上抛异常:保证评测机任意编码文件都能处理。

三、计算模块接口部分的性能改进

3.1 工时记录

阶段 内容 耗时(分钟)
5.1 建立基线:写朴素 DP、跑 5 档规模各 5 次 5
5.2 生成性能分析图:cProfile 采集 + matplotlib 出图 15
5.3 定位热点:导出消耗最大的函数 Top-N 5
5.4 实施改进:改算法、改热路径、重测 15
5.5 对比总结:汇总前后数据 + 写改进思路 10
合计 50

3.2 改进思路

朴素 DP 的复杂度是 O(n·m),每个单元格都要执行一次 Python 层循环,瓶颈在解释器开销
与其"减少单元格数量"(会丢精度),不如"改变每个单元格的代价"。把 LCS 的 DP 用
位向量表达——把较短串的 m 个字符分别映射到大整数的第 i 位,于是对单个字符的
"整列更新"可以合并为 (state + u) | (state - u) 一次大整数运算,相当于同时处理 64 位。
这就是位并行 LCS,复杂度 O(n·m/64)。

3.3 性能分析图

tools/profiling.py(基于 cProfile + matplotlib)自动生成:

改进前 改进后
profile_before profile_after
指标 改进前(4000 字) 改进后(10 万字)
总耗时 1.32 秒 0.28 秒
第一名 lcs_length_dp 97.0% lcs_length_bitparallel 53.5%
第二名 文件 IO 1.0% 文件 IO 25.8%

10 万字端到端 0.28 秒,其中 LCS 仍排第一但占比只剩 53.5%——瓶颈被移走
文件 IO 与正则开始提高,这是性能改进成功的标志。

3.4 改进前后耗时对比

tools/bench.py 测量(每档 3 次取中位数):

文本规模 改进前(朴素 DP) 改进后(位并行) 提速
1 500 字 0.166 s 0.0005 s 350×
6 000 字 2.80 s 0.005 s 610×
12 000 字 11.41 s 0.014 s 820×
10 万字 ≈ 790 s(外推) 0.71 s ≈ 1100×

四、单元测试展示

4.1 测试设计思路

  • 白盒为主:先画控制流,按「语句覆盖 → 判定覆盖 → 条件覆盖」逐级设计输入。
  • 核心算法对拍similarity.py 的 LCS 与 tests/helpers.py 里独立的暴力 DP 逐串对照。
  • 边界值 + 等价类:覆盖「正常 / 空 / 异常 / 超大」四类输入。
  • 接口冻结的回归保护:18 个主用例与作业需求表一一对应,任何对 LCS 实现的误改都会被立刻打红。

4.2 部分单元测试代码

用例 1(算法正确性,与独立暴力 DP 对拍):

def test_case12_matches_brute_force_reference():
    rng = random.Random(2026)
    for _ in range(20):
        first = "".join(rng.choice("甲乙丙丁戊") for _ in range(rng.randint(1, 60)))
        second = "".join(rng.choice("甲乙丙丁戊") for _ in range(rng.randint(1, 60)))
        expected = brute_force_lcs(first, second)
        assert lcs_length_dp(first, second) == expected
        assert lcs_length(first, second) == expected

用例 7(题目自带样例回归):

def test_case07_assignment_sample():
    assert rate(SAMPLE_ORIGINAL, SAMPLE_COPY) == pytest.approx(14 / 19)

用例 13(GBK 编码文件能正确读出):

def test_case13_gbk_encoded_file(tmp_path):
    path = tmp_path / "gbk.txt"
    path.write_bytes("今天是星期天。".encode("gb18030"))
    decoded = read_text_file(path)
    assert decoded.encoding == "gb18030"
    assert decoded.text == "今天是星期天。"

性能回归测试(改进前红、改进后绿,本身即改进证据):

def test_large_input_under_5_seconds():
    original = "春江花月夜山水云天风霜雪雨" * 6250  # 100000 字
    copy = original[:90_000]
    started = time.perf_counter()
    plagiarism_rate(original, copy)
    elapsed = time.perf_counter() - started
    assert elapsed < 5.0

实测改进前此用例会超时失败;换成位并行实现后 0.14 秒通过。

4.3 测试覆盖率截图

coverage

54 个用例全部通过,192 条语句 100% 覆盖。


五、异常处理说明

先交代一个前提:计算模块 similarity.py 本身是纯函数——输入是已归一化的两个字符串、
输出是浮点数,不碰文件、不依赖外部状态,因此它内部不产生异常。真正会出错的地方只有
两个边界:文件 IO 层io_utils.py)和命令行入口层main.py)。

所以异常处理的设计目标很明确:把不可控的异常挡在计算模块之外,在边界处把它们翻译成
「一句人话 + 退出码 2」,保证两件事——计算模块永远拿到干净输入;评测机永远看不到
traceback
(作业红线里「发生异常退出」扣 2 分,正是这一条的对策)。

下面按异常类型逐个说明。

5.1 文件不存在(FileNotFoundError

设计目标:输入路径写错、文件被移动是最常见的错误。如果不处理,Python 会抛出一整段
堆栈,末尾才提一句 No such file or directory,既难看又让评测误以为是程序崩了。所以在
IO 边界拦截,翻译成一句人话,并以退出码 2 结束。

错误场景:命令行里原文/抄袭版路径打错,或文件被改名。

def test_case17_missing_file_exits_with_2(tmp_path, capsys):
    """用例 17:输入文件不存在 → 友好提示 + 退出码 2,不打印 traceback。"""
    missing = tmp_path / "nope.txt"
    answer = tmp_path / "answer.txt"
    assert main([str(missing), str(missing), str(answer)]) == 2
    assert "Traceback" not in capsys.readouterr().err

5.2 路径是目录(IsADirectoryError

设计目标:这里有个 Windows 特有的坑——把目录当文件 read_bytes(),原生抛的其实是
PermissionError,会误导用户以为「没权限」。所以不能直接透传,要先判断路径是不是目录,
是就显式转成 IsADirectoryError,让提示语和真实原因对得上。

错误场景:把某个文件夹路径当作输入文件传给程序。

def test_input_directory_exits_with_2(tmp_path):
    """输入路径是目录 → 退出码 2。"""
    copy = tmp_path / "copy.txt"
    copy.write_text("天气晴", encoding="utf-8")
    assert main([str(tmp_path), str(copy), str(tmp_path / "a.txt")]) == 2

5.3 权限不足与其它 OSError

设计目标:文件存在、但不是目录,却依然读不了(ACL 拒绝、文件被锁等),这类归到
PermissionError;白名单之外罕见的 IO 错误,则直接用系统原始错误信息 strerror,不吞掉、
不臆造。核心是一个 _describe_os_error() 翻译函数,把四类 OSError 统一映射成中文提示。

错误场景:文件被系统或 ACL 保护;磁盘 IO 异常等罕见情况。

@pytest.mark.parametrize(
    ("exc", "expected"),
    [
        (FileNotFoundError(), "文件不存在"),
        (IsADirectoryError(), "该路径是目录,不是文件"),
        (PermissionError(), "没有访问权限"),
        (OSError(9999, "自定义错误"), "自定义错误"),
    ],
)
def test_describe_os_error_branches(exc, expected):
    """四种 OSError 都要翻译成人话。"""
    assert _describe_os_error(exc) == expected

5.4 命令行参数校验失败(argparseSystemExit(2)

设计目标:参数个数不对、--metric 传了非法值这类「用法错误」,交给 argparse 统一
处理——它直接以退出码 2 结束并打印用法说明,业务逻辑根本不会执行。好处是用法错误和
运行错误用同一个退出码 2,外层判断简单。

错误场景:少传一个路径参数;--metric bogus(口径只接受 orig_base / max_base)。

def test_case16_wrong_argument_count_exits_with_2(text_pair):
    """用例 16:参数个数不对 → 退出码 2。"""
    original, copy, _ = text_pair
    with pytest.raises(SystemExit) as excinfo:
        main([str(original), str(copy)])
    assert excinfo.value.code == 2


def test_invalid_metric_exits_with_2(text_pair):
    """非法口径取值由 argparse 拦下,退出码 2。"""
    original, copy, answer = text_pair
    with pytest.raises(SystemExit) as excinfo:
        main([str(original), str(copy), str(answer), "--metric", "bogus"])
    assert excinfo.value.code == 2

5.5 答案文件不可写

设计目标:答案路径指向一个目录、或所在目录没有写权限时,同样要友好报错并退出码 2,
而不是让 write_text 抛异常。这保证「算出来了却写不进去」的情况有明确、可预期的结果。

错误场景:把答案文件的路径写成某个目录。

def test_unwritable_answer_exits_with_2(text_pair):
    """答案路径不可写(指向目录)→ 退出码 2。"""
    original, copy, _ = text_pair
    assert main([str(original), str(copy), str(original.parent)]) == 2

5.6 编码解码兜底(不抛异常)

设计目标:读取用「BOM → UTF-8 → GB18030」回退链解不出来的字节时,不抛异常,改用
errors="replace" 把坏字节替换成 继续跑。宁可算出一个可能不精确的重复率,也绝不让
程序因编码问题崩溃——评测机上的文件编码无法预知,这条是硬保证。

错误场景:极罕见的乱码文本、或二进制内容混入文本文件。

def test_undecodable_bytes_fall_back_to_replace():
    """UTF-8 与 GB18030 都解不了时用 replace 兜底,而不是抛异常。"""
    text, encoding = decode_bytes(b"\xff\xff")
    assert encoding == "utf-8(replace)"
    assert isinstance(text, str)

5.7 顶层未预期异常兜底

设计目标:最后一道防线。任何上面没覆盖到的异常,都在入口最外层 except Exception
接住,翻译成一句人话、退出码 2,绝不把 traceback 露给评测。这层是「任何输入都不异常
退出」的最终保障,宁可让错误信息笼统一点,也不能让程序带着堆栈崩溃。

错误场景:任何未预期的运行时错误。

def test_unexpected_exception_is_caught(monkeypatch, capsys):
    """兜底分支:未预期异常也只给一句人话 + 退出码 2,不抛 traceback。"""

    def boom(_args):
        raise RuntimeError("boom")

    monkeypatch.setattr(main_module, "_run", boom)
    assert main(["a", "b", "c"]) == 2
    assert "Traceback" not in capsys.readouterr().err

六、总结

这次作业有以下三点感悟:

第一,算法的选型与验证同样重要。 查重本质是求最长公共子序列(LCS),
朴素动态规划 O(n·m) 在 10 万字输入上要跑几百秒。我最终用位并行 LCS 把复杂度
压到 O(n·m/64),10 万字 0.71 秒,提速约 1100 倍。但比"换了个快算法"更值得说明的是
怎么确认它是对的——位运算是显而易见的"看起来对,其实错"。所以我保留了朴素
DP 作参照,用随机对拍逐串验证,再靠 54 个单元测试把行为固定。

第二,被自己的数据推翻了一次。 最初我以为"超长文本按固定步长抽字符"是
无偏采样,实测 0.90 被算成 0.40,偏差一半。这件事教会了我任何"看起来合理的
优化"都要先跑数据验证,不能想当然。

第三,接口冻结的设计。 plagiarism_rate 的签名从第一版到最后一行代码都没变,
性能改进只发生在内部——这让"改进前后对比"有了稳定锚点,也让测试回归有了明确的
保护对象。

不足也写下来:PSP 估计还是偏乐观,性能改进阶段实际多花了 10 分钟,主要花在
对拍验证和剖析工具链调试上,这两块一开始没算进估计。


posted @ 2026-09-14 21:53  akcow  阅读(10)  评论(0)    收藏  举报