第二周作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 实现一个简单的论文查重系统 |
作业 GitHub 链接:3124004125 — Go 论文查重
一、PSP表格(预估)
| PSP 2.1 | 阶段 | 估计耗时(分钟) |
|---|---|---|
| Planning | 计划 | 5 |
| Estimate | 估计任务所需时间 | 20 |
| Development | 开发(下列开发子项小计) | |
| Analysis | 需求分析与需求理解 | 10 |
| Design Spec | 生成设计说明 | 10 |
| Design Review | 设计复审 | 15 |
| Coding Standard | 代码规范 | 15 |
| Design | 具体设计 | 30 |
| Coding | 具体编码 | 60 |
| Code Review | 代码复审 | 45 |
| Test | 测试、错误修复与验证 | 45 |
| Reporting | 报告(下列报告子项小计) | |
| Test Report | 测试报告 | 25 |
| Size Measurement | 计算工作量 | 10 |
| Postmortem & Process Improvement Plan | 事后总结与过程改进计划 | 20 |
| Total | 总计 | 310 |
二、计算模块接口的设计与实现过程
2.1 需求拆分与文件组织
程序分成命令行入口、文件操作、文本规范化和相似度计算四部分,采用函数式编程,不额外引入类层次。
3124004125/
├── go.mod
├── main.go 命令行入口、流程编排
├── fileio.go 文件读取、编码校验、输出保护
├── normalize.go Unicode 文本规范化
├── similarity.go 特征统计、Dice 相似度
├── fileio_test.go 文件与异常场景测试
├── normalize_test.go 规范化测试
├── similarity_test.go 算法测试
├── similarity_benchmark_test.go 性能基准
└── performance/ CPU profile、图表和说明
主要接口如下:
| 接口 | 职责 | 返回结果 |
|---|---|---|
Run(args []string) error |
检查参数并串联全部流程 | 成功返回 nil,失败返回错误 |
ReadText(path string) (string, error) |
读取 UTF-8 文件,移除开头 BOM | 文本或错误 |
EnsureOutputDistinct(resultPath string, inputPaths ...string) error |
检查输出是否指向输入文件 | 冲突时返回错误 |
Normalize(text string) []rune |
保留字母与数字,将字母转为小写 | 规范化字符序列 |
CountBigrams(text []rune) map[[2]rune]int |
统计相邻字符二元组频次 | 特征频次表 |
Similarity(original, candidate []rune) float64 |
计算字面相似度 | 0~1 的数值 |
WriteResult(path string, score float64) error |
写入两位小数及换行 | 写入结果或错误 |
文件读写不进入相似度函数,使算法可以独立测试。程序流程为:
接收三个路径
↓
检查参数数量及输出覆盖风险 ──失败──→ 标准错误输出,非零退出
↓
读取两份文本并校验 UTF-8 ───失败──→ 标准错误输出,非零退出
↓
规范化文本
↓
计算相似度
↓
将结果写入答案文件 ────────失败──→ 标准错误输出,非零退出
↓
正常结束
2.2 文本规范化
文件读取阶段使用 utf8.Valid 检查编码,避免把无效字节直接当成正文。随后以 rune 遍历文本,使用 unicode.IsLetter 和 unicode.IsDigit 保留字母与数字,并使用 unicode.ToLower 转换字母大小写。其余字符被过滤,包括空白、标点和符号。
例如,今天是星期天,天气晴。 会转换为 今天是星期天天气晴。这样,单纯修改标点或空格不会改变查重结果。
该规范化没有实现同义词处理、全半角统一或 Unicode 组合字符归一化。删除标点还会使原来跨句的字符形成相邻特征,这是当前方案的取舍。
2.3 字符二元组与多重集 Dice
对规范化后的文本,以相邻两个字符为一个特征。例如:
文本 A:甲乙丙丁
特征 A:甲乙、乙丙、丙丁
文本 B:甲乙丙戊
特征 B:甲乙、乙丙、丙戊
使用 map[[2]rune]int 保存出现次数。这里使用的是多重集,而不是只记录“是否出现”的普通集合。例如 aaaa 中的 aa 出现三次,重复段落的频次不会丢失。
设两篇文本中特征 g 的频次分别为 A(g) 和 B(g),则:
共有特征数 = Σ min(A(g), B(g))
相似度 = 2 × 共有特征数 / (原文特征总数 + 待检测文本特征总数)
上述例子的共有特征数为 2,总特征数为 6,因此相似度为 2 × 2 / 6,写入文件后为 0.67。
增删改通常只影响修改位置附近的二元组;段落换序保留了多数段内特征,因此也能得到较高的相似度。算法不需要中文分词词典,也不依赖外部服务,便于部署和复现。
2.4 边界处理与复杂度
相似度函数按以下顺序处理边界:
- 两份规范化文本相同,返回 1,包括两份都为空。
- 只有一份为空,返回 0。
- 任一文本不足两个字符,改用单字符频次计算 Dice。
- 其余情况使用二元组频次计算。
设两篇文本的总长度为 N,不同特征数为 U。在哈希表操作的通常假设下,预期时间复杂度为 O(N),整体读取、规范化与特征表的空间复杂度为 O(N+U)。当前实现会将两份文件完整读入内存,不属于流式算法。
输出是字面相似度,不能严格解释为被抄袭字数占比。星期天 和 周天 不会被直接识别为同义词;题面也没有给出标准算法和全部隐藏数据,因此不能据此声称已通过课程全部测试。
三、计算模块性能分析与改进方向
3.1 测量范围和环境
本项目使用 Go 自带的 CPU profiling 和 pprof。
| 项目 | 条件 |
|---|---|
| Go | 1.25.3 |
| 平台 | Windows amd64 |
| CPU | Intel Core i9-13980HX |
| 原文大小 | 1,860,000 字节 |
| 待检测文本大小 | 1,920,057 字节 |
| 输入构造 | 重复固定中文段落,替换词语并追加段落 |
| 测量路径 | Normalize → Similarity → CountBigrams |
| profile 进程时长 | 18.34 秒 |
| CPU 总采样时间 | 18.51 秒 |
性能基准在计时循环之前构造文本,在循环内执行规范化和相似度计算。两篇文本不同,避免命中相同文本的提前返回路径。此基准不包含实际文件读写;CPU profile 仍可能包含少量测试启动、输入准备、调度和垃圾回收开销。
CPU 总采样时间与实际经过时间不是同一指标,多线程采样可使前者略高于后者;18.34 秒也不是处理一对文件的耗时。
采样与查看命令:
go test -run '^$' -bench '^BenchmarkPipeline$' -benchtime=8s -cpuprofile performance/cpu.pprof -count=1
go tool pprof -top -nodecount=25 performance/cpu.pprof
3.2 性能分析图

图 1:根据真实 pprof 数据整理的 flat/cum 对照图。flat 为函数自身耗时,cum 包括子调用;不同函数的 cum 存在重叠,不能相加。

图 2:go tool pprof 自动生成的调用图,用于查看规范化、特征统计与运行时函数之间的调用关系。
调用图复现命令(需要 Graphviz):
go tool pprof -png -output performance/cpu-callgraph.png performance/cpu.pprof
3.3 耗时较大的函数
| 函数 | flat 时间 | flat 占比 | cum 时间 | cum 占比 |
|---|---|---|---|---|
unicode.is16 |
4.01 s | 21.66% | 4.02 s | 21.72% |
unicode.lookupCaseRange |
2.33 s | 12.59% | 2.33 s | 12.59% |
runtime.mapassign_fast64 |
2.04 s | 11.02% | 5.08 s | 27.44% |
papercheck.CountBigrams |
1.54 s | 8.32% | 6.62 s | 35.76% |
papercheck.Normalize |
1.19 s | 6.43% | 11.12 s | 60.08% |
按全部函数的自身耗时排序,unicode.is16 最高。若只看业务函数自身耗时,CountBigrams 较高;若看业务函数包含子调用的耗时,Normalize 占比最高。规范化阶段包含 Unicode 分类和大小写转换,二元组统计阶段主要伴随 map 写入和哈希计算。
四、计算模块单元测试与覆盖率
4.1 测试数据设计
当前共有 31 个以 Test 开头的测试函数。测试从可手算数据、规范化不变量、短文本和文件错误四个方向构造,避免只验证正常长文本。
| 场景 | 代表性测试 | 验证内容 |
|---|---|---|
| 完全相同 | TestSimilarityIdenticalText |
返回 1 |
| 两份都为空 | TestSimilarityBothEmpty |
返回 1 |
| 仅一份为空 | TestSimilarityOneEmpty |
返回 0 |
| 无共同二元组 | TestSimilarityNoCommonBigrams |
返回 0 |
| 手算样例 | TestSimilarityHandCalculatedPointSixSeven |
精确比较 2/3 |
| 插入文字 | TestSimilarityInsertion |
得分介于 0 与 1 |
| 删除文字 | TestSimilarityDeletion |
得分介于 0 与 1 |
| 替换文字 | TestSimilarityReplacement |
得分介于 0 与 1 |
| 片段换序 | TestSimilarityReorderedTextRetainsLocalFeatures |
保留部分局部特征 |
| 重复片段 | TestSimilarityRepeatedPassageUsesMultiplicity |
频次参与计算,结果为 0.75 |
| 单字符回退 | TestSimilaritySingleCharacterFallback |
单字符与双字符得到 2/3 |
| 待检测文本较短 | TestSimilarityUsesCharacterFallbackWhenCandidateIsShort |
反向短文本路径正确 |
| 标点空白 | TestNormalizeRemovesWhitespaceAndPunctuation |
过滤非正文字符 |
| 字母、中文、数字混合 | TestNormalizeLowercasesUnicodeLetters |
字母转小写并保留数字 |
| UTF-8 BOM | TestReadTextRemovesBOM |
忽略文件开头 BOM |
| 文件输出 | TestWriteResultUsesTwoDecimalPlaces |
精确写入 0.67\n |
| 硬链接输出 | TestEnsureOutputDistinctRejectsHardLink |
同一文件的不同名字也被拒绝 |
| 参数、编码和文件错误 | 见下一节 | 返回错误并保护输入/已有答案 |
4.2 部分单元测试代码
以下片段来自当前测试文件。第一个用例直接验证可手算的数值,避免仅检查输出范围:
func TestSimilarityHandCalculatedPointSixSeven(t *testing.T) {
got := Similarity([]rune("甲乙丙丁"), []rune("甲乙丙戊"))
if math.Abs(got-2.0/3.0) > 1e-12 {
t.Fatalf("Similarity() = %v, want 0.666...", got)
}
}
重复片段用例检查是否保留多重集频次:
func TestSimilarityRepeatedPassageUsesMultiplicity(t *testing.T) {
got := Similarity([]rune("甲乙甲乙"), []rune("甲乙甲乙甲乙"))
if math.Abs(got-0.75) > 1e-12 {
t.Fatalf("Similarity() = %v, want 0.75", got)
}
}
输出格式由独立测试验证:
func TestWriteResultUsesTwoDecimalPlaces(t *testing.T) {
path := filepath.Join(t.TempDir(), "答案.txt")
if err := WriteResult(path, 2.0/3.0); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "0.67\n" {
t.Fatalf("result = %q, want 0.67\\n", data)
}
}
4.3 测试结果与覆盖率报告
本文整理时重新运行以下命令:
go test ./... -count=1 -coverprofile coverage.out
go tool cover -func coverage.out
go tool cover -html coverage.out -o performance/coverage.html
实际输出:
ok papercheck 0.253s coverage: 89.8% of statements
| 函数 | 语句覆盖率 |
|---|---|
ReadText |
100.0% |
Normalize |
100.0% |
CountBigrams |
100.0% |
CountCharacters |
100.0% |
Similarity |
100.0% |
Run |
93.3% |
EnsureOutputDistinct |
85.7% |
WriteResult |
77.8% |
| 全项目 | 89.8% |

五、计算模块异常处理
Go通过 error 返回错误,由 main 统一写入标准错误并以非零状态退出。
| 错误类别 | 设计目标与处理 | 已有测试样例 |
|---|---|---|
| 参数数量错误 | 阻止参数越界,输出用法 | TestRunRejectsWrongArgumentCount:只传入一个参数 |
| 输入不存在 | 返回读取错误,保留已有答案 | TestRunMissingInputKeepsExistingResult:待检测文件缺失 |
| 非法 UTF-8 | 拒绝错误编码,避免乱码参与计算 | TestReadTextRejectsInvalidUTF8、TestRunInvalidUTF8KeepsExistingResult:输入字节 FF FE |
| 输出覆盖输入 | 在写入前拒绝,保护原文 | TestEnsureOutputDistinctRejectsSamePath:输出与原文同路径 |
| 文件别名指向输入 | 规范化路径并比较文件身份 | TestEnsureOutputDistinctRejectsHardLink:答案路径是原文的硬链接 |
| 输出不能打开 | 返回写入错误,不报告成功 | TestRunRejectsDirectoryAsResult:答案路径为目录 |
各类错误的关键测试断言如下,片段中的路径与数据由对应测试的临时目录准备:
// 参数数量错误:只传一个参数必须失败。
if err := Run([]string{"only-one"}); err == nil {
t.Fatal("Run() accepted an invalid argument count")
}
// 输入缺失:Run 返回错误,测试还会确认既有答案仍为 keep\n。
err := Run([]string{originalPath, filepath.Join(directory, "missing.txt"), resultPath})
if err == nil {
t.Fatal("Run() accepted a missing input")
}
// 编码错误:invalid.txt 的内容由测试写为 []byte{0xff, 0xfe}。
if _, err := ReadText(path); err == nil || !strings.Contains(err.Error(), "UTF-8") {
t.Fatalf("ReadText() error = %v, want UTF-8 error", err)
}
// 输出与输入同路径。
if err := EnsureOutputDistinct(input, input); err == nil {
t.Fatal("EnsureOutputDistinct() accepted identical input and output")
}
// alias 经 os.Link(input, alias) 创建,即同一文件的另一个名字。
if err := EnsureOutputDistinct(alias, input); err == nil {
t.Fatal("EnsureOutputDistinct() accepted a hard-link alias")
}
// resultPath 是测试通过 os.Mkdir 创建的目录。
if err := Run([]string{originalPath, candidatePath, resultPath}); err == nil {
t.Fatal("Run() accepted a directory as result path")
}
六、实际工时与项目回顾
本项目完成了命令行文件查重、二元组多重集 Dice、边界和异常处理、31 个单元测试以及 CPU 性能分析。通过GPT-6 Astra启发思路,自主设计方案,由 GPT-6 Astra 主agent与GPT-5.6 Luna 子agent辅助完成,随后进行了代码差异检查与命令行验收,本文根据这些实际产物整理。
目前最明确的性能观察是:在本次中文重复段落数据上,规范化调用链比相似度计算调用链消耗更多 CPU 时间。后续优化应先验证这一热点,而不是仅根据算法直觉优化公式计算。
七、PSP表格(完成后)
| PSP 2.1 | 阶段 | 实际耗时(分钟) |
|---|---|---|
| Planning | 计划 | 5 |
| Estimate | 估计任务所需时间 | 5 |
| Development | 开发(下列开发子项小计) | |
| Analysis | 需求分析与需求理解 | 20 |
| Design Spec | 生成设计说明 | 10 |
| Design Review | 设计复审 | 22 |
| Coding Standard | 代码规范 | 15 |
| Design | 具体设计 | 34 |
| Coding | 具体编码 | 29 |
| Code Review | 代码复审 | 40 |
| Test | 测试、错误修复与验证 | 12 |
| Reporting | 报告(下列报告子项小计) | |
| Test Report | 测试报告 | 21 |
| Size Measurement | 计算工作量 | 5 |
| Postmortem & Process Improvement Plan | 事后总结与过程改进计划 | 50 |
| Total | 总计 | 268 |
浙公网安备 33010602011771号