第一次个人编程作业

https://github.com/gitfirehappy/3124004240

项目 内容
课程 软件工程
作业要求 第一次个人编程作业
作业目标 使用 C++ 完成论文查重程序,并完成性能分析、单元测试和异常处理

第一次个人编程作业

一、PSP 预估

以下时间在编码前估计,单位为分钟。阶段合计只统计其子项,最终合计为 Planning、Development 和 Reporting 三个阶段之和。

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

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

2.1 项目结构

3124004240/
├─ app/
│  └─ main.cpp                 程序入口
├─ src/
│  ├─ CheckError.h             异常类型
│  ├─ FileIO.h/.cpp            文件读写
│  ├─ TextNormalize.h/.cpp     UTF-8 解码和文本清理
│  ├─ NGram.h/.cpp             字符 n-gram 词频
│  ├─ Similarity.h/.cpp        余弦相似度
│  └─ CheckPaper.h/.cpp        查重流程
├─ test/
│  ├─ tests.h                  测试数据和断言
│  └─ vs_tests.cpp             VS 单元测试入口
├─ testdata/                   测试文本
└─ PaperCheck.sln              VS 解决方案

生产代码包含 1 个自定义异常类、10 个可调用函数和 1 个文件内辅助函数。没有为简单流程增加业务类。

模块 类或接口函数 作用
CheckError CheckError 表示参数或文件操作错误
FileIO readAllwriteAnswer 读取输入文件;将结果写成两位小数
TextNormalize normalize 解码 UTF-8;保留 ASCII 字母数字、基本汉字和 CJK 扩展 A
NGram buildNGram 统计字符 1-gram 和 2-gram 词频
Similarity cosineSimilarity 计算两个词频向量的余弦相似度
CheckPaper similarityOfcheckFilescheckToFilerunCheck 连接参数、文件、算法和输出
main main 捕获异常并返回进程状态

keepCharTextNormalize.cpp 内部使用的辅助函数,不属于模块对外接口。

2.2 调用关系

main
└─ runCheck                 检查命令行参数
   └─ checkToFile
      ├─ checkFiles
      │  ├─ readAll × 2
      │  └─ similarityOf
      │     ├─ normalize × 2
      │     ├─ buildNGram × 2
      │     └─ cosineSimilarity
      └─ writeAnswer

核心函数 similarityOf 的流程:

接收两段已经读入的文本
    ↓
UTF-8 解码并去掉空白、标点
    ↓
两边都空?──是──→ 1.00
    │否
只有一边空?──是──→ 0.00
    │否
构造 1-gram、2-gram 词频
    ↓
计算余弦相似度并返回

2.3 算法

程序不使用外部分词库。文本经规范化后,分别统计单字符(1-gram)和相邻双字符(2-gram)的出现次数:

  • 1-gram 保留整体用字特征,对少量增删改较稳定。
  • 2-gram 保留局部顺序,避免只比较单字集合。

两类特征存入 unordered_map<uint64_t, uint32_t>。键表示特征,值表示出现次数。2-gram 的键设置最高位,以免和 1-gram 的键重复。计算余弦时再把词频转换为 double

similarity = dot(A, B) / (length(A) * length(B))

结果限制在 [0, 1],写入答案文件时保留两位小数。使用哈希表时,算法平均时间复杂度为 O(n + m),空间复杂度为 O(n + m)

该实现的特点是纯 C++、不依赖词典,并且只访问命令行给出的两个输入文件和一个答案文件。工程使用 /W4 和 Code Analysis 生成,当前没有编译警告。

2.4 运行结果

按作业要求从命令行传入原文、抄袭稿和答案文件的绝对路径。这里使用同一篇长原文,分别与加字版和删字版比较:

main.exe C:\...\orig.txt C:\...\orig_0.8_add.txt C:\...\answer_add.txt
main.exe C:\...\orig.txt C:\...\orig_0.8_del.txt C:\...\answer_del.txt

本机答案文件输出分别为 0.990.99,且文件中只有一个保留两位小数的浮点数。

运行结果

三、计算模块的性能改进

性能改进用时:20 分钟。

使用相同的 orig.txt,在 VS Release x64 下重复构造 500 次特征向量。两种实现各交替运行 9 次,分别取中位数。buildNGram 是计算模块中耗时最大的函数。进一步检查发现,词频只需要保存非负整数,首版使用 double 没有必要。

改进内容:把词频类型由 double 改为 uint32_t,只在计算余弦时转换成 double。算法和输出结果不变。

using NGramVec = std::unordered_map<uint64_t, uint32_t>;

同一组测试的结果:

项目 改进前 改进后
词频类型 double uint32_t
500 次 buildNGram 中位耗时 165.632 ms 161.732 ms
耗时减少 - 约 2.4%
耗时最大的函数 buildNGram buildNGram

这次改进幅度不大,但类型更符合“次数”的含义,也减少了构造词频时的浮点操作。哈希表仍会预留约 2n 个位置,避免反复扩容。

性能截图启用 PERF_PROFILE 宏:输入文件只读一次,随后重复计算 3000 次,最后只写一次答案。这只用于让 VS CPU 采样有足够持续时间;提交前取消该宏,正常版本仍只计算一次。

性能

四、计算模块的单元测试

使用 Visual Studio C++ 单元测试框架,共编写 15 个测试。测试按代码分支设计,覆盖正常输入、边界输入和异常输入。

测试目标 测试用例 构造思路
相同文本 IdenticalTexts 两个输入完全相同,期望为 1.00
题目示例 ExampleSentence 使用题目给出的两句话,结果应大于 0 且小于 1
两边为空 BothEmpty 空串和只有标点的文本,期望为 1.00
一边为空 OneEmpty 分别让原文和抄袭稿为空,期望为 0.00
忽略标点 PunctuationIgnored 你好,世界!你好世界,期望相同
无关文本 UnrelatedTexts 使用没有公共字符的两个字符串
增加字符 AddNoise 在原句中插入若干汉字
删除字符 DeleteChars 从原句中删除若干汉字
替换字符 ReplaceChars 将原句中的一个汉字替换
UTF-8 清理 NormalizeKeepsCjk 混合汉字、字母、数字和标点
文件不存在 FileNotFound 使用确定不存在的输入路径
参数错误 ArgcError argc != 4
输出失败 WriteFail 将目录 . 当作答案文件路径
输出格式 WriteFormat 写入 0.634,检查结果为 0.63
完整流程 CheckToFile 使用两个相同临时文件,检查答案文件

部分测试代码:

inline void test_both_empty() {
    expectNear(similarityOf("", ""), 1.0, 1e-9,
               "两个空文本视为完全相同");
    expectNear(similarityOf(",。!", "   "), 1.0, 1e-9,
               "去掉标点后都空,视为相同");
}

inline void test_replace_chars() {
    const std::string orig = "今天晚上我要去看电影";
    const std::string plag = "今天晚上我要去看电映";
    expectTrue(similarityOf(orig, plag) > 0.75,
               "少量替换后仍应很相似");
}

本机测试结果:15 个测试全部通过。

测试

测试覆盖了查重主流程、两个空文本分支、单边空文本分支、参数错误以及文件打开和写入失败。隐藏评测数据没有公开,因此测试只验证程序接口和可明确判断的行为。

五、计算模块的异常处理

程序定义 CheckError 表示可预期错误。main 捕获 CheckError 和其它 std::exception,向标准错误输出原因并返回 1,避免未捕获异常导致程序崩溃。

5.1 参数数量错误

设计目标:必须接收原文、抄袭稿和答案文件三个路径。参数数量不等于 3 时停止执行,不读取文件。

对应场景:直接运行 main.exe,或漏传任意路径。

对应测试:

inline void test_argc_error() {
    bool thrown = false;
    char* args[] = {const_cast<char*>("main.exe")};
    try {
        runCheck(1, args);
    } catch (const CheckError&) {
        thrown = true;
    }
    expectTrue(thrown, "参数个数不对应抛 CheckError");
}

5.2 输入文件无法打开

设计目标:原文或抄袭稿不存在、无权限读取时,指出打不开的路径并停止计算。

对应场景:输入路径不存在。

对应测试:

inline void test_file_not_found() {
    bool thrown = false;
    try {
        readAll("__no_such_paper_check_file__.txt");
    } catch (const CheckError&) {
        thrown = true;
    }
    expectTrue(thrown, "缺文件应抛 CheckError");
}

5.3 答案文件无法写入

设计目标:答案路径是目录或没有写权限时,报告写入错误,不能假装执行成功。

对应场景:把当前目录 . 当作答案文件路径。

对应测试:

inline void test_write_fail() {
    bool thrown = false;
    try {
        writeAnswer(".", 0.50);
    } catch (const CheckError&) {
        thrown = true;
    }
    expectTrue(thrown, "答案路径不可写应抛 CheckError");
}

六、PSP 实际耗时

以下实际耗时合计为 180 分钟。阶段合计只统计其子项。

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 10
· Estimate · 估计这个任务需要多少时间 30 10
Development 开发 255 130
· Analysis · 需求分析(包括学习新技术) 40 15
· Design Spec · 生成设计文档 20 10
· Design Review · 设计复审 10 5
· Coding Standard · 代码规范 15 5
· Design · 具体设计 30 15
· Coding · 具体编码 80 45
· Code Review · 代码复审 20 10
· Test · 测试(自我测试、修改代码、提交修改) 40 25
Reporting 报告 55 40
· Test Report · 测试报告 25 15
· Size Measurement · 计算工作量 10 5
· Postmortem & Process Improvement Plan · 事后总结并提出过程改进计划 20 20
合计 340 180
posted @ 2026-09-14 20:23  FY_C  阅读(11)  评论(0)    收藏  举报