CSP相似度计算题解析:字符串切分与集合去重实战
2026/9/7 22:50:22 网站建设 项目流程

CSP(CCF计算机软件能力认证)每年要考四五场,前两题历来被大家叫作“签到题”,意思是说认真读题、按部就班写就能拿满分。2024年3月这场认证的第二题“相似度计算”就非常典型:题目给两行英文文本,要求按单词维度计算相似度,表面看只是个字符串处理加集合去重的问题,但真正动手写的时候,大小写归一化、单词切分规则、输出百分号的精度控制,每一个细节都能让不细心的人翻车。

这道题很适合刚接触CSP认证、准备打基础的朋友拿来练手,也适合那些已经能写前两题但偶尔因为“小坑”丢分的同学做一次全流程复盘。它考察的并不是什么高深算法,而是你在有限时间内把一段文字描述准确翻译成代码的能力,尤其是对C++标准库字符串函数和集合容器的熟练度。我当年第一次做这道题的时候,代码逻辑十分钟就写完,结果在“空文本和除零”这个边界上纠结了半天。这篇就把整个解题过程、代码实现、踩坑记录一次性讲透。

1. 题目还原与核心考点拆解

1.1 原题到底在问什么

题目会给两行文本,每行由若干个英文单词组成,中间可能有空格、标点、数字等非字母字符。要求把文本里的单词提取出来,忽略大小写差异,然后计算两段文本的“相似度”。相似度的定义很直白:两个文本中相同单词的数量,除以两个文本中所有不重复单词的总数量。

举个例子,第一行是“I love CSP”,第二行是“I love coding”,把单词都转成小写后,第一段的单词集合是{i, love, csp},第二段是{i, love, coding},交集是{i, love},并集是{i, love, csp, coding},相似度就是2除以4等于0.5,输出为0.50%。

核心限制有两个:一是忽略大小写,二是同一个单词重复出现只算一次。也就是说,CSP、Csp、csp这三个写法在题目眼里是同一个单词,而“love love love”这一整行也只有一个love。

1.2 考点地图:字符串处理、集合论与浮点输出的三重考验

先看字符串处理。题面里的“单词”并不是用空格隔开的简单字符串,而是连续字母组成的子串。也就是说,如果输入是“hello,world;123test”,那么合法单词是hello、world、test,123不是单词,逗号、分号、数字都是分隔符。这个细节直接否定了用cin >>逐词读取的做法,因为你不知道单词之间到底隔了什么。

再看集合运算。题目要求“相同的单词只计算一次”,这几乎就是在明示你要用集合。把两段文本的单词分别放进两个set,交集大小就是相同单词的数量,并集大小就是总的不同单词数量。底层数学原理就是经典的Jaccard相似度公式,这个概念在信息检索、推荐系统、文本去重里都会被反复用到。

最后是输出格式。要求以百分数形式输出,保留两位小数,末尾带百分号。C++里用printf("%.2f%%")是最省事的写法,但如果不小心忘了转义百分号,或者用了整数除法导致精度丢失,那这题的分数就全没了。

1.3 相似度公式的本质:这就是Jaccard系数

很多第一次接触这道题的人会觉得“相似度”是个很虚的概念,其实它背后是有标准定义的。Jaccard系数的计算公式是交集大小除以并集大小,取值范围在0到1之间,两个集合完全相同则为1,完全没有重合则为0。这道题把它包装成“相似度计算”,本质上就是让考生实现一次Jaccard相似度。

理解这一层能带来一个额外好处:你在学习这道题时积累的集合运算思路,可以直接迁移到后面更复杂的文本处理题上。CSP认证不考偏题怪题,它考的永远是基本功的组合,字符串、集合、哈希这三样东西高频出现,而这道题恰好把它们串在了一个完整的场景里。

2. 破题思路:从读题到设计算法的完整推演

2.1 文本行的读取与单词切分是第一步

拿到这道题,第一个要解决的问题就是怎么把一行文本里的单词干干净净地提取出来。由于单词之间可能隔着空格、逗号、分号、数字、括号等各种字符,所以最稳妥的做法不是依赖键盘输入的空格分隔,而是自己遍历整行字符串,遇到字母就临时累积,遇到非字母就说明当前单词结束。

具体切分逻辑可以这样写:维护一个空字符串cur,从左往右扫描输入行的每个字符,如果当前字符是字母,就把它转成小写后追加到cur后面;如果当前字符不是字母,并且cur不为空,那说明攒出了一个完整单词,把它放入vector,再清空cur继续扫描。循环结束后如果cur不为空,记得再放一次,因为最后一个单词后面可能没有分隔符。

这个遍历切分的方法在任何语言里都能写,不依赖C++的正则库,也不依赖Python的re模块,属于最通用、最不容易被环境坑到的方案。对于CSP这种需要稳定发挥的比赛,我向来推荐用这种手工可控的方式。

2.2 大小写归一化与去重策略必须同时考虑

题目要求忽略大小写,所以在切分单词的时候顺手做小写转换最省事,这样后面比较单词是否相同时就不会有麻烦。有的同学喜欢先把原始单词存下来,最后再统一转小写,这也没问题,但容易漏掉某个角落里的转换调用,不如在单词进入容器之前就完成归一化,从源头保证数据干净。

去重则是另一个维度。你可以选择先把所有单词放进vector,排个序再unique去重;也可以直接把单词塞进set容器,让容器自动去重。前者需要多写几次STL调用,后者更符合“即插即用”的思路。我个人强烈推荐后者,因为set不仅能去重,还能顺便帮你完成后续交集并集的运算,一举多得。

2.3 集合运算落地:为什么我推荐用unordered_set而不是set

C++里有两套集合容器,set底层是红黑树,元素自动排序,插入和查找复杂度都是O(log n);unordered_set底层是哈希表,元素无序,插入和查找均摊复杂度是O(1)。对于这个题,我们并不需要单词的排序结果,所以unordered_set是更高效的选择,尤其是文本长度比较大的时候,性能差距非常明显。

当然,如果你对哈希表的迭代顺序不放心,用set也完全能过。CSP前两题的数据量通常不会大到让红黑树和哈希表拉开显著差距,这个选择更多是习惯和代码风格的问题。但要提醒一点:如果用unordered_set,头文件记得写#include <unordered_set>,有些老教材默认只讲set,新手很容易漏掉这个头文件导致编译报错。

2.4 算法复杂度分析:为什么这个方案能稳过

设第一行文本长度为L1,第二行长度为L2。遍历切分的过程是O(L1+L2),向哈希集合中插入单词均摊O(1),所以构建两个集合总复杂度O(L1+L2)。求交集时遍历其中一个集合的所有元素,在另一个集合里做哈希查询,均摊O(1),因此也是O(min(|A|, |B|))。总时间复杂度是线性的,空间复杂度最多是O(L1+L2)。

这种复杂度意味着就算文本长度达到几十万级别也不会超时,更不用说CSP前两题通常只会给几百到几千长度的文本。你在脑子里过一遍复杂度,就能提前判断自己的方案会不会超时,这也是认证考试里很重要的一项能力。

3. 完整实现与代码逐段拆解

3.1 C++参考实现(AC代码)

下面这段是我在考场里写过的版本,经过整理后保留了最关键的几段逻辑。代码用C++17编写,核心思路就是“遍历切分 + 集合去重 + 交集并集”,没有任何花哨的优化,但胜在稳定和直观。

#include <bits/stdc++.h> using namespace std; vector<string> splitWords(const string& s) { vector<string> words; string cur; for (char ch : s) { if (isalpha(ch)) { cur.push_back(tolower(ch)); } else { if (!cur.empty()) { words.push_back(cur); cur.clear(); } } } if (!cur.empty()) { words.push_back(cur); } return words; } int main() { string line1, line2; getline(cin, line1); getline(cin, line2); vector<string> w1 = splitWords(line1); vector<string> w2 = splitWords(line2); unordered_set<string> s1(w1.begin(), w1.end()); unordered_set<string> s2(w2.begin(), w2.end()); int common = 0; for (const string& word : s1) { if (s2.count(word)) { common++; } } int total = (int)s1.size() + (int)s2.size() - common; double sim = (total == 0) ? 1.0 : (double)common / total; printf("%.2f%%\n", sim * 100.0); return 0; }

这份代码我实际跑过所有常规用例,行为符合题目要求。有两个地方值得单独说明:一是isalpha判断字符是否为字母时,本身能识别ASCII字母表里的英文字母,这对题面的“英文单词”完全够用;二是最终用printf格式化输出,.2f保留两位小数,%%输出一个百分号。

3.2 逐段拆解:读取、切分、插集合、算交集、输出

先看读取部分。两行文本里可能有空行,也可能有空格开头或结尾的情况,用getline(cin, line)能完整保留每一行的原样内容,比cin >> line更安全。如果你用cin >>读,那读到第一个空格就停了,后面的单词全丢,这题直接白给。

再看splitWords函数。遍历字符时isalpha(ch)为真,说明当前字符是字母,追加到cur并转小写。这个tolower调用很关键,否则你后面比较CSP和csp时会发现它们不一样。遇到非字母时,如果cur里已经攒了单词,就把它推进words并清空cur。循环结束后的那个if判断是防止最后一个单词因为文件末尾没有分隔符而丢失。

接着看集合构造。用vector的迭代器区间直接初始化unordered_set,这一步会自动完成去重。如果一行文本中love出现一百次,s1里也只有一个love,这正是题目要的效果。然后遍历s1,逐个检查s2里有没有相同单词,有就common加一。遍历s1而不是s2无所谓,只要两个集合都遍历全,结果一致即可。

最后看并集计算。total = s1.size() + s2.size() - common,这个公式很好理解:把两个集合的大小加起来,交集部分被算了两遍,减去一遍就是并集大小。相似度就是common / total。total为0时说明两个集合都为空,此时0/0没有数学意义,我选择输出1.0,即认为两个空文本完全相似,这一点你可以根据题面约定自行决定,我在后面的防坑章节会展开聊。

3.3 用Python实现的对照组:简洁但注意输入输出细节

如果你更熟悉Python,或者想用Python快速验证思路,下面这段代码同样能完成任务:

import sys import re def split_words(text): return re.findall(r'[a-zA-Z]+', text.lower()) line1 = sys.stdin.readline().strip() line2 = sys.stdin.readline().strip() s1 = set(split_words(line1)) s2 = set(split_words(line2)) common = len(s1 & s2) total = len(s1 | s2) sim = common / total if total > 0 else 1.0 print(f"{sim * 100:.2f}%")

Python的re.findall直接就能把连续字母提取出来,text.lower()一键转小写,set天然去重,代码比C++短很多。但CSP正式认证环境中,C++的编译和运行更为稳定,而且大多数培训机构和往年真题解析都以C++为主,所以我更建议你在考试里使用C++版本。Python版本适合在本地做快速原型实验,用来验证自己对题意的理解是否正确。

4. 高频报错与防坑手册

4.1 输出格式翻车:百分号与精度是重灾区

我见过太多人在最后一步栽跟头。题目要求输出类似“50.00%”的格式,有些同学用cout直接输出,忘了设置fixed和setprecision,结果变成“50%”或者“0.5%”,直接判错。用printf的话,一定记得写printf("%.2f%%", sim * 100.0),这里有两个百分号,第一个是转义输出,第二个才是真正的百分号字符。

另一点是浮点计算误差。如果你写int common / int total,得到的是整数除法,结果直接变成0,这是毫无疑问的致命错误。必须把common或total先转成double再除,C++里我习惯(double)common / total,这样编译器会把后面的total自动提升为double,不会丢精度。Python不存在整数除法的问题,但打印时保留两位小数的语法不同,也要注意。

4.2 空文本与除零:一个容易被忽略的边界情况

如果两行文本里一个字母都没有,比如全是数字和符号,那么s1和s2都是空集合,total等于0,直接除会崩溃或产生未定义行为。处理办法是加一个判断:if (total == 0) sim = 1.0。至于答案是0.00%还是100.00%,原题大概率不会给这么极端的用例,但你的代码必须要能运行而不崩溃,这是基本的健壮性要求。

我在实际测试时会把空行、纯数字行、纯标点行都跑一遍,确保程序不会在边界输入上崩掉。CSP的隐藏测试点最喜欢在边界做文章,你要是能提前做好防御,就比很多人稳了一截。

4.3 大小写忽略的隐形陷阱

题目说忽略大小写,意味着“Abc”和“abc”要算同一个单词,所以必须把每个单词都转成统一形式。我的做法是在切分过程中边拼边转,这样后续无论是插入集合还是比较,拿到的都是小写单词。有一种常见的错误写法是:先把原始文本按空格拆开,再对每个单词做大小写转换,最后去重,这样做在单词之间只有空格时没问题,但遇到“Hello,World”这种逗号分隔的情况就会把“Hello,”当成一个单词,最后集合里出现带标点的脏数据,相似度计算完全乱套。

4.4 测试数据构造技巧:手写样例验证每一步

写完代码别急着提交,先自己构造几个测试用例,覆盖不同类型的单词切分和边界情况。我常用的测试集包括:

  • 普通空格分隔:“I love CSP”和“I love coding”
  • 大小写混写:“CSP csp Csp”和“csp CSP”
  • 标点混合:“Hello, world; 123”和“hello world”
  • 空行
  • 纯数字行
  • 同一个单词重复多次

每跑一个用例,在纸上手算一遍预期结果,和程序输出做对比。比如“CSP csp Csp”和“csp CSP”这两行,切分后第一个集合只有一个单词csp,第二个集合也只有一个单词csp,交集1并集1,相似度100.00%。如果你算出来不是这个结果,那说明切分或大小写处理有bug。

5. 从这道题延伸出去的备考思路

5.1 回到CSP前两题的核心逻辑:字符串处理为什么永远不过时

CSP认证每年题目风格会有微调,但前两题绕不开字符串处理、模拟、简单数学、集合映射这四大类。相似度计算这道题,恰好把字符串处理中的切分、归一化,和数据结构中的集合运算结合在了一起,是一个非常标准的“送分但不送命”的出题方式。它能帮你检验两件事:一是你能否读懂题目里每个约束条件的真实含义,二是你能否把这些约束快速翻译成STL容器的操作。

很多同学备考CSP时一味刷难题,觉得前两题太简单不值得花时间。但实践证明,前两题丢分的概率一点也不低,尤其是第二题,题目长度比第一题长,描述里的细节多,稍不留神就会掉进某个坑。把最近几年的真题前两题拿出来逐题精刷,把每种常见坑都踩一遍,比盲目刷十道难题有用得多。

5.2 类似题型的横向对比:集合运算还能怎么考

掌握了相似度计算,你可以顺手做做这几类变体题:一是“两个数组的交集”,给定两个整数数组求交集,这题本质就是集合运算,只是把字符换成了数字;二是“不同单词数量统计”,给定一篇文章统计不同单词的个数,只需要一个set就能解决;三是“词频统计”,要求输出每个单词出现的次数,这时候需要map或unordered_map,比set多一维信息。你会发现它们底层的数据结构思维完全一致,都是“去重 + 查询”。

这种横向对比的复习方式,比单纯背题有效得多。你不需要做大量重复劳动,只需要把每道题的考点抽象出来,归纳成几个大类,再针对每个大类总结出通用的模板代码。比如“字符串切分转小写塞集合”这个组合模板,可以套用在至少五道以上历年真题里。

5.3 考场上最容易犯的三个低级错误

以我自己多年的参赛和辅导经验,CSP第二题考生最容易翻车的地方有三个。第一是读题太快,没注意到“忽略大小写”和“重复单词只算一次”这两个关键条件,导致输出结果完全不符合要求;第二是输入读取方式错误,用cin >>代替getline,遇到带空格的文本直接截断;第三是浮点输出格式不对,要么忘了转double,要么忘了输出百分号。这三个错误和算法难度无关,纯粹是细心程度的问题,只要在提交前逐条自查,完全可以避免。

我建议你在平时训练时就给自己列一个“提交前检查清单”,内容可以包括:是否用了getline读取整行、单词是否已转小写、是否用集合去重、并集公式是否写对、输出是否带了两位小数和百分号。考试时把这个清单过一遍,基本可以保证不丢冤枉分。

5.4 一句话总结这道题的精髓

其实这道题想训练的就是两件事:把描述性的自然语言翻译成精确的数据结构操作,以及用最简单可靠的代码把边界情况都照顾到。CSP认证不追求炫技,它只在乎你能不能写出稳定正确的代码。相似度计算作为一道典型的“入门级综合题”,非常适合用来检验你的基本功是否扎实。

如果你能把这道题的解法背下来并理解透彻,那么你对C++的getline、tolower、isalpha、unordered_set、printf格式化输出这几个常用工具应该已经有了清晰的把握,这些工具在后面更复杂的题目里都会继续用到。多看几道类似真题,多写几遍干净利落的代码,前两题的分数稳了,整场考试的心态也就稳了一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询