第一次个人编程作业

这个作业属于哪个课程 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702
这个作业的目标 实现一个基于命令行文件输入输出的论文查重程序,准确计算原文与改写文本的重复率。

项目仓库lwq66666/RJGC

学号:3124004249

开发语言:C++17

项目版本:v1.0

一、项目简介

本次作业题目为"论文查重"。程序从命令行接收三个参数:原文文件绝对路径、抄袭版论文文件绝对路径、答案文件绝对路径。程序读取前两个文件后计算重复率,将结果以浮点数写入答案文件,精确到小数点后两位。

我使用 C++17 完成,只依赖标准库。入口为 project1.cpp,核心计算分布在 UnicodeTextTokenizerSimHasherPlagiarismChecker 等模块,测试在 tests/test_main.cpp

main.exe <原文文件绝对路径> <抄袭版论文文件绝对路径> <答案文件绝对路径>

例如 main.exe D:\tests\orig.txt D:\tests\orig_0.8_add.txt D:\tests\ans.txt

程序成功运行时不输出任何内容,唯一产出是答案文件——这是命令行工具的惯例。答案文件内容严格是 4 个字节 0.91不带换行符(万一评测是读全文比对字符串,多一个字节就会判错)。

二、PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 480 640
· Analysis · 需求分析(包括学习新技术) 60 75
· Design Spec · 生成设计文档 30 25
· Design Review · 设计复审 20 15
· Coding Standard · 代码规范 20 15
· Design · 具体设计 60 90
· Coding · 具体编码 180 240
· Code Review · 代码复审 40 45
· Test · 测试(自我测试,修改代码,提交修改) 70 135
Reporting 报告 90 130
· Test Report · 测试报告 30 40
· Size Measurement · 计算工作量 20 15
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 40 75
合计 合计 600 795

两处明显超支:「测试」70 → 135 分钟,超支不是因为写测试慢,而是为了量覆盖率踩了三个工具坑(见 5.4);「事后总结」40 → 75 分钟,因为花大量时间做性能测量,最后结论是"我原本计划的两处优化都不成立"(见第四节)。

三、计算模块接口的设计与实现过程

3.1 代码组织

按"单一职责 + 依赖单向"拆成 9 个模块,全部在 namespace paper_check 中,依赖关系无环。

文件 作用 依赖
Common.h 公共类型别名(CodePoint / Token / Fingerprint)与常量

| Exceptions.h | 异常层次:AppError 及四个子类 | — |
| UnicodeText.h/.cpp | UTF-8 编解码、汉字/字母数字判定 | Common |
| Hash.h/.cpp | FNV-1a 64 位哈希 | Common |
| Tokenizer.h/.cpp | 分词(bigram / unigram)与词频统计 | Common UnicodeText |
| SimHasher.h/.cpp | 词频表 → 64 位指纹;海明距离、相似度换算 | Common Hash |
| TextFile.h/.cpp | RAII 输入文件(构造即打开/读取,失败抛异常) | Common Exceptions |
| AnswerFile.h/.cpp | RAII 输出文件(构造即创建,析构 flush + close) | Common Exceptions |
| PlagiarismChecker.h/.cpp | 业务编排:把上面几步串起来 | 其余全部 |
| project1.cpp | 入口:解析参数、调编排层、统一异常处理 | PlagiarismChecker |

依赖单向无环带来一个实际好处:计算类模块完全不碰文件系统,单元测试可以直接调用,不需要准备测试文件。

入口保持简单,只做参数校验和异常捕获:

int main(int argc, char** argv) {
    try {
        if (argc != 4) {   // argv[0] 是程序名,三个参数对应 argc == 4
            throw ArgumentError("命令行参数个数不正确,需要 3 个参数,实际收到 " +
                                std::to_string(argc - 1) + " 个");
        }
        const PlagiarismChecker checker;
        const SimilarityResult result = checker.CompareFiles(argv[1], argv[2]);

        AnswerFile answer_file(argv[3]);   // 先算结果再建文件,顺序不能换(见 6.4)
        answer_file.WriteSimilarity(result.similarity);
        return 0;
    }
    catch (const ArgumentError& error) {
        std::cerr << "错误: " << error.what() << std::endl;
        PrintUsage();
        return 1;
    } catch (const AppError& error) {
        std::cerr << "错误: " << error.what() << std::endl;
        return 1;
    } catch (...) {   // 兜底:未捕获异常会 std::terminate 杀进程,要扣分
        std::cerr << "未预期的未知错误" << std::endl;
        return 1;
    }
}

catch 顺序必须"先具体、后笼统"——C++ 按书写顺序匹配,把 AppError 写前面的话 ArgumentError 分支永远不会命中。

3.2 主接口

类 / 函数 功能
unicode::Decode / Encode / IsCjk UTF-8 字节串 ↔ 码点序列(容忍非法序列)、汉字判定
Fnv1a64(key) FNV-1a 64 位哈希,可复现
Tokenizer::Split(cps) 切出完整词元列表(测试用)
Tokenizer::CountTokens(cps) 边分词边统计词频,不物化中间数组(生产路径)
SimHasher::Compute(freq) 词频表 → 64 位指纹
HammingDistance(a, b) 两个指纹不同的二进制位数
DistanceToSimilarity(d, bits) 海明距离 → 重复率,四舍五入到两位小数
PlagiarismChecker::CompareBytes / CompareFiles 两段字节流 / 两个文件 → 结果

3.3 三个关键设计决定

(1)为什么用 bigram,而不是单字

这是本次最关键的决定。三个 orig_0.8_dis_*.txt 是把原文字序打乱生成的,如果按单字切分,打乱字序完全不改变单字词频,两篇指纹会完全相同、误报成 1.00——抄袭版反而比原文还"像"。实测:

样例 单字(unigram) 相邻两字(bigram)
orig_0.8_dis_1.txt 1.00 0.95
orig_0.8_dis_10.txt 1.00 0.86
orig_0.8_dis_15.txt 1.00 0.73

换成 bigram 后,打乱字序会破坏绝大多数相邻关系,结果符合"改动越大分数越低"。这个坑已固化成单元测试(口径_unigram对乱序文本失效)——把设计决策变成一条会失败的测试,比写在注释里可靠得多

(2)为什么自己写 FNV-1a,不用 std::hash<std::string>

std::hash<std::string> 的实现是"未指定的",部分标准库会加随机盐(防哈希碰撞攻击),导致同一份文本两次运行得到不同指纹。查重结果必须可复现,所以自己实现:

std::uint64_t hash = 0xCBF29CE484222325ULL;          // 偏移基数
for (const char character : key) {
    // 必须先转 unsigned char:char 在 MSVC 下是 signed,字节大于 127 时会符号扩展,
    // 把高 32 位污染成 1。
    hash ^= static_cast<std::uint64_t>(static_cast<unsigned char>(character));
    hash *= 0x00000100000001B3ULL;                    // FNV 质数,溢出即对 2^64 取模
}

那个 static_cast<unsigned char> 很容易漏。char 在 MSVC 下默认有符号,直接异或会把 0x80 以上的字节变成负数、符号位扩散,结果和 g++(char 默认无符号)不一样。加上之后,同一输入在两个编译器下指纹完全相同(已实测)。

(3)为什么解码要容忍非法 UTF-8

样例里 orig_0.8_del.txt 是按字节删字构造的,很容易把汉字的三字节序列截断成两字节。这类非法序列必须换成 U+FFFD 继续走——一旦抛异常,程序非正常退出,按规则直接扣分。

还有个更隐蔽的点:遇到坏序列时只吃掉这个序列本身,不能把后面的合法字符一起吞掉,否则整篇文本会静默丢字。有专门的回归测试守护(Unicode_坏字节不吞后续文本):输入 FF 80 汉,要求解出 U+FFFD, U+FFFD, 汉 三个码点。

3.4 查重算法

bigram 分词 + 词频加权 SimHash(64 位)+ 海明距离

读文件(二进制) → UTF-8 解码 → 分词 → 统计词频 → 64 位指纹 → 海明距离 → 重复率
重复率 = 1 - 海明距离 / 64

选 SimHash 而不是余弦相似度的原因:SimHash 把整篇文档压成一个 64 位整数,两篇文档的比较退化成一次异或 + 数 1 的位数,比较成本 O(1)。题目对时间空间都有限制,这个特性很划算。

指纹计算:对每个词元算 64 位哈希并以词频为权重,逐位投票(第 i 位是 1 就把权重加进 S[i]),最后 S[i] > 总权重/2 则该位置 1。

3.5 核心代码片段

Fingerprint SimHasher::Compute(const TermFrequency& frequencies) const {
    if (frequencies.empty()) {
        throw EmptyDocumentError("文档中没有任何有效词元,无法计算指纹");
    }
    // 累加器用 uint64:权重上限是整篇词元总数,超大文本下 int32 会溢出成负数。
    // 【等价变形】设 S[i] = 哈希第 i 位为 1 的所有词元权重之和,W = 全部权重之和,则
    //   原累加值 = S[i] - (W - S[i]) = 2·S[i] - W,「> 0」等价于 S[i] > W/2。
    //   所以不用处理为 0 的那些位,只遍历置位的比特。
    std::vector<std::uint64_t> set_bit_weight(static_cast<std::size_t>(bit_count_), 0);
    std::uint64_t total_weight = 0;

    for (const auto& entry : frequencies) {
        const std::uint64_t weight = static_cast<std::uint64_t>(entry.second);
        total_weight += weight;

        std::uint64_t hash = Fnv1a64(entry.first);
        while (hash != 0) {            // h &= h - 1 每次消掉最低位的 1
            const int bit = CountTrailingZeros64(hash);
            if (bit < bit_count_) {
                set_bit_weight[static_cast<std::size_t>(bit)] += weight;
            }
            hash &= hash - 1;
        }
    }
    const std::uint64_t threshold = total_weight / 2;
    Fingerprint fingerprint = 0;
    for (int bit = 0; bit < bit_count_; ++bit) {
        if (set_bit_weight[static_cast<std::size_t>(bit)] > threshold) {
            fingerprint |= (1ULL << bit);
        }
    }
    return fingerprint;
}

这个变形把内层循环从 64 次降到平均 32 次,但实测没有可测量的提升——原因见 4.3,这恰恰是本次最值得说的部分。

数"末尾 0 的个数"和"数 1 的个数"都用可移植写法(SWAR 位技巧 + de Bruijn 乘法查表),没用 __popcnt64 / _BitScanForward64,避免了满屏 #ifdef _MSC_VER

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

这一节记录一次失败的优化尝试,因为它比"我做了两个优化、快了两倍"更有价值。

4.1 初始方案

考虑过两种:SimHash + 海明距离;字符 n-gram 词频向量的余弦相似度。余弦相似度在短文本上区分度好,但要对每个 n-gram 建词表、算点积和模长,比较成本随词表增长。SimHash 比较是 O(1),所以选了 SimHash

4.2 性能瓶颈分析

写了独立诊断程序把流水线拆成三段分别计时(steady_clock,取多轮最小值),测两种数据分布:

【真实文本 · 重复 200 次(词元重复率高)】
  输入 5914200 字节 (5.6 MB),词元 1514800
  解码 7.5 ms | 分词 47.8 ms | 指纹计算 34.6 ms | 合计 89.9 ms

【伪随机汉字 · 200 万字符(词元几乎全唯一,最坏情况)】
  输入 6000000 字节 (5.7 MB),词元 1999999
  解码 7.5 ms | 分词 54.3 ms | 指纹计算 644.1 ms | 合计 705.8 ms

两个场景瓶颈位置完全不同:真实文本分词是大头(去重后只有 4882 种词元,哈希表很小);伪随机汉字指纹计算独占 91%(去重后有 893266 种词元,unordered_map 要插入近 90 万次字符串)。只测一种数据分布会得出完全错误的结论。

4.3 三个候选方案,实测全部否决

候选方案 真实文本 伪随机汉字
① 合并分词与计数:Split 物化后统计 47.3 ms 56.6 ms
① 合并分词与计数:CountTokens 边分边计 80.3 ms 820.7 ms
② 逐位扫描 64 次(原写法) 34.1 ms
② 只遍历置位比特("优化"后) 35.6 ms
③ 追加式流式累加 1012 ms(基准 33 ms)

① 合并分词与计数:直觉上省掉上百万元素的 vector<string> 一定更快,实测和直觉相反——真实文本反而慢了,最坏情况慢了 14 倍。原因是省掉数组的同时引入了更贵的东西:CountTokens 要在 unordered_map 里做几十万到上百万次 string 插入,每次都可能触发分配和 rehash;而 Split 只是往 vectorpush_back,分配是摊还的、非常便宜。所以这不是单向改进:收益取决于词表大小。

② 位运算变形"优化"之后反而慢了 1.5 ms。说明瓶颈 100% 在哈希表本身,不在那个位循环——砍一半迭代次数,在总耗时里根本看不出来。这个变形最终保留,但理由改成"逻辑更清楚、循环体内没有分支",不是"更快"

③ 追加式流式累加(我自己想的方案):让累加器本身当哈希表,计数器满了就重扫去重、累加值减半。实测慢了 30 倍,而且指纹与基准不一致——减半破坏了"某词元的总贡献等于它的出现次数"这个恒等式,截断点取决于数据、不可控。已彻底放弃。

4.4 结论:本来就不需要优化

场景 单文件 两文件合计 5 秒上限
真实中文文本 5.6 MB 90 ms 0.18 s 余量 28 倍
伪随机汉字 5.7 MB(最坏) 706 ms 1.41 s 余量 3.5 倍

即使在构造出来的最坏情况下两文件也只要 1.41 秒。这个程序从一开始就不需要性能优化。

最终交付版本里,性能相关改动只有一处有依据(CountTokens,且仅在真实文本场景更优),另两处候选实测无效,已如实否决

收获:不要凭直觉优化。先做阶段分解计时确认瓶颈真实位置;每个候选方案都要断言产物与基准逐字节相同(否则"优化"改变了行为);一定要构造最坏情况输入——只看真实文本会得出完全相反的结论。

五、计算模块部分单元测试展示

5.1 测试方法

测试在 tests/test_main.cpp,共 49 个用例全部通过。没用 GoogleTest,而是自己写了约 40 行的极简断言框架,理由是零依赖:助教拿到工程在 VS 里按 F5 就能跑。

测试工程 tests.vcxproj 已接入解决方案,它不复制源码,而是用相对路径反向引用主工程模块,好处是"改了实现忘了同步测试工程"这类事故不会发生:

<ItemGroup>
  <ClCompile Include="test_main.cpp" />
  <!-- 注意不要包含带 main() 的 project1.cpp,否则 LNK2005 -->
  <ClCompile Include="..\project1\SimHasher.cpp" />
  <ClCompile Include="..\project1\Tokenizer.cpp" />
  ...
</ItemGroup>
===== 单元测试 =====

[通过] Unicode_汉字编解码往返
...
[通过] Checker_依赖注入生效
         (100 万字符最坏情况耗时 1.22461 秒,预算 5 秒)
[通过] 性能_百万字符在5秒预算内

合计 49 个用例:通过 49,失败 0

5.2 测试用例设计

分组 用例数 覆盖内容
Unicode 编解码 13 单/两/三/四字节往返、编码边界、截断序列、孤立续字节、过长编码、代理区、超范围码点、坏字节不吞后续文本
分词器 6 bigram 切分、单字成段保留、标点丢弃、英文折小写、unigram 模式、两条路径一致
哈希 3 可复现、空串等于偏移基数、不同输入结果不同
SimHash 7 相同文本距离 0、改写比无关更相似、空词元抛异常、位数越界抛异常、位数参数生效、两条路径一致、与词元顺序无关
相似度换算 3 边界值、恒为两位小数、越界输入被钳制
业务编排 8 相同得 1.00、改写居中、空文档得 0.00、纯空白得 0.00、双方皆空不得 1.00、CompareFiles 真实文件、缺文件异常传播、依赖注入生效
算法口径 1 unigram 对乱序文本失效(把踩过的坑钉住)
异常层次 1 四类均继承 AppError
RAII 文件 6 写入后读回一致、重复读取走缓存、文件不存在抛异常、空文件可读、传目录抛异常、答案目录不存在抛异常
性能 1 100 万字符在 5 秒预算内

5.3 部分测试代码

void TestUnicode_InvalidSequenceDoesNotSwallowFollowingText() {
    // 坏序列后面跟着正常汉字时,坏字节必须只吃掉自己。否则整篇文本静默丢字、重复率失真。
    ByteString mixed = {static_cast<char>(0xFF), static_cast<char>(0x80)};
    mixed += "汉";
    const CodePointList decoded = unicode::Decode(mixed);
    CHECK(decoded.size() == 3);
    CHECK(decoded[0] == kReplacementCharacter);
    CHECK(decoded[1] == kReplacementCharacter);
    CHECK(decoded[2] == U'汉');
}

void TestUnicode_OverlongEncodingIsRejected() {
    // C0 AF 是字符 '/' 的「过长编码」,本可以用 0x2F 一个字节表示。
    // 非法 UTF-8 必须换成 U+FFFD —— 否则同一字符有多种字节写法,可绕过查重比对。
    const ByteString overlong = {static_cast<char>(0xC0), static_cast<char>(0xAF)};
    const CodePointList decoded = unicode::Decode(overlong);
    CHECK(decoded.size() == 1);
    CHECK(decoded.front() == kReplacementCharacter);
}

void TestUnigramModeFailsOnShuffledText() {
    // 守护「为什么必须用 bigram」这个决策:字频不变但顺序全乱时,
    // 单字口径的指纹完全相同(误报 1.00),bigram 能识别出不同。
    const std::string normal = "一二三四五六七八";
    const std::string shuffled = "八七六五四三二一";
    const SimHasher hasher;

    const int unigram_distance = HammingDistance(
        hasher.Compute(Tokenizer(TokenMode::Unigram).Split(unicode::Decode(normal))),
        hasher.Compute(Tokenizer(TokenMode::Unigram).Split(unicode::Decode(shuffled))));
    const int bigram_distance = HammingDistance(
        hasher.Compute(Tokenizer(TokenMode::Bigram).Split(unicode::Decode(normal))),
        hasher.Compute(Tokenizer(TokenMode::Bigram).Split(unicode::Decode(shuffled))));

    CHECK(unigram_distance == 0);   // 单字口径完全看不出差别 —— 这就是坑
    CHECK(bigram_distance > 0);     // bigram 能看出来
}

5.4 覆盖率

用 g++ 的 --coverage(gcov)测量:

# 1. 编译插桩。-O0 让行号和源码一一对应,不要用 -O2
g++ -std=c++17 -O0 --coverage -fprofile-abs-path \
    -I 3124004249/project1/project1 -I 3124004249/project1/tests \
    3124004249/project1/tests/test_main.cpp \
    3124004249/project1/project1/{UnicodeText,Hash,Tokenizer,SimHasher,PlagiarismChecker,TextFile,AnswerFile}.cpp \
    -o cov_tests

# 2. 运行。Windows 上 .gcda 不会写当前目录,必须用 GCOV_PREFIX 指定输出位置
GCOV_PREFIX=<输出目录> GCOV_PREFIX_STRIP=0 ./cov_tests

# 3. 用 gcov -i 出 JSON 报告(不要用默认文本输出,原因见下)
gcov -i -b -c cov_tests-SimHasher.gcno

结果:

文件                             行覆盖          已覆盖/总行        分支覆盖      分支数
--------------------------------------------------------------------------
AnswerFile.cpp               92.9%       13/14          44.4%       18
Exceptions.h                100.0%        5/5             --        0
Hash.cpp                    100.0%        8/8          100.0%        2
PlagiarismChecker.cpp        93.8%       30/32          67.7%       31
SimHasher.cpp               100.0%       54/54          82.5%       40
TextFile.cpp                 90.0%       18/20          46.4%       28
Tokenizer.cpp                96.1%       49/51          70.0%       50
UnicodeText.cpp              97.4%       76/78          79.4%       97
--------------------------------------------------------------------------
生产代码合计                  95.1%      253/266         73.5%      266

生产代码行覆盖 95.1%,没覆盖到的基本都是函数末尾的 return 和右花括号(算不出覆盖率),以及两个很难合法触发的 I/O 错误分支。

量覆盖率踩了三个坑,每个都很容易被误判成"我的代码没被执行"

  1. 第一次跑完 gcov,明细文件里一行数据都没有。原因是项目绝对路径含中文(D:/c++学习/...),gcov 生成明细时找不到源文件,产出了只有 4 行表头、零行数据的空文件。改用 gcov -i 的 JSON 格式解决(行号和命中次数都在数据里,不受路径影响)。
  2. Windows 上 .gcda 不会写到当前目录,要设 GCOV_PREFIX / GCOV_PREFIX_STRIP
  3. gcov -o 要指向编译期 basename 对应的 .gcno(如 cov_tests-SimHasher.gcno),直接传源文件路径会报 cannot open notes file

整理成脚本之后,覆盖率就变成了行动清单——照着"未覆盖行"清单反向补了 11 个用例(两字节往返、编码边界、过长编码/代理区/超范围码点、坏字节不吞后续文本、空文件可读、传目录抛异常、CompareFiles 真实文件路径、缺文件异常传播、依赖注入生效),覆盖率从 80.2% 提到 95.1%。真正有价值的不是百分比,而是"哪些行没被覆盖"这份清单。

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

异常层次设计成四个子类统一继承 AppError

异常类 触发场景 对应测试
ArgumentError 命令行参数个数不是 3 个 参数校验
InputFileError 输入文件不存在 / 无法读取 / 传的是目录 文件_文件不存在抛异常文件_传目录抛异常
OutputFileError 答案文件无法创建 / 写入失败 文件_答案目录不存在抛异常
EmptyDocumentError 文档取不出任何词元(空文件或通篇标点) SimHash_空词元抛异常

统一继承一个基类的好处是 main 里只需一个 catch (const AppError&) 兜住所有可预期的业务错误,具体类型由抛出点决定。

6.1 参数数量错误

设计目标:评测系统按固定格式传入三个参数,个数不对时不应继续运行,而应提示用法并返回错误码。做法就是 3.1 里那段 if (argc != 4) throw ArgumentError(...)。必须是 argc != 4 而不是 != 3——argv[0] 是程序自身名字,argv[1] 之前必须先拦住,否则参数为 0 时就是数组越界、行为未定义。

这里有个插曲:我最初在 VS 里按 Ctrl+F5 运行时"窗口一闪就没了"。排查后确认这是完全正确的行为——argc == 1,程序打印用法后以退出码 1 退出,控制台随之关闭。正确做法是把三个参数写进 project1.vcxproj.user

顺便说一句,千万不要加 system("pause")getchar() 来"让窗口停住"——评测是自动化调用,程序会卡在等待输入上导致超时;作业也明确规定"尝试妨碍评测"得 0 分。

6.2 输入文件不存在

设计目标:路径不存在时给出清晰错误而不是崩溃。用 RAII 构造即打开:构造函数里 stream_.open(path_, std::ios::binary)is_open() 为假就抛 InputFileError("无法打开文件:" + path_)

这里必须开 std::ios::binary:Windows 下文本模式会把 \r\n 翻译成 \n,导致同一输入在不同平台得到不同结果。

RAII 的价值在 CompareFiles 里体现得很直接:两个 TextFile 都是栈上对象,第二个打不开时第一个会被正常析构,不泄漏句柄。

6.3 输入文件无法读取或传的是目录

设计目标:路径存在但不是普通文件(比如传了目录)时给明确错误,而不是让后续代码拿到负数长度。

stream_.seekg(0, std::ios::end);
const std::streamoff file_size = stream_.tellg();
if (file_size < 0) {  // 传入目录路径时会走到这里
    throw InputFileError("无法获取文件大小(可能不是普通文件):" + path_);
}

cached_bytes_.resize(static_cast<std::size_t>(file_size));
if (file_size > 0) {  // 空文件不能取 &bytes[0],那是未定义行为
    stream_.read(&cached_bytes_[0], file_size);
}

C++11 起 &vec[0] 在空 vector 上是未定义行为,而空文件是合法输入,这个判断不能省。

6.4 答案文件无法写入

设计目标:答案路径目录不存在或没有写权限时输出错误并返回错误码。RAII 构造即创建、析构 flush + close,即使中途抛异常也会走到析构,不会留下未落盘的缓冲:

AnswerFile::AnswerFile(const std::string& path) : path_(path), stream_(path, std::ios::binary) {
    if (!stream_.is_open()) {
        throw OutputFileError("无法创建答案文件:" + path_);
    }
}

// 写入:std::fixed 不能省 —— 不加的话很小的值会输出成科学计数法,评测按浮点解析会失败。
stream_ << std::fixed << std::setprecision(2) << similarity;
if (!stream_.good()) {
    throw OutputFileError("写入答案文件失败:" + path_);
}
// 故意不写换行:文件内容就是这一个数,万一评测是读全文比对字符串,多一字节会判错。

还有一处顺序设计:main 里是先算结果、再创建答案文件。反过来的话,AnswerFile 会先把文件截断成空的,再在计算中抛异常就会留下一个空答案文件——评测读到空文件会判错。先算后建保证"只要答案文件存在,里面就一定有正确结果"。

6.5 空文本处理

设计目标:两个空文本可以认为完全相同;只有一个为空时重复率应为 0。这里不抛异常,而是返回明确的无效标记:

bool TryComputeFingerprint(..., Fingerprint& fingerprint_out) {
    ...
    try {
        fingerprint_out = hasher.Compute(frequencies);
        return true;
    } catch (const EmptyDocumentError&) {
        // 在这一层消化掉,把「拒绝计算之后怎么办」的决定收在一处。
        fingerprint_out = 0;
        return false;
    }
}

// CompareBytes 里:一方没内容可取时「相似度」无从谈起。明确给 0.00,
// 而不是让两个空指纹相比得出 1.00 那种假结论。
result.both_documents_valid = origin_valid && copy_valid;
if (!result.both_documents_valid) {
    result.similarity = 0.0;
    result.hamming_distance = 0;
    return result;
}

值得强调:EmptyDocumentErrorSimHasher 层是抛出的("无法计算"),但在编排层被消化成结果标记。因为对"两篇文档比较"这个业务来说,"有一方没内容"不是错误,而是应被明确表达的结果。若让异常冒到 main,用户看到的是错误退出码,而正确答案是"重复率 0.00"。

七、样例运行结果

查重文件 海明距离 输出重复率
orig.txt(自比) 0 1.00
orig_0.8_add.txt 6 0.91
orig_0.8_del.txt 12 0.81
orig_0.8_dis_1.txt 3 0.95
orig_0.8_dis_10.txt 9 0.86
orig_0.8_dis_15.txt 17 0.73

符合预期:add(增加内容)改动最小分数最高,dis_15(打乱字序最严重)改动最大分数最低。其他对照:

对比 重复率 用途
原文 vs 原文 1.00 上界,必须满分
原文 vs 一段无关的 C++ 笔记 0.58 下界参考
空文件 vs 空文件 0.00 两个空指纹不能算成 1.00

跨编译器一致性:同一组输入在 MinGW g++ 与 MSVC 下编译,5 个样例输出文件逐字节完全相同。这证明代码没有依赖任何"实现相关行为"(如 char 的符号性、编译器内建函数的具体行为)。

八、代码质量分析

  • 模块职责单一,依赖单向无环。计算模块完全不碰文件系统,测试可直接调用。
  • 注释精简。17 个文件约 100 行注释(占非空行 15%),只写"不看注释就会写错"的关键点,比如"必须先转 unsigned char""std::fixed 不能省"。详细设计理由写在 README 和本文里。
  • 异常分层统一main 一个 catch (const AppError&) 兜住所有业务错误,末尾 catch (...) 防止未捕获异常触发 std::terminate
  • 不用编译器内建函数。popcount 和 ctz 都用可移植写法(SWAR + de Bruijn 查表)实现,避免满屏 #ifdef _MSC_VER
  • 两处工程化细节:源码统一 UTF-8 with BOM 并加 /utf-8 编译选项;控制台输出前调 SetConsoleOutputCP(CP_UTF8)
  • 不连接网络,不读写答案文件以外的任何文件。
g++  -Wall -Wextra -Wpedantic     零警告
MSVC /W4                          零警告
MSVC /analyze(代码质量分析)      零警告
XML 工程文件校验                   全部通过

/analyze 是 VS 菜单里「分析 → 对解决方案运行代码分析」对应的开关,比 /W4 严格得多,会做数据流分析(空指针解引用、资源泄漏、缓冲区越界等)。这项零警告说明代码在静态分析层面是干净的。

关于源码编码补充一句:MSVC 在中文 Windows 上默认按 GBK(代码页 936)读源码。源文件里有中文字符串字面量时,按 GBK 解读会先报 warning C4819,紧接着是一串看起来像语法错误error C3927 / C2143 / C2059——根因是中文注释被解码成乱码后,乱码里出现了引号或反斜杠把代码带崩了。我同时用了 BOM 和 /utf-8(任选其一即可,双保险)。

九、总结与改进方向

本项目用 C++17 实现了论文查重程序:手写 UTF-8 编解码、bigram 分词、FNV-1a 哈希、词频加权 SimHash 指纹与海明距离比较,9 个模块依赖单向无环,2 个 RAII 类管理文件句柄,49 个单元测试零依赖运行。

三个比较关键的判断:

第一,bigram 而不是单字。 单字口径下三个打乱字序的样例全部误报成 1.00,换成 bigram 后是 0.95 / 0.86 / 0.73,符合预期。这个决策现在被一条单元测试守护着。

第二,性能优化要用数据决策。 原计划的两处优化实测都不成立(合并分词在词表大时慢 14 倍,位运算变形收益是负的),我自己想的第三套方案也被否决。最终结论是"这个程序本来就不需要优化"——最坏情况对 5 秒上限有 3.5 倍余量。把"测后发现优化无效所以不采纳"如实写出来,比硬塞两个拖慢代码的"优化"更有意义。

第三,量覆盖率的价值在于"未覆盖行清单"而非百分比。 照着清单补了 11 个用例,覆盖了过长编码、代理区、坏字节不吞后续文本这些手动想不到的边界,覆盖率从 80.2% 提到 95.1%。

后续可改进方向:

  • 指纹位数可配。目前固定 64 位,SimHasher 构造函数已支持传参(测试里验过 32 位),但没在命令行暴露。对"改动很小"的场景,提高位数能让分数更平滑。
  • 分词可引入停用词表。"的""了"这类高频虚词计入词频但对区分度贡献很小。不过这需要读词表文件,与"不读取额外文件"的约束有冲突,需要权衡。
  • 可增加句子级相似度做辅助判断。目前整篇一个指纹,若两篇整体相似但只在某一段高度重合,整篇指纹会稀释这个局部信号。分句加权可以缓解,代价是性能。
  • 可补充 VS 性能探查器截图,目前用的是自己写的计时程序(steady_clock 取多轮最小值)。
posted @ 2026-09-15 20:55  黎伟骞  阅读(8)  评论(0)    收藏  举报