PAT基础级L1-070这道吃火锅题,当年第一次在考场里看到题面,我是先愣了一下的。吃火锅也能出成编程题?等我把题面读完才反应过来,这其实是一道非常典型的字符串查找题——给你一段社交平台文本,统计多少条消息里包含了“chi1 huo3 guo1”这串经过数字和空格“加密”的吃火锅拼音,并输出第一条匹配消息的编号。整道题15分,代码量不大,但输入处理的细节没拿捏好,照样会丢分。这篇文章不是要把一道15分小题讲出花来,而是借着这道题,把PAT基础题背后的读题方法、多行输入处理、子串查找的基本操作,以及我在实际答题和带人刷题时反复踩过的坑一起捋一遍。如果你正准备考PAT,或者刚学编程还不知道怎么处理多行文本输入,这篇能帮你把这类“文本统计型”题目的套路摸清楚;如果你是刷题老手,也可以直接跳到第四节,那些排查经验是真的容易翻车。
1. 题目还原与核心考点拆解
1.1 原题场景到底长什么样
题目本身包装得很生活化:某火锅店老板想知道朋友圈里有多少条消息提到了“吃火锅”,但社交平台上刷到的消息并不是直接打出汉字,而是一串看起来很奇怪的“chi1 huo3 guo1”。你需要从标准输入一段文本,每行是一条消息,文本的结尾是单独成行的一个英文句点“.”。最后输出两行:第一行是包含关键词“chi1 huo3 guo1”的消息总数,如果总数大于0,第二行输出第一条包含该关键词的消息编号,编号从1开始;如果总数等于0,第二行输出题面给定的特殊占位符“-_#”。
很多初学者第一眼看到这个题会犯一个方向性错误:光盯着“火锅”两个字看,以为要找的是中文“火锅”或者“chi1 huo3 guo1”里面的某个词。实际上关键词是整串字符,中间的数字1、空格、字母一个都不能少。也就是说,你要统计的不是“哪条消息在说吃火锅”,而是“哪条消息完整包含这串固定字符串”。这其实是一个精确子串查找问题,不是分词问题,更不是语义判断问题。理清这一点,整个题目就成功了一半。
还有一个读题时需要捕捉的信息点:结尾的“.”这一行是结束标志,本身不参与统计,也不占行号。这个规则如果不仔细看,后面的边界处理会很别扭。PAT题库特别偏爱这类“用单独一行作为输入结束标志”的设定,所以一旦题面出现类似描述,直接建立条件反射:读入一行,先判断是不是结束符,再决定要不要处理。
1.2 15分到底考了哪些能力
这道题只有15分,在PAT基础级里属于“送分题”阵营。但送分题不代表没有陷阱,它考察的能力点其实非常集中:
- 多行文本的读取能力。很多新手只会用 cin 或者 input() 读固定数量的输入,遇到“读若干行直到某一行结束”这种非固定行数输入就直接懵了。
- 字符串精确匹配能力。知道用 find() 或 in 运算符去找子串,而不是自己写两层循环傻乎乎地比对。
- 状态维护能力。用两个变量分别记录“总数量”和“第一次出现的位置”,这是很多简单题的核心逻辑。
- 条件输出能力。根据总数是否为0切换不同的输出格式,属于最基本的控制流。
这四点没有一个涉及复杂算法,但任何一点做不到位都会导致拿不满分。从分值结构来看,出题人给的15分里,读入和输出格式大约占一半,真正的匹配逻辑只占另一半。我经常跟刷题的朋友说,PAT基础级考的不是你会不会KMP,而是你能不能把一件简单的事情做严谨。吃火锅就是最标准的例子。
2. 实现思路:从读题到动手的推理过程
2.1 先确认要维护的数据
拿到题目不要急着写代码,先在草稿纸上把状态变量列出来。这道题需要维护三个东西:
- 当前是第几行,用一个行号计数器 idx,初始为0。
- 已经匹配到的总数量 cnt,初始为0。
- 第一条匹配消息的编号 first,初始可以设为-1,因为行号从1开始,-1表示“还没找到过”。
为什么 first 要用 -1 而不是0?因为如果第一条有效消息正好是第1行,那 first 的值就是1,不是0。用0作为初始值的话,如果第一行就匹配,你没法区分“找到的第一条是第0行”和“还没找到”。用-1做哨兵值是最稳妥的。这个小设计在竞赛代码里非常常见,也建议新手养成用哨兵值标记“尚未出现”的习惯。
cnt 和 first 不是同一个东西:cnt 需要累加所有匹配的行,first 只在第一次匹配时被赋值。有人会想,我匹配到第一条之后直接输出行号不就行了?不行,因为你必须等输入全部结束、统计完总数以后才能统一输出两行结果,所以不能提前输出,只能把第一条的行号暂存到变量里。
2.2 逐行读入和终止判断的细节
这道题输入的行数不固定,每一行还可能包含空格,所以整行读入是唯一正确的姿势。C++里用 getline(cin, s),Python里用 sys.stdin 逐行遍历,Java里用 BufferedReader.readLine()。千万不要用 cin >> s 或者 scanf("%s"),因为它们是按空白符切分的,一行里有空格就会被拆成多个输入段,整个行号体系瞬间崩掉。
终止判断的代码位置也有讲究。正确流程是:读入一行,先判断这一行是不是单独一个“.”,如果是就直接 break,不再执行后续的行号累加和匹配判断;如果不是,再进行 idx++、字符串查找这些操作。这个先后顺序看起来微不足道,却是很多人丢分的第一个坑。如果先把 idx++ 放在判断之前,结束行本身也会占一个行号,后续所有行编号都会偏大1,输出结果自然错位。
Python 里有一个额外细节:sys.stdin 读到的字符串末尾是带着换行符 \n 的。所以判断结束符时不能直接写 line == ".",要先 line.rstrip('\n') 再比较,或者用 line.strip() 也可以。如果忘了去换行符,你就永远等不到那行句号,程序会一直读到 EOF 为止。
2.3 什么时候用 find,什么时候用正则
字符串匹配说起来简单,但不同场景要用的工具不一样。本题的关键词是一段固定字符串,没有任何通配符和可变部分,用标准库自带的子串查找函数就足够了。C++ 里是 s.find("chi1 huo3 guo1") != string::npos,Python 里是 "chi1 huo3 guo1" in line,Java 里是 line.contains(...),Go 里是 strings.Contains(line, ...)。
千万不要一上来就想着用正则表达式。有些同学学了正则以后,看到“文本中找特定模式”就觉得应该用正则,但在竞赛环境里正则引擎的构建和匹配开销比普通 find 慢得多,而且还需要处理转义、分隔符等问题,完全没必要。正则适合的是一类更复杂的需求,比如“匹配所有形如 chi 后跟一个数字的模式”或者“匹配位于句子边界的完整单词”,这类带规则的匹配才轮到正则上场。把简单问题用简单工具解决,是竞赛和工程里共通的判断力。
3. 代码实现与逐行解析
3.1 一个标准且简洁的 C++ 版本
下面这段是我在考场上比较推荐的写法,短小直接,不容易出错。
#include <iostream> #include <string> using namespace std; int main() { string s; int cnt = 0; int first = -1; int idx = 0; while (getline(cin, s)) { if (s == ".") break; // 结束行,直接退出 idx++; // 当前行的真实编号 if (s.find("chi1 huo3 guo1") != string::npos) { cnt++; if (first == -1) first = idx; // 只记录第一次出现 } } cout << cnt << "\n"; if (cnt == 0) { cout << "-_#" << "\n"; } else { cout << first << "\n"; } return 0; }逐行看:getline 负责整行读取,包括行内的空格;循环体一开始就检查是不是结束行;idx 只在有效消息行上递增;find 返回值不等于 npos 就说明找到了;first 用 -1 做哨兵,保证只记录第一次出现的位置。输出部分先输出总数,再根据总数是否为0决定输出占位符还是行号。整个逻辑闭环,没有任何多余操作。
这里提一个关于 getline 的细节:getline 读取成功会返回流对象本身,所以 while 循环条件里的 getline(cin, s) 会在文件结束或者读取失败时自动变成 false,退出循环。这意味着如果输入没有那个“.”结束行,程序也能在读到 EOF 后正常结束。在 PAT 环境下这个行为是安全的,不用担心死循环。
3.2 Python 实现的简洁优势
Python 版本的代码更短,但读入和换行符处理需要多留个心眼。
import sys cnt = 0 first = -1 idx = 0 for line in sys.stdin: line = line.rstrip('\n') if line == ".": break idx += 1 if "chi1 huo3 guo1" in line: cnt += 1 if first == -1: first = idx print(cnt) if cnt == 0: print("-_#") else: print(first)很多人第一次写 Python 版时,读入用的是 input()。input() 每次只读一行,倒也够用,而且会自动去掉末尾换行符。但如果输入量比较大,sys.stdin 的遍历效率更高,也更贴近“持续读入直到结束”这一语义。用 for line in sys.stdin 的时候,line 末尾有换行符,rstrip('\n') 把它去掉,这样判断结束行才能成立。有人图省事直接写 line.strip() == ".",也能用,但如果某条文本行首尾本身有空格,strip 会把有效空格也删掉,影响后续匹配判断。虽然本题关键词不会出现在行首行尾的额外空格里,但严谨一点还是只删换行符为好。
3.3 Java 和 Go 的实现思路
用 Java 写的话,核心是 BufferedReader 和 readLine。readLine 返回的字符串同样不带换行符,判断结束行时直接比较即可。匹配用 String 的 contains 方法。代码骨架大致是:
BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String line; int idx = 0, cnt = 0, first = -1; while ((line = br.readLine()) != null) { if (line.equals(".")) break; idx++; if (line.contains("chi1 huo3 guo1")) { cnt++; if (first == -1) first = idx; } }Go 版本用 bufio.Scanner,逐行 Scan,然后 Text() 拿到当前行,剩余变量逻辑完全一致。到这里你应该能发现,这道题的语言差异只体现在读入 API 的名称上,核心的算法思路是跨语言通用的。这就是我经常说的:刷题练的是思路,语言只是表达思路的工具。吃火锅这种题,只要思路正确,换任何一门主流语言都能在几分钟内写完。
4. 实操心得:我在这个题上踩过的坑
4.1 编号差一的惨案
先说这个最容易踩的坑:行号为什么差了1。错误代码通常是这样的:
while (getline(cin, s)) { idx++; if (s == ".") break; // 后续匹配 }表面看起来没问题,先给行号加1再判断结束行,但问题是“.”这一行也被算成了有效消息。假设输入总共有5行,第5行是“.”,那么循环结束时 idx 的最大值是5。如果第一条匹配的消息出现在第1行,输出倒是正常;但如果第一条匹配出现在最后一行之前,输出的编号就会比真实编号大1。更隐蔽的是,如果“.”这一行前面恰好有一条消息匹配,编号错误直接导致测试点WA,而且很难肉眼察觉。
正确做法就是把 idx++ 放到结束判断之后。这个顺序问题在上机考试中特别容易犯,因为人紧张的时候会下意识按“读一行计一行”的惯性来写。我建议在练习时就刻意养成固定顺序:先判断,后计数。
4.2 特殊输出“-_#”的严格性
当总数等于0时,第二行要输出“-#”,这个字符串第一次看到的人多少会愣一下——它长得像一个颜文字。恰恰是因为它太像表情符号了,很多人在考场上一紧张就打成“#-”或者“-#”。PAT 的评测器对输出是逐字符严格比对的,多一个空格、少一个下划线、顺序不对,全部判错。
怎么避免?我的经验是:把这类固定输出字符串单独从代码逻辑里抽出来,写成独立的常量或者直接放在输出语句里,不要靠记忆临时敲。交卷前再花十秒钟把输出格式的字符逐字核对一遍。这种特殊输出在PAT基础题里出现频率不低,比如某些题要求输出“N/A”或者“-1”,越是看起来像乱码的越是要警惕。
4.3 空行到底算不算一行
输入数据里可能出现空行,也就是只有换行符、没有任何可见字符的行。这条空行算不算一条消息?答案是要算的。它占一个行号,只是不包含关键词,不影响 cnt 和 first。如果用 getline 或 readLine 读入,空行会被正常读取,行号累计也对;但如果你用了 cin >> s 或者 Scanner 的 next() 这类按空白符分隔的读入方式,空行会被直接跳过,行号整个错乱。这也是我反复强调整行读入的原因。
顺便说一个希望能“骗过测试数据”的误区:有人以为空行可以忽略,理由是题面说“每行是一条消息”,空行不是消息。但评测数据的生成往往不会考虑这么细致,它会原样构造文本,空行就是文本的一部分。按“所有非结束行都参与行号累计”的规则处理,一定是对的。
4.4 关键词内部的空格不能乱动
“chi1 huo3 guo1”看起来像是被空格分成了三段,于是有同学想用 split 把每行按空格切开,然后逐个单词比较。这个思路能走通,但非常麻烦:你不得不处理连续多个空格、行首行尾空格、单词与单词之间多余空格等情况。而且如果关键词中间的某个空格在原始文本里被替换成了其他空白符,split 方案还要做额外归一化。
直接整串 find 的好处是:它只关心这串字符是不是连续出现在这一行中,其他什么都不管。不管这一行开头有没有空格,不管关键词两边是什么符号,只要连续出现“chi1 huo3 guo1”这14个字符(含空格和数字),就是一个有效匹配。这就是精确子串匹配的威力。工程上经常有人说“能用现成库函数解决的就不要自己造轮子”,竞赛也是一样。
4.5 数据规模和运行时间
原题中每行消息长度不超过80个字符,行数虽然可能不少,但整体规模并不大。字符串 find 在最坏情况下的时间复杂度是 O(n*m),其中 n 是文本长度,m 是关键词长度。对于80个字符的行和14个字符的关键词,这个开销几乎可以忽略。所以这道题完全没有必要上 KMP 或 Sunday 之类的优化算法,那属于杀鸡用牛刀。
不过这个点倒是可以顺便延伸一下:如果有一天你需要在上万行日志里快速统计大量关键词的出现位置,那 find 就真不够用了,更合理的选择是前缀树加 AC 自动机,把模式匹配的时间复杂度压到文本长度的线性级别。从15分的吃火锅到工业级的关键词过滤,模型是同一个,只是规模变了。这也是我为什么说基础题有基础题的价值,它把简单场景下的逻辑讲透了,复杂场景只是在这个逻辑上做性能和扩展性的升级。
5. 从这道基础题延伸开:PAT L1 的套路与策略
5.1 L1部分到底在考察什么
PAT 的基础级 L1 题目,整体难度都不高,考的不是算法智力,而是“准确理解题意 + 严谨处理边界 + 熟练使用基础库”这三件事。题面往往用生活化故事包装一个简单操作,吃火锅是典型例子,类似的还有输出 GPLT、6翻了、胎压监测等等。
这些题目有一个共同的出题偏好:不直接告诉你“统计包含某子串的行”,而是包装成火锅店、朋友圈、传感器这些场景。你要做的第一步永远是剥掉包装,找到真正的操作对象。有时候光这一点就能淘汰一批人——不是不会写代码,而是读不懂题。我的建议是拿到题先圈出三个东西:输入是什么、要算的是什么、输出格式是什么。把这三个问题的答案写在草稿纸上再动笔,准确率会高很多。
5.2 考试时间分配和拿分策略
PAT 考试按测试点给分,不是按AC先后排名,所以最划算的策略永远是先把能拿的分稳稳装进口袋。像吃火锅这种15分基础题,读题加写代码加调试,正常水平10分钟内应该完成。如果一道简单题卡了20分钟还没过,赶紧换下一题,回头再来看,避免在送分题上透支时间。
我个人的做题顺序是:先把所有题目通读一遍,标记出明显简单的题,优先做掉;再做中档题;最后剩余时间攻难题的部分测试点。这个顺序能保证你在考试结束前手里已经捏着大量确定的分,后面做难题时心态也会稳很多。别小看心态,实际考试里因为一道题卡住导致后面简单题都没写的人大有人在。
5.3 这类题在真实开发中的影子
吃火锅这道题的模型,放到真实开发里对应场景可太多了。运营后台统计包含特定关键词的评论条数,日志系统扫描某一错误码第一次出现的位置,数据清洗时定位异常样本首次出现在哪一行,内容安全系统过滤敏感词并报告命中数量……这些需求本质上都是“在文本流中查找固定子串并维护统计状态”。
所以别因为题目简单就觉得刷了没用。写业务代码和写竞赛代码的区别只在于工程约束更多、数据量更大,但核心的“扫描文本、判断匹配、维护状态”能力是一脉相承的。我后来带团队做日志告警功能时,第一个版本的核心循环就跟这道题的思路一模一样:逐行读、判断关键字、记录第一次命中位置和总次数。能把自己的竞赛直觉迁移到工程实践上,这才算是真正刷懂了这类题。
最后分享一点个人习惯
我现在带新人入门刷题,还经常拿这道吃火锅当第一道练习。原因很简单:它够小、够具体,能把多行输入、字符串查找、计数、特殊输出这些最基础的操作全部串起来,又不会让零基础的人一上来就崩溃。最后再分享一个小技巧:刷 PAT 基础题,一定要自己手动构造边界数据去测。比如空行出现在中间、结束行前一行正好有匹配、第一行就匹配、全部不匹配、匹配行紧挨着连续出现——把这几种情况跑一遍,吃火锅这种题基本就是稳的。边界数据测出来的问题,往往比主流程跑出来的问题值钱得多。