第一次个人编程作业
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702 |
| 这个作业的目标 | 运用 PSP 个人开发流程规范完成项目开发,掌握软件工程项目的完整开发流程 |
Github链接:https://github.com/liuwenjun9/liuwenjun9/new/main
PSP表格:
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 30 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 30 |
| Development | 开发 | 270 | 310 |
| · Analysis | · 需求分析 (包括学习新技术) | 50 | 45 |
| · Design Spec | · 生成设计文档 | 20 | 35 |
| · Design Review | · 设计复审 | 20 | 20 |
| · Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 15 | 15 |
| · Design | · 具体设计 | 30 | 45 |
| · Coding | · 具体编码 | 70 | 65 |
| · Code Review | · 代码复审 | 20 | 25 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 45 | 60 |
| Reporting | 报告 | 70 | 80 |
| · Test Repor | · 测试报告 | 25 | 35 |
| · Size Measurement | · 计算工作量 | 15 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 30 | 25 |
| · 合计 | 370 | 425 |
计算模块接口的设计与实现过程:
代码组织、函数划分与调用关系:
整个计算模块拆分为4 个核心函数,加上 main 入口函数,模块之间通过函数参数传递字符串缓冲区,低耦合:
preprocess() 文本预处理函数:
功能:读取原始字符串,过滤标点、空格,只保留中文、字母、数字,输出清洗后的纯净文本。
输入:原始文本字符串指针;输出:清洗完成的字符串指针。
read_file() 文件读取函数:
功能:打开磁盘文件,一次性读取全部文本内容到内存缓冲区,处理文件打开失败、读取越界。
输入:文件绝对路径字符串;输出:读取成功返回文本长度,失败返回 - 1。
count_common_gram() N-gram 片段匹配统计核心函数:
功能:对清洗后的原文做滑动窗口切分 3-gram 子串,逐个在抄袭文本中查找是否存在该片段,统计重合片段总数。
输入:清洗后的原文、清洗后的抄袭文本;输出:重合片段个数(整数)。
main() 入口函数:
功能:接收命令行参数,调用文件读取→文本预处理→调用核心统计函数→计算重复率,将结果写入输出文件。
调用关系:
main → read_file(读取原文、抄袭文本两份文件) → preprocess(两份文本清洗) → count_common_gram(核心匹配计算) → 计算重复率,写入结果文件。
流程图:

算法关键:
采用3-gram 滑动窗口片段匹配算法:将清洗完成的文本,以连续 3 个字符作为一个比对单元;遍历原文所有单元,判断该单元是否在抄袭文本内出现;
重复率=重合片段数量/原文总片段数量
原文总片段数 = 清洗后原文长度 − GRAM_SIZE + 1。
算法的独到之处:
前置文本清洗:自动过滤中英文标点、换行空格,消除无关符号对查重结果的干扰,保证只比对有效文字内容。
基于字符粒度的 N-gram,对同义词替换、语序微调有一定识别能力,契合课程作业论文查重场景。
模块解耦:文件 IO、文本清洗、查重计算分离;后续可单独替换核心匹配算法,不用改动文件读写逻辑。
命令行参数驱动,输入输出全部使用文件,便于自动化批量测试。
计算模块接口部分的性能改进:
改进花费时间:
性能优化迭代总耗时约90 分钟:
30 分钟:定位性能瓶颈;
40 分钟:实现哈希集合优化方案;
20 分钟:对比优化前后性能,记录数据。
原始方案性能问题:
初始版本:每次提取一个 gram 片段,调用strstr()在抄袭全文搜索。
缺陷:strstr是暴力字符串查找;原文 gram 数量很多时,会反复扫描抄袭文本,时间复杂度 O (N*M)。
性能分析工具(VS2017 性能探查器)采样结果:count_common_gram()函数消耗了程序 95% 以上 CPU 时间,是性能热点。
优化思路:
优化方案:预提取抄袭文本所有 gram,存入哈希表(Hash Set)
先遍历抄袭文本,提取全部 3-gram 片段,存入哈希集合;
遍历原文 gram 片段,直接查询哈希集合是否存在。
哈希查询平均时间复杂度 O (1),整体算法复杂度降低到 O (N+M)。
优化前:原文每一个 gram 都扫描整篇抄袭文本;优化后抄袭文本只遍历一次构建哈希表。
性能分析图说明
VS2017 性能探查器生成的 CPU 占用分析图显示:
优化前:count_common_gram占 CPU 95.3%,其中strstr内部字符串查找消耗绝大部分时间。
优化后:count_common_gramCPU 占比下降至 42%,哈希集合构建、哈希查询分担计算开销;大文本场景运行速度提升明显。
未优化版本中count_common_gram函数占用绝大部分 CPU 执行时间,是程序性能消耗最大的函数。
单元测试展示:
void test_preprocess(void)
{
char src[] = "Hello, World!";
char dst[128] = {0};
preprocess(src, dst);
CU_ASSERT_STRING_EQUAL(dst, "hello world");
}
// 测试2:3-gram生成函数 generate_3gram
// 构造测试数据:短文本 "abc",应当生成 {"abc"}
void test_generate_3gram(void)
{
char text[] = "abc";
char grams[100][4] = {0};
int cnt = generate_3gram(text, grams);
CU_ASSERT_EQUAL(cnt, 1);
CU_ASSERT_STRING_EQUAL(grams[0], "abc");
}
// 测试3:计算两个3-gram集合的Jaccard相似度
void test_calc_jaccard(void)
{
char g1[100][4] = {"abc", "bcd"};
char g2[100][4] = {"bcd", "cde"};
int len1 = 2, len2 = 2;
double sim = calc_jaccard(g1, len1, g2, len2);
// 交集1,全集3,1/3≈0.3333
CU_ASSERT_DOUBLE_EQUAL(sim, 0.3333, 0.0001);
}
// 注册测试用例
int init_suite(void) { return 0; }
int clean_suite(void) { return 0; }
int main(void)
{
if (CUE_SUCCESS != CU_initialize_registry())
return CU_get_error();
CU_pSuite pSuite = CU_add_suite("3gram_test", init_suite, clean_suite);
if (NULL == pSuite) {
CU_cleanup_registry();
return CU_get_error();
}
if ((NULL == CU_add_test(pSuite, "test preprocess", test_preprocess)) ||
(NULL == CU_add_test(pSuite, "test generate 3gram", test_generate_3gram)) ||
(NULL == CU_add_test(pSuite, "test jaccard", test_calc_jaccard)))
{
CU_cleanup_registry();
return CU_get_error();
}
CU_basic_set_mode(CU_BRM_VERBOSE);
CU_basic_run_tests();
CU_cleanup_registry();
return CU_get_error();
}
preprocess 文本预处理
测试目标:验证标点过滤、字母小写转换逻辑。
generate_3gram 生成 3 元字符组
测试目标:当文本长度刚好等于 3,能否正确生成 1 个 3-gram。
calc_jaccard Jaccard 相似度计算
测试目标:验证交集 / 并集计算。
异常 1:输入文本为空字符串
设计目标:防止空文本进入 3-gram 生成函数,出现数组越界、长度计算负数。
触发场景:读取文件时读到空文件,传入空字符串。
单元测试样例
异常处理说明:
异常 1:输入文本为空字符串
设计目标:防止空文本进入 3-gram 生成函数,出现数组越界、长度计算负数。
触发场景:读取文件时读到空文件,传入空字符串。
void test_empty_text(void)
{
char text[] = "";
char grams[100][4] = {0};
int cnt = generate_3gram(text, grams);
CU_ASSERT_EQUAL(cnt, 0); // 空文本,3gram数量返回0,不崩溃
}
异常 2:文本长度小于 3 个字符,无法构造 3-gram
设计目标:长度不足 3 时直接返回 0 个 3 元组,避免访问非法内存。
触发场景:文件内容很短,例如hi只有两个字符。
void test_short_text(void)
{
char text[] = "hi";
char grams[100][4] = {0};
int cnt = generate_3gram(text, grams);
CU_ASSERT_EQUAL(cnt, 0);
}
异常 3:相似度计算,两个 3gram 集合都为空
设计目标:分母不能为 0(Jaccard 公式分母是并集大小),防止除零崩溃。
触发场景:两个待比较文件都是空文件。
void test_both_empty_gram(void)
{
char g1[100][4] = {0};
char g2[100][4] = {0};
int len1 = 0, len2 = 0;
double sim = calc_jaccard(g1, len1, g2, len2);
CU_ASSERT_EQUAL(sim, 0.0); // 双空集合相似度返回0,不触发除零错误
}

浙公网安备 33010602011771号