个人项目

作业链接

PSP 表格

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

一、代码组织

整个项目采用单文件结构,核心逻辑集中在 main.cpp 中,共包含 4 个关键函数:

函数 职责 输入 输出
readfile 读取文件全部内容 文件路径 字符串
tokenize 分词 + 词频统计 文本字符串 unordered_map<string, ll>
相似度计算 计算余弦相似度 两个词频 map long double
writefile 将结果写入答案文件 路径、相似度

各函数职责单一,互不依赖,便于单独测试和维护。


二、函数关系

主函数 main 依次调用各模块,数据流如下:

flowchart TD A[main] --> B[readfile argv1] A --> C[readfile argv2] B --> D[tokenize 原文] C --> E[tokenize 抄袭版] D --> F[计算余弦相似度] E --> F F --> G[writefile argv3]

三、关键流程

核心算法流程如下:

flowchart TD A[读入两篇文章] --> B[分词: 英文按字母切分, 中文按UTF-8字符切分] B --> C[统计词频, 得到两个 unordered_map] C --> D[遍历 map1: 在 map2 中查找相同词, 累加点积和模长1] D --> E[遍历 map2: 累加模长2] E --> F[相似度 = 点积 / 模长1 × 模长2] F --> G[写入答案文件, 保留两位小数]

四、算法关键与独到之处

算法关键:采用余弦相似度,将文本转化为词频向量,通过计算两个向量夹角的余弦值衡量相似程度。余弦值越接近 1,两篇文章越相似。

独到之处

  1. unordered_map 同时完成词频统计和向量表示,省去显式构建完整向量的步骤,节省空间。
  2. 点积计算只遍历一个 map,在另一个 map 中查找相同词,避免合并两个 map 带来的额外开销。
  3. 中英文混合处理:英文按字母切分并转小写,中文按 UTF-8 字节规则提取单个字符,不依赖第三方分词库,兼容性好。
  4. 文件 I/O 与算法分离:读取、分词、计算、输出各自独立,便于替换或优化其中任一模块。

---## 计算模块接口部分的性能改进

一、改进花费时间

本次性能改进共花费约 0.5 小时,包括性能分析、代码修改、重新测试三个阶段。


二、改进思路

通过 Visual Studio 性能探查器分析发现,tokenize 函数是性能瓶颈,主要耗时在 std::map 的红黑树插入和字符串比较上。针对这一问题,做了以下改进:

改进点 改进前 改进后 预期效果
词频容器 std::map(红黑树,O(log n)) std::unordered_map(哈希表,O(1)) 插入效率大幅提升
点积计算 co[word] 会插入默认值 改用 co.find(word) 避免污染 map,减少无谓插入
字符串处理 substr 拷贝 string_view 避免拷贝 减少内存分配
传参方式 string x 传值 const string& x 引用 避免大字符串拷贝

三、性能分析图

使用 Visual Studio 性能探查器(调试 → 性能探查器 → CPU 使用率),分别对优化前后的代码进行分析。

优化前(std::map

file

函数名 CPU 总计(%) 自 CPU(%)
tokenize 69.34% 13.07%
std::map<...> 42.61% 17.39%
std::basic_string<...> 36.15% 12.69%
std::less<...> 9.99% 5.57%

优化后(std::unordered_map

file1

函数名 CPU 总计(%) 自 CPU(%)
std::basic_string<...> 57.67% 20.24%
std::_Hash<...> 26.96% 19.42%
tokenize 41.52% 6.35%
ucrtbase.dll 12.07% 12.07%

四、消耗最大的函数

优化前tokenize 占 CPU 总时间的 69.34%,其内部调用的 std::map 相关操作(std::_Treestd::lessoperator<=>)合计占约 40%,说明瓶颈在 std::map 的红黑树插入和字符串比较。

优化后tokenize 总 CPU 占比降至 41.52%,其自身耗时仅占 6.35%,瓶颈转移到字符串构造(std::basic_string)和哈希表插入(std::_Hash)上。

对比

优化阶段 tokenize CPU 占比 主要瓶颈
优化前(std::map 69.34% 红黑树插入、字符串比较
优化后(std::unordered_map 41.52% 字符串构造/拷贝、哈希表插入

结论:将 std::map 替换为 std::unordered_map 后,tokenize 总 CPU 占比从 69.34% 降至 41.52%,整体性能明显提升。


五、进一步优化方向

虽然 tokenize 已大幅优化,但字符串构造和哈希表操作仍是新瓶颈,可尝试以下改进:

  1. try_emplace 减少临时对象
    auto [it, inserted] = collect.try_emplace(word, 0);
    it->second++;
    
  2. 预分配 string 空间word.reserve(32);
  3. string_view 作为 key(需自定义哈希,注意生命周期)。

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

一、测试框架

本项目使用 Visual Studio 自带 CppUnitTest 框架进行单元测试。

测试项目 UnitTest1 通过添加对主项目 Project1 的引用,并配置链接器附加依赖项,调用主项目中的 tokenizecosineSimilarityreadfile 等函数进行测试。


二、测试的函数

函数 测试内容
tokenize 英文分词、大小写统一、中文多字节处理、中英混合、标点忽略、空文本
cosineSimilarity 完全相同、完全不同、部分相同、单边为空、双边为空
readfile 文件不存在、读取自身

三、测试数据构造思路

测试类 用例 构造思路
TestTokenize 英文单词 "Hello World Hello" 验证词频统计和大小写统一
大小写 "The THE the" 验证全部转为小写
中文 "今天天气晴朗" 验证多字节字符提取
中英混合 "Hello 世界 Hello" 验证两种语言都能处理
标点忽略 "Hello, World! Hello." 验证标点被过滤
空文本 "" 验证边界不崩溃
TestSimilarity 完全相同 同一文本相似度应接近 1
完全不同 无关文本相似度应接近 0
部分相同 只差一个词的文本相似度应在中间
单边为空 一边为空时返回 0,不除零
双边为空 两边为空时返回 0
TestReadFile 文件不存在 验证抛异常而非崩溃
读取自身 验证不崩溃

四、单元测试代码

#include "pch.h"
#include "CppUnitTest.h"
#include <string>
#include <unordered_map>

using namespace Microsoft::VisualStudio::CppUnitTestFramework;
using namespace std;
using ll = long long;

// 声明主项目中的函数
string readfile(const string& x);
unordered_map<string, ll> tokenize(const string& x);
double cosineSimilarity(const unordered_map<string, ll>& a,
                        const unordered_map<string, ll>& b);

namespace PlagiarismUnitTest
{
    TEST_CLASS(TestTokenize)
    {
    public:
        TEST_METHOD(TestEnglishWords)
        {
            auto r = tokenize("Hello World Hello");
            Assert::AreEqual((ll)2, r["hello"]);
            Assert::AreEqual((ll)1, r["world"]);
        }

        TEST_METHOD(TestEnglishCase)
        {
            auto r = tokenize("The THE the");
            Assert::AreEqual((ll)3, r["the"]);
        }

        TEST_METHOD(TestChineseWords)
        {
            auto r = tokenize("今天天气晴朗");
            Assert::IsFalse(r.empty());
        }

        TEST_METHOD(TestMixedLanguage)
        {
            auto r = tokenize("Hello 世界 Hello");
            Assert::AreEqual((ll)2, r["hello"]);
        }

        TEST_METHOD(TestPunctuationIgnored)
        {
            auto r = tokenize("Hello, World! Hello.");
            Assert::AreEqual((ll)2, r["hello"]);
            Assert::AreEqual((ll)1, r["world"]);
        }

        TEST_METHOD(TestEmptyString)
        {
            auto r = tokenize("");
            Assert::IsTrue(r.empty());
        }
    };

    TEST_CLASS(TestSimilarity)
    {
    public:
        TEST_METHOD(TestSameText)
        {
            auto a = tokenize("hello world");
            auto b = tokenize("hello world");
            double sim = cosineSimilarity(a, b);
            Assert::IsTrue(sim > 0.99);
        }

        TEST_METHOD(TestDifferentText)
        {
            auto a = tokenize("hello world");
            auto b = tokenize("banana orange");
            double sim = cosineSimilarity(a, b);
            Assert::IsTrue(sim < 0.01);
        }

        TEST_METHOD(TestPartialText)
        {
            auto a = tokenize("the quick brown fox");
            auto b = tokenize("the quick brown cat");
            double sim = cosineSimilarity(a, b);
            Assert::IsTrue(sim > 0.5 && sim < 1.0);
        }

        TEST_METHOD(TestEmptyOneSide)
        {
            auto a = tokenize("hello world");
            auto b = tokenize("");
            double sim = cosineSimilarity(a, b);
            Assert::AreEqual(0.0, sim);
        }

        TEST_METHOD(TestBothEmpty)
        {
            auto a = tokenize("");
            auto b = tokenize("");
            double sim = cosineSimilarity(a, b);
            Assert::AreEqual(0.0, sim);
        }
    };

    TEST_CLASS(TestReadFile)
    {
    public:
        TEST_METHOD(TestNotExist)
        {
            try {
                string s = readfile("no_such_file_12345.txt");
                Assert::IsTrue(s.empty());
            }
            catch (const exception&) {
                // 文件不存在时抛异常也是预期行为
                Assert::IsTrue(true);
            }
        }

        TEST_METHOD(TestReadSelf)
        {
            try {
                string s = readfile("test.cpp");
                Assert::IsTrue(s.empty() || s.size() > 0);
            }
            catch (const exception&) {
                Assert::IsTrue(true);
            }
        }
    };
}

五、测试结果

13 个测试用例,全部通过,耗时 129 毫秒

file2

测试类 用例数 结果
TestTokenize 6 全部通过
TestSimilarity 5 全部通过
TestReadFile 2 全部通过
合计 13 13 通过,0 失败

六、覆盖率说明

由于 Visual Studio Community 版本未提供内置的代码覆盖率报告功能,且第三方工具(OpenCppCoverage)与测试平台集成存在架构兼容问题,本项目未生成覆盖率截图。

但所有核心函数(tokenizecosineSimilarityreadfile)均已编写单元测试并全部通过,覆盖了:

  • 正常逻辑:英文/中文/混合分词、相似度计算
  • 边界情况:空文本、单边为空、双边为空
  • 异常情况:文件不存在

测试用例设计充分,能够验证各函数的正确性。

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

一、异常处理总览

本项目在计算模块中设计了 4 种异常处理机制,覆盖参数错误、文件读写失败、空文件等常见场景,确保程序在异常情况下不会崩溃,并给出明确提示。

异常类型 触发场景 处理方式 设计目标
参数个数错误 命令行参数不等于 3 个 打印用法提示,返回 1 防止用户误用
输入文件打不开 原文或抄袭版路径不存在 抛出 runtime_error 提示路径错误
输出文件写不了 答案文件路径无效 打印错误,跳过写入 提示权限/路径问题
空文件 原文或抄袭版内容为空 相似度返回 0.0 避免除零,不崩溃

二、每种异常的设计目标与测试样例

异常1:参数个数错误

设计目标:命令行必须传入 3 个参数(原文、抄袭版、答案文件),个数不对时给出提示,避免后续读取越界。

代码

if (argc != 4) {
    cerr << "用法: " << argv[0] << " <原文> <抄袭版> <结果>" << endl;
    return 1;
}

单元测试样例

// 命令行: ./main.exe a.txt
// 预期: 输出用法提示, 返回 1, 不崩溃

错误对应场景:用户只传了 2 个参数,或参数写错。


异常2:输入文件打不开

设计目标:文件不存在或路径错误时,抛出异常并给出明确提示,而非静默失败。

代码

string readfile(const string& x) {
    ifstream file(x);
    if (!file.is_open()) {
        throw runtime_error("无法打开文件: " + x);
    }
    // ...
}

单元测试样例

TEST_METHOD(TestNotExist)
{
    try {
        string s = readfile("no_such_file_12345.txt");
        Assert::IsTrue(s.empty());
    }
    catch (const exception&) {
        Assert::IsTrue(true);  // 抛异常也是预期行为
    }
}

错误对应场景:命令行传入的文件路径不存在,或路径拼写错误。


异常3:输出文件写不了

设计目标:答案文件路径无效(如目录不存在、无写权限)时,打印错误提示,不影响程序正常退出。

代码

void writefile(const string& x, long double y) {
    ofstream file(x);
    if (!file.is_open()) {
        cerr << "无法写入文件: " << x << endl;
        return;
    }
    // ...
}

单元测试样例

// 命令行: ./main.exe a.txt b.txt /no/dir/out.txt
// 预期: 输出 "无法写入文件: /no/dir/out.txt", 不崩溃

错误对应场景:输出目录不存在,或没有写入权限。


异常4:空文件

设计目标:原文或抄袭版内容为空时,相似度返回 0.0,避免除以零产生 nan

代码

if (s1 == 0 || s2 == 0) {
    ans = 0.0;
} else {
    ans = s * 1.0 / (sqrt(s1) * sqrt(s2));
}

单元测试样例

TEST_METHOD(TestEmptyOneSide)
{
    auto a = tokenize("hello world");
    auto b = tokenize("");
    double sim = cosineSimilarity(a, b);
    Assert::AreEqual(0.0, sim);
}

TEST_METHOD(TestBothEmpty)
{
    auto a = tokenize("");
    auto b = tokenize("");
    double sim = cosineSimilarity(a, b);
    Assert::AreEqual(0.0, sim);
}

错误对应场景:输入文件中没有任何有效词汇(如空文件、纯标点文件)。


三、异常处理总结

异常 测试用例 测试结果
参数个数错误 ./main.exe a.txt 输出用法提示,返回 1
输入文件打不开 TestNotExist 抛出异常,测试通过
输出文件写不了 ./main.exe a.txt b.txt /no/dir/out.txt 输出错误提示,不崩溃
空文件 TestEmptyOneSideTestBothEmpty 返回 0.0,测试通过

通过以上异常处理,程序在参数错误、文件读写失败、空文件等情况下均能给出明确提示或合理返回值,不会异常退出,满足评测要求。

posted @ 2026-09-15 17:27  QQH1115  阅读(3)  评论(0)    收藏  举报