第二周作业

这个作业属于哪个课程 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.IsLetterunicode.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. 两份规范化文本相同,返回 1,包括两份都为空。
  2. 只有一份为空,返回 0。
  3. 任一文本不足两个字符,改用单字符频次计算 Dice。
  4. 其余情况使用二元组频次计算。

设两篇文本的总长度为 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 性能分析图

image

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

image

图 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%

image

五、计算模块异常处理

Go通过 error 返回错误,由 main 统一写入标准错误并以非零状态退出。

错误类别 设计目标与处理 已有测试样例
参数数量错误 阻止参数越界,输出用法 TestRunRejectsWrongArgumentCount:只传入一个参数
输入不存在 返回读取错误,保留已有答案 TestRunMissingInputKeepsExistingResult:待检测文件缺失
非法 UTF-8 拒绝错误编码,避免乱码参与计算 TestReadTextRejectsInvalidUTF8TestRunInvalidUTF8KeepsExistingResult:输入字节 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
posted @ 2026-09-11 21:00  proee  阅读(6)  评论(0)    收藏  举报