第一次个人编程作业
| 这个作业属于哪个课程 | 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,核心计算分布在 UnicodeText、Tokenizer、SimHasher、PlagiarismChecker 等模块,测试在 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 只是往 vector 里 push_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 错误分支。
量覆盖率踩了三个坑,每个都很容易被误判成"我的代码没被执行":
- 第一次跑完 gcov,明细文件里一行数据都没有。原因是项目绝对路径含中文(
D:/c++学习/...),gcov 生成明细时找不到源文件,产出了只有 4 行表头、零行数据的空文件。改用gcov -i的 JSON 格式解决(行号和命中次数都在数据里,不受路径影响)。 - Windows 上
.gcda不会写到当前目录,要设GCOV_PREFIX/GCOV_PREFIX_STRIP。 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;
}
值得强调:EmptyDocumentError 在 SimHasher 层是抛出的("无法计算"),但在编排层被消化成结果标记。因为对"两篇文档比较"这个业务来说,"有一方没内容"不是错误,而是应被明确表达的结果。若让异常冒到 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取多轮最小值)。
浙公网安备 33010602011771号