第一次个人编程作业

这个作业属于哪个课程 软件工程课程班级链接
这个作业要求在哪里 个人项目作业要求链接
这个作业的目标 1. 掌握基于 PSP2.1 的个人软件工程开发流程
2. 设计并实现高效精确的论文查重算法
3. 运用 Visual Studio 2026 进行 CPU 性能剖析与优化
4. 编写全覆盖单元测试并分析代码覆盖率
5. 完善工程化异常处理与健壮性设计
作业 GitHub 仓库链接 个人项目源码

1. PSP 表格记录

下表记录了在本次我论文查重项目开发各个阶段中的预估时间与实际耗时:

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

耗时差异简要说明:在实际开发中,总体耗时比预估多出约 55 分钟。主要时间倾斜在 单元测试与代码覆盖率(Test) 阶段:原生 C++ 单元测试在 Visual Studio 2026 中的环境配置、C++17 标准与 UTF-8 编码设置、以及通过 .runsettings 开启底层原生动态插桩分析耗费了较多精力;但在核心匹配算法的实现上,由于前期规划清晰、采用了倒排索引,实际编码十分顺利。


2. 计算模块接口的设计与实现过程

2.1 整体架构与代码组织

本项目采用现代化 C++17 标准实现,按职责划分为四个逻辑层级:

  1. 文件与流处理层
    • 负责命令行参数校验、二进制安全的文件流读取与结果写入。
    • 包含流式 HTML 智能清洗器(cleanHtmlDocumentprepareInputText),支持自动识别并剔除网页源码噪声(如 GitHub 下载时混入的 blob-code、标签与实体)。
  2. 文本归一化与特征解析层
    • 负责 UTF-8 多字节序列与 Unicode 码点(char32_t)的高效解码(decodeUtf8)。
    • 负责标点符号过滤、空白字符折叠、全角半角映射与英文字符大小写归一化(normalizeCodePointnormalizeText)。
  3. 核心计算接口层
    • 对外核心暴露接口:double calculateSimilarity(const string& origText, const string& copyText)
    • 负责驱动预处理流程、调度匹配核心并输出符合四舍五入规则的相似度比值(保留两位小数)。
  4. 模式匹配与相似度比对核心层
    • 基于改进的 Ratcliff/Obershelp(格式塔模式匹配,Gestalt Pattern Matching) 算法。
    • 负责构建字符位置倒排索引、最长连续匹配子串检索以及双向贪心扩展。

2.2 核心算法流程图

算法处理与计算数据流转过程如下图所示:

flowchart TD Start(["程序启动:读取命令行参数"]) --> ReadFile["安全读取原文与抄袭版文本"] ReadFile --> DetectHtml{"检测是否包含 HTML 标签/源码?"} DetectHtml -- 是 --> CleanHtml["提取正文文本,剥离标签与实体解码"] DetectHtml -- 否 --> RawText["保留原始文本数据"] CleanHtml --> DecodeUTF8["UTF-8 解码为 Unicode 码点序列 (uint32_t)"] RawText --> DecodeUTF8 DecodeUTF8 --> Normalize["过滤标点/空白,全角转半角,大写转小写"] Normalize --> EmptyCheck{"归一化后文本是否为空?"} EmptyCheck -- 原文或抄袭版为空 --> ZeroSim["直接返回 0.00 相似度"] EmptyCheck -- 正常文本 --> BuildIndex["针对原文构建字符位置倒排索引表"] BuildIndex --> InitStack["初始化显式迭代栈,压入初始比较区间 [0, len)"] InitStack --> StackLoop{"比对栈是否为空?"} StackLoop -- 否 --> PopInterval["弹出当前比对区间"] PopInterval --> FindLCS["基于倒排索引 + 高频剪枝 检索最长公共连续子串"] FindLCS --> MatchFound{"找到有效匹配?"} MatchFound -- 是 --> Accumulate["累加匹配字符长度 matchedChars += Length"] Accumulate --> PushLeft["将最长匹配左侧子区间压入栈"] PushLeft --> PushRight["将最长匹配右侧子区间压入栈"] PushRight --> StackLoop MatchFound -- 否 --> StackLoop StackLoop -- 是 --> CalcScore["计算相似度: 2.0 * matchedChars / (lenA + lenB)"] CalcScore --> Output["格式化为两位小数写入答案文件"] ZeroSim --> Output Output --> Finish(["程序正常退出"])

2.3 算法关键与独到之处

  1. 结构保序的全局模式匹配
    • 传统的 Jaccard 或单纯 \(n\)-gram 词频统计无法捕捉文本上下文的结构保序性,在遭遇大段重排或分散抽句时容易出现失真。
    • 本项目选用的 Ratcliff/Obershelp 算法通过递归/迭代寻找最长公共子序列,并将子串左右两侧的未匹配区域分别进行递归分治,天然对局部连续性赋予极高权重,不仅对增删词汇保持高精度,对语序颠倒(dis_1/dis_10/dis_15)也能给出高度符合人类直觉的梯度评分。
  2. 字符倒排索引加速
    • 经典 Ratcliff/Obershelp 采用暴力双重循环寻找最长公共子串,时间复杂度高达 \(O(N \cdot M)\),在面对万字长文时计算极慢。
    • 本项目预先对原文建立 unordered_map<char32_t, vector<int>> 倒排索引,使得抄袭文本在遍历到字符 \(c\) 时,能以 \(O(1)\) 直接获取其在原文中的所有出现锚点,大幅缩减无效对比。
  3. 高频字自适应剪枝
    • 汉语中诸如“的、了、是、在”等单字在长篇小说中可能出现成千上万次。如果每个高频字都进行双向扩展,会导致算法退化。
    • 我们引入了高频字符动态过滤阈值(出现频次 \(> 1\%\) 的字符不作为最长子串的起始探索锚点),瞬间消除 \(90\%\) 以上的无意义候选分支,长文本比对耗时从原本的数秒缩减至 \(30 \sim 40\text{ ms}\)
  4. 双向贪心扩展与显式栈设计
    • 一旦在锚点处匹配成功,立即同时向左、向右进行线性贪心拓展,快速锁定最大连续长度。
    • 采用 std::vector<MatchInterval> 显式栈消除函数递归,彻底杜绝在极端长文本分治下可能引发的栈溢出隐患。
  5. 健壮的流式 HTML/乱码容错与 Unicode 全面归一化
    • 系统支持无缝处理带 UTF-8 BOM 头的文本、全角符号、英文大小写及从网页直存的 HTML 代码,保证核心算法只比对纯净语意内容。

3. 计算模块接口部分的性能改进

3.1 性能改进所花费的时间

  • 记录时间:在整个计算模块的性能剖析、瓶颈定位与优化改进上,共花费约 75 分钟

3.2 性能改进思路与演进历程

  1. 初版瓶颈分析
    • 初版算法采用标准的动态规划/双指针暴力求最长匹配,时间复杂度为 \(O(N \cdot M)\)。在测试余华的《活着》(约 1 万字符规模)时,若进行全量暴力比对,字符比对运算次数达数亿次,无法达到毫秒级响应。
  2. 引入倒排索引与空间复用
    • 将原文的所有 Unicode 码点位置预先存入倒排索引,检索时直接跳转候选位置。
    • 将所有文本直接解码为 vector<char32_t> 紧凑向量,杜绝在匹配核心中频繁构造和拷贝 std::string
  3. 高频常用字剪枝策略(关键突破)
    • 实验发现,由于中文虚词(如“的”、“和”)极其密集,倒排索引列表中这些字符包含了数百个位置,导致候选扩展过于繁重。
    • 增加频次统计:当某个字在文本中占比超过设定阈值(\(1\%\))时,将其标记为 popularToken,匹配引擎不以该字作为初始种子锚点(但如果它包含在较长连续词句中,仍会通过贪心扩展自然覆盖)。此项改进使得算法性能提升了 \(8 \sim 10\)

3.3 性能分析图展示

在 Visual Studio 2026 的 “调试 -> 性能探查器 (CPU 使用率)” 工具下,以 Release x64 模式对万字真实测试样例(orig.txt vs orig_0.8_add.txt 等)进行性能分析剖析,如图所示
image

3.4 程序中消耗最大的函数分析

从 CPU Profiler 的自采样分析数据中可以看出,CPU 耗时主要集中在以下两大核心函数:

  1. prepareInputText / findHtmlClosingTag(文本预处理与标签清洗)
    • CPU 耗时占比:约 \(37\% \sim 45\%\)
    • 消耗原因:由于输入可能包含数万行的 GitHub 网页源码表格(包含大量 <span class="blob-code"> 标签与 HTML 实体),程序必须对字符流逐字节扫描、解析标签属性并解码 Unicode 字符。
    • 合理性与优化:该函数采用线性无回溯扫描,时间复杂度为 \(O(L)\)\(L\) 为文件字节长度)。由于它只执行一次,且将脏数据完全洗净,为后续匹配消除了数万个干扰字符,因此这一消耗是完全合理且必要的。
  2. findLongestMatch / calculateMatchedLength(最长匹配搜索与双向扩展)
    • CPU 耗时占比:约 \(35\% \sim 42\%\)
    • 消耗原因:这是查重算法的密集计算核心。虽然倒排索引将复杂度压到了近乎线性,但在此函数内仍然需要遍历候选锚点,并在内存中进行双向边界比对。
    • 性能总结:在经过倒排索引和高频字剪枝之后,程序处理 1 万字规模的文章总耗时仅在 \(30 \sim 40\text{ ms}\) 之间,远远优于作业要求的 5 秒时限,性能非常优秀。

4. 计算模块部分单元测试展示

4.1 核心单元测试代码展示

本项目基于 Visual Studio 原生 C++ 单元测试框架(Microsoft::VisualStudio::CppUnitTestFramework)编写了完备的自动化测试套件。以下为部分典型测试用例代码:

#include "pch.h"
#include "CppUnitTest.h"
#include "../plagiarism_checker_ratcliff_obershelp/main.cpp"

using namespace Microsoft::VisualStudio::CppUnitTestFramework;

namespace PlagiarismCheckerUnitTests {
    TEST_CLASS(CoreAlgorithmTests) {
    public:
        // 1. 边界测试:两个空文本比对,验证防除零异常
        TEST_METHOD(TestEmptyBoth) {
            double sim = calculateSimilarity("", "");
            Assert::AreEqual(0.00, sim, 0.0001, L"双空文本查重率应为 0.00");
        }

        // 2. 极端测试:完全相同文本比对
        TEST_METHOD(TestIdenticalText) {
            string text = "今天是星期天,天气晴朗,我和朋友一起去公园散步。";
            double sim = calculateSimilarity(text, text);
            Assert::AreEqual(1.00, sim, 0.0001, L"完全相同文本相似度应为 1.00");
        }

        // 3. 容错测试:全角字符、英文大小写及标点扰动归一化测试
        TEST_METHOD(TestFullwidthAndCase) {
            string orig = "Hello World! 这是一个测试。12345";
            string copy = "hello world,这是一个测试!12345";
            double sim = calculateSimilarity(orig, copy);
            Assert::IsTrue(sim >= 0.95, L"全半角与大小写归一化后相似度应极高");
        }

        // 4. 功能测试:HTML 源码自动过滤与纯文本提取比对
        TEST_METHOD(TestHtmlCleaning) {
            string orig = "今天阳光明媚,微风正好。";
            string htmlCopy = "<div class=\"blob-code\">今天阳光明媚,微风正好。</div>";
            double sim = calculateSimilarity(orig, htmlCopy);
            Assert::AreEqual(1.00, sim, 0.0001, L"HTML 标签剥离后应与纯文本完全一致");
        }

        // 5. 扰动测试:文本乱序与局部篡改测试
        TEST_METHOD(TestModification) {
            string orig = "白日依山尽,黄河入海流。欲穷千里目,更上一层楼。";
            string copy = "黄河入海流,白日依山尽。更上一层楼,欲穷千里目。";
            double sim = calculateSimilarity(orig, copy);
            Assert::IsTrue(sim >= 0.70 && sim <= 0.95, L"语序颠倒但字句保留应具有合理梯度相似度");
        }
    };
}

4.2 测试函数与测试数据构造思路

为了实现对业务逻辑全路径的严密覆盖,我们运用软件测试工程方法论(等价类划分法、边界值分析法、错误推测法),系统性地设计了 10 个维度的测试用例:

  1. 边界值测试
    • TestEmptyBothTestEmptyOne:构造单侧为空或双侧均为空文本,验证分母为 0 时的数值安全性与健壮性。
    • TestShortText:输入仅有单个字或两个字极短文本,检验倒排索引边界不会越界。
  2. 极端差异测试
    • TestIdenticalText:输入完全相同的段落,期望相似度精确等于 \(1.00\)
    • TestCompletelyDifferent:输入语义完全不交叉的文本(如纯英文、古汉语诗词),期望相似度精确为 \(0.00\)
  3. 文本清洗与归一化测试
    • TestUtf8Bom:注入 Windows 记事本常见的 \xEF\xBB\xBF UTF-8 BOM 头,验证 BOM 剥离逻辑。
    • TestPunctuationAndWhitespace:在句中插入换行、制表符、全角空格及中英文各种标点,验证有效语义字符的提取能力。
    • TestFullwidthAndCase:混合全角数字 123、大写字母 WORLD 与半角对照,测试归一化转换一致性。
    • TestHtmlCleaning:构造包含网页结构标签、表格及 CSS 类的复杂 HTML 片段,验证在不依赖外部库情况下的流式清洗能力。
  4. 现实仿抄袭扰动测试
    • TestModification:模拟现实论文常见的调换句序、局部增词减字操作,验证 Ratcliff/Obershelp 算法的梯度识别准确性。

4.3 单元测试运行结果

在 Visual Studio 2026 测试资源管理器中运行上述所有 10 项测试用例,10/10 全部通过,耗时仅 121 毫秒。测试运行结果截图如下:

image

4.4 代码覆盖率截图与深入分析

通过配置 test.runsettings 开启 C++ 原生动态插桩分析后,得到的测试覆盖率展开详情如下图所示:

image

覆盖率数据深度解读:

  • 测试类行覆盖率达 \(98.7\%\),块覆盖率达 \(99.6\%\)
    • CoreAlgorithmTests 测试套件内的 10 个测试方法覆盖率几乎全达 \(100.0\%\),证明所有设计的测试断言均被完整无遗漏地执行。
  • 核心业务算法函数高度覆盖(\(80\% \sim 100\%\)
    • 核心算法函数 prepareInputText\(100.0\%\))、normalizeText\(100.0\%\))、findLongestMatch\(96.7\%\))、calculateMatchedLength\(97.8\%\))、findHtmlTagEnd\(94.4\%\))均获得了极其充分的覆盖。
  • 未覆盖代码说明
    • 未覆盖的代码行主要集中在防御性异常处理分支(如文件打开失败抛出异常、文件无法写入等底层操作系统 I/O 错误分支),以及部分备用的特殊实体解码逻辑,符合真实工程代码覆盖率的标准规律。

5. 计算模块部分异常处理说明

为了保证程序在遭遇各类非法参数、损坏数据或极端操作系统环境时不发生闪退、未定义行为(Undefined Behavior)或死循环,程序设计了四大维度的异常与容错防护机制。

5.1 异常 1:命令行参数不足或格式错误(Invalid Arguments)

  • 设计目标:防止用户未提供足够参数导致数组访问越界(argv[i] 产生段错误),并提供明确的 CLI 操作指引。
  • 应用场景:用户直接双击 exe 或在控制台只输入了 1~2 个文件路径。
  • 处理机制与代码实现
    if (argc != 4) {
        cerr << "错误:命令行参数数量不正确!" << endl;
        cerr << "用法:" << argv[0] << " <原文绝对路径> <抄袭版绝对路径> <输出文件绝对路径>" << endl;
        return 1;
    }
    
  • 验证样例:直接执行 main.exe,控制台打印上述提示并以退出码 1 优雅终止。

5.2 异常 2:输入文件不存在或读取受限(File Open/Read Failure)

  • 设计目标:确保程序在原文路径错误、文件损坏或被占用时能够立即拦截,并给出清晰报错。
  • 应用场景:指定的 orig.txtorig_add.txt 路径不存在,或者当前操作系统账户无读取权限。
  • 处理机制与代码实现
    string readFileContent(const string& filePath) {
        ifstream inFile(filePath, ios::binary);
        if (!inFile.is_open()) {
            throw runtime_error("无法打开输入文件: " + filePath);
        }
        // 读取逻辑...
    }
    
  • 验证样例:传入一个虚构的路径 C:\not_exist.txt,程序捕获 runtime_error 异常并在 std::cerr 输出友好的错误信息,避免系统崩溃。

5.3 异常 3:输出结果文件无法创建或写入(File Write Failure)

  • 设计目标:防止查重结果无法落盘导致的静默失败,确保计算成果可靠持久化。
  • 应用场景:输出路径指定到了无写权限的系统保留目录(如 C:\Windows\System32\),或者磁盘满。
  • 处理机制与代码实现
    ofstream outFile(outPath);
    if (!outFile.is_open()) {
        cerr << "错误:无法创建或写入输出文件: " << outPath << endl;
        return 1;
    }
    outFile << fixed << setprecision(2) << similarity << endl;
    if (!outFile.good()) {
        cerr << "错误:文件写入过程中发生 I/O 错误!" << endl;
        return 1;
    }
    
  • 验证样例:将输出路径设为 X:\invalid_drive\ans.txt,程序安全捕获写入失败状态,终端输出错误指引。

5.4 异常 4:空文件与零有效文本边界处理(Zero Division / Blank Text)

  • 设计目标:在经过复杂的 HTML 清洗和标点过滤后,如果双方文本的有效字符总数为 0,防止数学公式 \(\frac{2 \times \text{matched}}{\text{len}_1 + \text{len}_2}\) 发生除以零异常(生成浮点数 NaN 或引发 FPE 中断)。
  • 应用场景:两篇文档均为空文件,或文档内部全部由标点、空白符、HTML 无效标签构成。
  • 处理机制与代码实现
    if (tokensA.empty() || tokensB.empty()) {
        return 0.0;
    }
    
  • 对应单元测试样例
    TEST_METHOD(TestEmptyBoth)TEST_METHOD(TestEmptyOne),输入空串断言直接安全返回 0.00

6. 从 3-gram 到 Ratcliff/Obershelp 的工程跃迁

在本次个人项目的开发过程中,我并没有直接敲定最终算法,而是经历了一次从“浅层特征统计”“深度结构匹配”的完整技术迭代与重构演进。


6.1 初代探索 3-gram

在项目初期,为了快速交付一个基线(Baseline)版本,我首先实现了基于字符级 3-gram(三元文法模型)的查重算法:

  • 核心逻辑:以 3 个连续 Unicode 字符为滑动窗口切片提取特征集合,利用哈希集合(unordered_set)计算 Jaccard 系数或重合特征交集占比。
  • 突出优势:实现轻巧,无需复杂回溯,单次线性扫描即可完成特征构建,万字比对耗时仅需 \(20 \sim 30\text{ ms}\)
  • 致命痛点(精度天花板)
    在引入真实样例(orig.txt 与各个扰动版本)深入测试时,3-gram 暴露了严重的结构性缺陷:
    1. 边界效应导致增删敏感度过高:在面对仅增加了少量前缀/插入语句的 orig_0.8_add.txtorig_0.8_del.txt 时,3-gram 的测定相似度骤降到了 \(0.52\)\(0.66\)。然而,人类肉眼一望可知两篇文章有超过 \(85\%\) 的大段正文完全一致。只因滑动窗口被插入词切碎,导致海量 3-gram 特征失效。
    2. 丧失上下文长程连续性:3-gram 本质上仍是将文章打散为“词袋”,无法区分“整段整句大面积抄袭”与“全篇随机撞车零星词汇”的本质区别。

6.2 转向 Ratcliff/Obershelp 格式塔模式匹配

为了让查重结果更符合人类判断论文抄袭的直觉常理(“优先锁定最长连续段落,再依次审视周边零碎改动”),我决定重构核心计算模块,引入经典而优雅的 Ratcliff/Obershelp(格式塔模式匹配,Gestalt Pattern Matching) 算法。

该算法的核心哲学高度契合心理学中的格式塔原理:

  1. 在待比对文本 \(A\)\(B\) 中寻找最长公共连续子串(LCS)作为“锚点”;
  2. 锁定锚点后,将锚点左侧的未匹配子区间与右侧的未匹配子区间分别作为新的独立问题进行分治处理;
  3. 最终相似度定义为:

    \[Score = \frac{2 \times \sum \text{Length}(\text{Matched Substrings})}{\text{Length}(A) + \text{Length}(B)} \]

两个版本的实际测试数据对比:

测试样例类别 扰动特征描述 初代 3-gram 相似度 演进后 Ratcliff/Obershelp 相似度 评价与结论
orig_0.8_add.txt 在原文基础上插入少量段落 \(0.52\) \(0.90\) 质的提升:准确捕捉到主体大段连续原文
orig_0.8_del.txt 删减原文部分段落 \(0.66\) \(0.88\) 大幅修正:未被删除的大段核心内容被高权重计入
orig_0.8_dis_1.txt 局部极微弱语序调换 \(0.90\) \(0.97\) 极高敏锐度,细微调序不影响整段识别
orig_0.8_dis_10.txt 中等幅度段落重排 \(0.65\) \(0.81\) 展现平滑的阶梯衰减
orig_0.8_dis_15.txt 大幅度跨段乱序颠倒 \(0.30\) \(0.60\) 保持合理分级,避免被完全误判为全异

演进后的算法在测试集上展现出了不错的区分度与合理性


6.3 优化 \(O(N \cdot M)\)

然而,Ratcliff/Obershelp 算法在原生形态下存在一个极其严重的工业化阻碍:其最长子串搜索在最坏情况下时间复杂度高达\(O(N \cdot M)\)。在长篇论文比对时极易出现卡顿,甚至无法满足作业“5秒内完成”的严格时限。

为此,我在工程落地中进行了四大针对性优化:

  1. 构建字符倒排索引
    预先建立字符到原文出现位置的映射表,彻底摒弃暴力的全盘双重循环,匹配时实现 \(O(1)\) 锚点跳转。
  2. 高频常用字动态剪枝
    汉语中“的、了、是”等虚词出现频次极高,盲目以其为锚点会导致分支爆炸。通过动态阈值(频次 \(> 1\%\) 的字符不作为搜索起始锚点),直接砍掉了 \(90\%\) 以上的无效遍历,使得整体性能提升了近一个数量级。
  3. 双向贪心快速扩展
    在锚点命中后立即沿两端做线性扩展,单次即可吃满最大连续相同长度。
  4. 显式数据栈消除递归
    使用 std::vector<MatchInterval> 模拟运行栈,根治了深层文本分治下可能导致系统栈溢出的隐患。

最终成效:优化后的 Ratcliff/Obershelp 在《活着》文本比对中,总耗时控制在30~40ms,在精度极大提升的同时,依然拥有浅层 3-gram 的性能


6.4 软件工程实践反思与感悟

回顾这一场算法演进之路,我深刻领悟到软件工程中 没有银弹渐进式重构 的真谛:

  • 理论模型必须结合业务实际:单纯看理论复杂度,3-gram 貌似更轻快,但在论文查重这种强依赖“连续语句保序”的场景下,它的精度是相对欠缺的;
  • 高复杂度算法可以通过工程手段来优化:看似退化后性能不佳的 \(O(N \cdot M)\) 分治算法,只要敏锐抓住输入语料的分布特征(如中文高频词规律),配合精妙的数据结构(倒排索引、显式栈),也能将其常数和实际平均开销压到极致;
  • 单元测试对重构很重要:正是因为有一套高覆盖的单元测试,我才能在从 3-gram 颠覆重构为 Ratcliff/Obershelp 的过程中,随时能够回归验证,确保每一个边界条件都稳定。

7. 附件

image

posted @ 2026-09-14 23:26  StrIn1234  阅读(5)  评论(0)    收藏  举报