个人项目
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 依次调用各模块,数据流如下:
三、关键流程
核心算法流程如下:
四、算法关键与独到之处
算法关键:采用余弦相似度,将文本转化为词频向量,通过计算两个向量夹角的余弦值衡量相似程度。余弦值越接近 1,两篇文章越相似。
独到之处:
- 用
unordered_map同时完成词频统计和向量表示,省去显式构建完整向量的步骤,节省空间。 - 点积计算只遍历一个 map,在另一个 map 中查找相同词,避免合并两个 map 带来的额外开销。
- 中英文混合处理:英文按字母切分并转小写,中文按 UTF-8 字节规则提取单个字符,不依赖第三方分词库,兼容性好。
- 文件 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)

| 函数名 | 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)

| 函数名 | 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::_Tree、std::less、operator<=>)合计占约 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 已大幅优化,但字符串构造和哈希表操作仍是新瓶颈,可尝试以下改进:
- 用
try_emplace减少临时对象:auto [it, inserted] = collect.try_emplace(word, 0); it->second++; - 预分配
string空间:word.reserve(32); - 用
string_view作为 key(需自定义哈希,注意生命周期)。
计算模块部分单元测试展示
一、测试框架
本项目使用 Visual Studio 自带 CppUnitTest 框架进行单元测试。
测试项目 UnitTest1 通过添加对主项目 Project1 的引用,并配置链接器附加依赖项,调用主项目中的 tokenize、cosineSimilarity、readfile 等函数进行测试。
二、测试的函数
| 函数 | 测试内容 |
|---|---|
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 毫秒。

| 测试类 | 用例数 | 结果 |
|---|---|---|
TestTokenize |
6 | 全部通过 |
TestSimilarity |
5 | 全部通过 |
TestReadFile |
2 | 全部通过 |
| 合计 | 13 | 13 通过,0 失败 |
六、覆盖率说明
由于 Visual Studio Community 版本未提供内置的代码覆盖率报告功能,且第三方工具(OpenCppCoverage)与测试平台集成存在架构兼容问题,本项目未生成覆盖率截图。
但所有核心函数(tokenize、cosineSimilarity、readfile)均已编写单元测试并全部通过,覆盖了:
- 正常逻辑:英文/中文/混合分词、相似度计算
- 边界情况:空文本、单边为空、双边为空
- 异常情况:文件不存在
测试用例设计充分,能够验证各函数的正确性。
计算模块部分异常处理说明
一、异常处理总览
本项目在计算模块中设计了 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 |
输出错误提示,不崩溃 |
| 空文件 | TestEmptyOneSide、TestBothEmpty |
返回 0.0,测试通过 |
通过以上异常处理,程序在参数错误、文件读写失败、空文件等情况下均能给出明确提示或合理返回值,不会异常退出,满足评测要求。

浙公网安备 33010602011771号