
如果你参加过几次天梯赛模拟赛对 L1-070《吃火锅》这道 15 分题应该不陌生。题目画风很轻松输入若干行聊天记录找出里面包含暗号chi1 huo3 guo1的行统计条数并输出第一次出现的位置没有就输出-1 -1。名字还带着一股火锅热气。但我想认真说一句每年都有不少人在这道看似送分的题上拿不到满分而且扣分原因往往不是不会写代码而是栽在字符串读取、子串查找和边界处理这些基本功上。如果你是正在备赛天梯赛的同学或者刚学完字符串处理想找题练手这篇文章正好对口。我会把题面里容易忽略的约定、三种主流语言的查找写法、完整参考代码以及我在模拟赛和带赛中见过的真实翻车现场都掰开揉碎讲一遍。1. 表面是找暗号实际考的是字符串基本功1.1 先把题面每个字读清楚按天梯赛练习集里的常见题面题目是这样描述的输入若干行聊天记录每行一句以单独一行.结束每行长度不超过 80。判断句子中是否包含子串chi1 huo3 guo1输出第一次出现的行号从 1 开始计数和包含该子串的句子条数如果一行都没有出现输出-1 -1。这里有几个词非常关键若干行题目没有告诉你总行数所以不能先读一个n再循环必须一直读到结束符为止。单独一行.这个结束行本身不是聊天记录不参与统计行号也不该算它。每行不超过 80 个字符这个限制是给你吃定心丸的意味着你不需要为性能发愁朴素查找完全够用。包含不是“等于”聊天记录可以是“今晚必须吃 chi1 huo3 guo1”只要暗号作为子串出现就算命中。条数一行里无论出现几次暗号只算一条记录。第一次出现行号最小的那一次。这些点看起来稀碎但每一条都对应一种扣分可能。很多人拿到题就开始写循环写到一半才发现cin读进来根本不是一行而是被空格拆开的碎片行号自然全乱。1.2 15 分题为什么还会有人丢分天梯赛 L1 的题目按分值递增15 分属于“大部分人都该 AC”的段位。但它和 5 分题的区别在于5 分题往往只考一个孤立知识点15 分题开始考复合能力。这道题至少把三件事叠在一起带空格的整行读取子串查找循环终止与状态记录。如果你只把“查找子串”想明白了忽略了整行读取代码一遇到空格就断片后面所有内容都会被拆成下一行统计结果自然对不上。还有一类同学是栽在输出顺序上题目要求先输出第一次出现的行号再输出条数有人写成“条数 行号”要求无匹配时输出一行-1 -1有人却输出两行。这些错误在样例上往往看不出来因为样例一般给的是能命中的场景你按自己的错误理解输出结果恰好和答案长得差不多一提交就 WA。所以我的建议是15 分题也要先花两分钟把题面过一遍把“结束符是什么”“行号从几开始”“输出顺序是什么”“无结果输出什么”这四个问题在纸上写清楚再动手写代码。2. 先聊清楚一行里到底要做什么判断2.1 为什么直接用现成的子串查找函数很多初学者看到“包含某个字符串”第一反应是手写两层循环外层枚举起始位置内层逐字符比较。不是不能写但真心没必要。原因有三个标准库的字符串查找是经过充分测试的正确性有保障字符串长度上限只有 80朴素查找和花哨查找的时间差完全可以忽略竞赛里每多写一行代码就多一个出 bug 的机会能调用库函数解决的问题不值得手工造轮子。但“直接用 find”有一个前提你真得懂find的返回值。在 C 里std::string::find找不到时返回的是std::string::npos一个等于size_t最大值的常量。判断是否找到的标准写法是pos ! string::npos。我见过太多人写if (line.find(pattern))以为返回非 0 就是找到。这个写法有个致命漏洞如果模式串出现在行首find返回 0if条件为假匹配就被漏掉了。换句话说暗号出现在行首反而被当成没出现这逻辑显然有问题。所以判断返回值时一定要和npos比较。2.2 三种主流语言的等价写法C 里这样判断if (line.find(chi1 huo3 guo1) ! string::npos) { // 命中 }Python 里更直观if chi1 huo3 guo1 in line: # 命中或者用line.find(...) ! -1。用in的语义和题目完全一致判断一个字符串是否是另一个字符串的子串。Java 里则是if (line.indexOf(chi1 huo3 guo1) ! -1) { // 命中 }三种语言本质相同就是一个子串包含判断命中后再做“计数 记录第一次行号”的操作。2.3 一个关于字符集的小庆幸chi1 huo3 guo1这个模式串里全是 ASCII 字符没有中文。别小看这一点它在 C 的字符串处理里算是运气极好。一旦涉及中文你就要额外考虑源文件编码、编译器对窄字符串的解释方式以及string::find按字节比较的问题。同一个“火锅”词在 UTF-8 下占 6 个字节在 GBK 下占 4 个字节换个环境运行结果可能就变了。这道题模式串纯 ASCII完全绕开了编码坑对新手友好得多。另外模式串里是带着空格的。也就是说你要找的是“chi1 huo3 guo1”这个完整的子串不是把三个词分开找。如果先把模式串按空格拆成三份再分别判断行里是否包含这三个词就会把类似chi1xxxxhuo3xxxxguo1的错误文本也判成命中与题目要求不符。3. 参考代码逐行拆解3.1 C 版完整实现#include iostream #include string using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(0); const string pattern chi1 huo3 guo1; string line; int lineNo 0; // 当前行号从 1 开始 int cnt 0; // 匹配的记录条数 int first -1; // 第一次出现的位置初始化为 -1 while (getline(cin, line)) { if (line .) break; // 结束符先判断 lineNo; // 有效聊天记录行号自增 if (line.find(pattern) ! string::npos) { cnt; if (first -1) { // 只在没有记录过时才更新 first lineNo; } } } if (cnt 0) { cout -1 -1\n; } else { cout first cnt \n; } return 0; }逐行说明一下关键点const string pattern ...把模式串定义成常量避免每个地方都手打一遍手一抖打错一个字符就全盘皆输。getline(cin, line)一次读一行包括空格。这里如果换成cin line遇到空格就会拆行输入直接被切碎。if (line .) break;必须先判断结束符再进行行号自增。否则.这一行会被算成第 n 行行号整体错乱。if (first -1)这个判断保证first只在第一次命中时被赋值后面即使还有匹配行也不会覆盖掉最早的记录。输出时注意顺序先first后cnt。3.2 Python 版完整实现import sys pattern chi1 huo3 guo1 line_no 0 cnt 0 first -1 for s in sys.stdin: s s.rstrip(\n) if s .: break line_no 1 if pattern in s: cnt 1 if first -1: first line_no if cnt 0: print(-1 -1) else: print(first, cnt)这里用的是sys.stdin而不是input()。原因有二一是sys.stdin按行迭代在输入规模不确定时更稳二是input()读到文件末尾会抛 EOFError处理起来多一层麻烦。在 PTA 上两者都可能过但养成用sys.stdin的习惯对以后做其他题更有帮助。还有一个小细节input()默认会去掉末尾换行而sys.stdin.readline()会保留换行符所以我用rstrip(\n)手动处理。空行读进来是空字符串它也是有效聊天记录照样占行号不能随便跳过。3.3 用一组边界用例验证逻辑写代码是一回事验证逻辑是另一回事。这道题我建议至少跑通下面几张表里的数据输入内容期望输出说明.-1 -1没有任何聊天记录hello\n.-1 -1有记录但没命中暗号chi1 huo3 guo1\n.1 1第一行命中共一条今天吃chi1 huo3 guo1吧\n再来一句\nchi1 huo3 guo1\n.1 2命中两行第一次出现在第 1 行\nchi1 huo3 guo1\n.2 1空行占一个行号第一行空行不命中第二行命中最后一行特别容易错。空行不是结束符它是内容为空的聊天记录照样占行号。如果代码里写了if (line \n) continue;之类的逻辑空行会被跳过行号就对不上了输出会变成1 1直接 WA。4. 实战中容易被扣分的四个细节4.1 getline 之前混用了 cin这个坑在综合题里很容易出现。比如你前面的题目要先读一个整数n做完后复制代码过来顺手保留了一个cin n没删。本地测试时可能没问题一提交就会出现灵异 WA。原因是cin n执行后换行符还残留在输入流里紧跟着的getline会先把这个残留换行符消费掉导致你读进来的第一行实际是空串后续所有行号整体偏移一位。解决办法很简单要么不要混用统一用getline读要么在切换时用cin.ignore()把残留字符清干净。这道题本身不提供n所以从一开始就用getline一路读到底是最干净的方案。4.2 把 find 的返回值当成布尔值这是 C 字符串查找里最经典的坑。line.find(pattern)返回 0 时表示模式串出现在行首但if判断为假返回npos时才是“没找到”。更离谱的写法是if (line.find(pattern) 0)因为find返回的是无符号的size_t类型永远大于等于 0这个条件恒真于是每一行都被判定为命中cnt无限累加。这种错误在本地很难发现因为你的测试数据可能恰好有匹配行输出看起来“第一行对上了”但第二个数不对。到 OJ 上就是 WA。记一条死规矩在 C 里判断find结果永远和string::npos比较。注意if (line.find(pattern))和if (line.find(pattern) 0)都是错误写法前者会漏掉行首匹配后者会误判所有行。4.3 行号统计慢了半拍有些同学写代码时把行号自增放在匹配判断之后if (line.find(pattern) ! string::npos) { cnt; if (first_line 0) first_line lineNo; } lineNo;如果第一行就命中此时lineNo还是 0first_line就被记成了 0后面即使更新也晚了输出可能变成“第 0 行”。而且结束符那一行也可能被统计进去。正确的顺序是先判断结束符再自增行号最后做匹配判断。顺序错了输出就会出现“第 0 行”这种诡异结果。4.4 一行出现多个暗号怎么算按题目语义——包含该子串的聊天记录条数——统计的是行数不是出现次数。一行里就算暗号出现一百次也只能让cnt加 1。所以我的代码里用的是if而不是while。有些同学为了保险写循环把一行里所有匹配位置全找出来每找到一个就cnt结果统计的是“出现总次数”而不是“记录条数”必然和答案不一致。如果题目真想统计总次数需要用类似while ((pos line.find(pattern, pos)) ! npos) { cnt; pos; }的写法但 L1-070 不需要。这两种口径的区别正是 15 分题最喜欢埋伏的地方。5. 从这 15 分题目延伸出去的东西5.1 如果暗号不止一个怎么办假设题目升级一下给定 m 个模式串查找哪些行命中了任意一个。如果 m 很小比如五六个写一个 for 循环逐个调用find就能解决。但如果 m 有上千个还逐个查找复杂度就是 行数 × 模式数 × 行长测试数据一大就会超时。这时候才需要考虑多模式匹配算法比如字典树配合失败指针的 AC 自动机。但要说明白这些进阶知识对 L1-070 来说完全用不上。竞赛题有个特点分值越低越希望你用最朴素的方案快速解决把时间和心态留给后面的复杂题。不要一看到“字符串匹配”四个字就上 AC 自动机那是杀鸡用牛刀。5.2 如果数据量大到不能全部读进内存很多初学者拿到这题习惯用一个数组把所有行先存下来最后再统一扫描。数据量小的时候没问题。但换一个场景每分钟产生几万行日志你要在持续流入的数据里统计某个固定关键字的出现次数和第一次出现位置就不能等全部读完再处理因为数据可能永远读不完。正确思路是逐行处理只保留必要状态行号、次数、第一次出现位置。这三个变量就够用了。这道题其实已经隐含了“在线处理”的结构代码里没有开数组存全部行本身就是流式处理的思想。5.3 竞赛之外的影子日志告警与内容扫描固定字符串匹配在工程里太常见了。比如服务端日志监控告警规则是“某行出现OutOfMemoryError就记录第一次出现的时间并累加告警次数”再比如用户昵称里的屏蔽词扫描判断一段文本是否包含指定关键词。这类需求的第一步往往就是这道题训练的能力按行读取、子串包含判断、记录第一条命中的上下文。所以别小看这 15 分。能把一道基础题做得干净利落比背一堆框架概念有用得多。5.4 带新手的一点个人体会我带学生刷天梯赛练习集时有个习惯每道 L1 题都当成一次小型工程任务来做。先写测试用例再写代码最后才提交。这道吃火锅题我要求学生至少把前面表格里那五组数据全部跑对了再交。原因很简单L1 题目覆盖的都是最基础的能力点这些点出错不是“不会”而是“不稳”。竞赛到最后拼的就是稳定输出。15 分的题丢分比 25 分的题丢分更可惜因为它本该是你从容拿下的地方。最后再分享一个我自己的检查习惯每次写完字符串匹配相关的题我会顺手确认三件事——模式串有没有打错、读入是不是按行读的、匹配判断是不是和npos比较。这三件事检查完这道题基本就不会再给你添堵了。火锅再香也得一口一口吃代码再简单也得一行一行看仔细。