自打开始刷程序设计竞赛的基础题,我就发现一个规律:越是分值看着不起眼的题,越爱在细节里埋坑。“L1-070 吃火锅 - 15 分”就是这么一道题。你说它难吧,核心逻辑就是个字符串匹配;你说它简单吧,我在练习时见过不少人在“输入终止条件”和“输出顺序”上翻车,白白丢分。这篇博文就把这道题从题目拆解到代码落地,再到底层原理和常见坑,完整捋一遍,希望对正在刷天梯赛基础题的朋友有帮助。
1. 题目分析与解题思路拆解
1.1 题目到底在问什么
先还原一下题目的真实场景。你是一个负责统计的人,面前是一堆聊天记录,每行一条消息。你的任务是检查这些消息里有没有提到“火锅”或者某个指定的菜品关键词。如果提到了,就算一条有效记录;最后要数一数总共有多少条有效记录,并且把第一条有效记录的行号输出出来。
听起来很直接,对吧?但题目里有一个很关键的前提:输入是一行一行给出的,直到遇到一个单独的英文句号“.”为止。也就是说,这个“.”不是消息内容,而是终止信号,读到它就必须停止处理,而且在统计结果里不能算进去。
还有一个隐藏的细节:题目要求的是“输出有效记录的总条数”和“第一条有效记录是在第几行”。注意,这里的“第几行”是从输入开始逐行数的,包括那些没有提到关键词的行,也包括最后那个终止符“.”所在的行吗?答案是:不把它算进去,因为读到“.”就结束循环了,不会继续往下数行号了。这个点在实现时要格外小心。
1.2 核心考点拆解
这道题是典型的基础字符串处理题,考察点可以拆成四个维度:
- 字符串子串匹配:你需要判断一行文本里是否包含目标关键词。
- 输入流的终止条件处理:什么时候停止读入,用什么标志位。
- 计数器与标志位的配合:既要统计总数,又要记录第一个命中项的位置。
- 边界输入的处理:比如空行、只含关键词的行、关键词出现在句子中间的行、完全没有关键词的输入。
很多人看到“吃火锅”这个标题就以为是模拟题,其实考察的核心是“在一个字符串序列里做条件筛选并统计”,这在很多真实业务场景里都会用到,比如日志筛选、关键词报警、热词统计等。所以别觉得这题只是竞赛玩具,它的思路是可以直接迁移的。
1.3 为什么选择这种考察方式
我个人的理解是,这类基础题的目的不是考你算法多精妙,而是考你有没有养成严谨的输入处理习惯。竞赛里有一类很典型的丢分方式:你算法写对了,但循环多读了一行,或者把终止符当成普通数据处理了,结果整个统计全部错位。这种错误恰恰是生产环境里最容易出问题的——接口返回了空值你没判、日志文件末尾多了个EOF标记你没处理、用户输入了终止指令你还在继续跑。
所以这道题的隐藏考点其实是“什么时候该停”,而不仅仅是“怎么匹配”。理解了这一点,你再看它就只有一层窗户纸了。
2. 字符串匹配方案选型与原理剖析
2.1 几种字符串匹配方案对比
判断一行字符串里是否包含另一个字符串,在主流编程语言里都有现成方法。以Python为例,最常见的是用in关键字;以C++为例,常用string::find。我们来对比一下几种方案的细节差异。
| 方案 | 实现方式 | 时间复杂度 | 适用场景 | 坑点 |
|---|---|---|---|---|
Pythonin | 底层调用快速搜索算法,通常是Boyer-Moore或类似优化 | 平均O(n),最坏O(n*m) | 大多数日常判断 | 无需手动处理,但别在循环里重复做重活 |
C++string::find | 通常实现为朴素匹配或针对短串优化的混合算法 | O(n*m)最坏,实际效率尚可 | 小众关键词匹配 | 返回值是string::npos,别写成== -1的硬比较 |
| 正则表达式 | 编译模式后匹配 | 匹配速度取决于模式复杂度 | 需要模式匹配如“火锅\d+” | 过度设计,小题大做 |
| 手动实现KMP | 构建部分匹配表,线性扫描 | O(n+m) | 关键词很长且需要大量复用 | 代码量大,题没必要用 |
对于“吃火锅”这道题,关键词是固定的、短的,输入量也不算大,用现成方案就够了。我见过有人为了追求极致效率在这道题上手写KMP,结果代码比题面还长,属实没必要。选型的核心原则是:在满足题目约束的前提下,用最简单、最不容易出错的方案。
2.2 为什么字符串匹配不能忽略大小写和空白
这个点很多人会忽略。题目里的聊天记录可能是用户随手发的,可能带空格、可能是英文单词、可能有大小写差异。如果关键词是“hotpot”,那“Hotpot”算不算命中?从自然语言处理的角度,应该算;但竞赛题如果不专门说明“忽略大小写”,那就严格按字面匹配来。
实操中我的建议是:先看一眼题目有没有提“不区分大小写”,没提就默认区分。至于空白字符,比如一行是“我想吃 火锅”,中间有空格,这种情况下包含关系依然成立,因为空格不影响“火锅”作为一个连续子串存在。但如果关键词恰好被拆成“火 锅”,那就匹配不到了,这是符合题意的。
2.3 匹配算法的效率在这里真不重要
再强调一次,这道题的分值只有15分,数据规模一般不会很大。按天梯赛L1级别的惯例,输入行数通常在小几十行以内,每行长度也就几十到一百字符。这个规模下,哪怕是O(n*m)的朴素匹配也就是微秒级的事,根本不需要什么高端优化。
与其纠结匹配算法,不如把精力花在输入读取出错的边界条件上。后面我会详细讲,这道题真正的分水岭在输入循环的终止条件,以及你对“行号”的定义方式上。
3. 完整实现与关键步骤复盘
3.1 Python参考实现
我先给一版可用的Python实现,再逐步拆解。
import sys def main(): keyword = "火锅" # 这里假设题目要求匹配的关键词是“火锅” total = 0 first_line = 0 line_no = 0 for line in sys.stdin: line = line.rstrip('\n') line_no += 1 if line == ".": break if keyword in line: total += 1 if first_line == 0: first_line = line_no if total == 0: print(0) else: print(total) print(first_line) if __name__ == "__main__": main()几个细节我解释一下:
rstrip('\n')是为了去掉每行末尾的换行符,避免判断line == "."时因为尾部有换行符而匹配失败。有些人用strip()也可以,但注意strip()会去掉行首行尾的所有空白字符,如果聊天记录里有一行内容前后有空格,strip()会改变内容。用rstrip('\n')更精确。line_no += 1放在判断终止符之前,是因为终止符所在的行也需要占用一个行号。不过因为我们遇到终止符就break了,所以这个行号不会被使用,实际上没有影响。first_line == 0用来标记“还没记录过第一个命中行”,因为行号从1开始,所以0可以作为未初始化的标志。这个技巧在竞赛代码里很常见,能省一个布尔变量。
3.2 C++参考实现
如果你用C++刷题,实现会稍微注意一下getline的用法。
#include <iostream> #include <string> int main() { std::string keyword = "\u706b\u9505"; // 火锅 std::string line; int total = 0; int first_line = 0; int line_no = 0; while (std::getline(std::cin, line)) { line_no++; if (line == ".") { break; } if (line.find(keyword) != std::string::npos) { total++; if (first_line == 0) { first_line = line_no; } } } if (total == 0) { std::cout << 0 << std::endl; } else { std::cout << total << std::endl; std::cout << first_line << std::endl; } return 0; }C++版本的核心判断是line.find(keyword) != std::string::npos。npos是string类里一个静态常量,表示“没有找到”。我看到有些初学者会写成line.find(keyword) >= 0,这在逻辑上是错的,因为find返回的是size_type类型,无符号,永远大于等于0。正确的判断就是和npos比较。
3.3 手动模拟一遍完整输入输出
光贴代码不够,我手动跑一组数据,直观展示程序的行为。
假设输入如下:
我想吃火锅 今天天气不错 海底捞的火锅真好吃 。逐行分析:
| 行号 | 内容 | 是否包含“火锅” | 累计total | first_line |
|---|---|---|---|---|
| 1 | 我想吃火锅 | 是 | 1 | 1 |
| 2 | 今天天气不错 | 否 | 1 | 1 |
| 3 | 海底捞的火锅真好吃 | 是 | 2 | 1 |
| 4 | . | 终止,break | - | - |
最后输出:
2 1再跑一组没有命中任何关键词的输入:
你好 再见 。输出就只有一个0。注意,不是输出两行,而是只输出一行0。这个输出规则也是题目明确要求的,别多输出。
4. 常见错误与调试记实录
4.1 错误一:终止符判断失败
这是这道题出现频率最高的错误。很多人读入一行后直接用line == "."判断,但读入的line尾部带着换行符,导致字符串是".\n",和"."不相等,于是终止条件永远不触发,程序把后面的行全读完才停,统计结果错得离谱。
这类问题用Python的input()函数时不会出现,因为input()会自动去掉尾部换行;但用sys.stdin或C++的getline时,就要特别注意。我的习惯是统一用rstrip('\n')或判断前.strip(),除非题目明确说行内可能有需要保留的空格。
4.2 错误二:输出格式不符合要求
题目要求的是“先输出总数,再输出第一条命中行的行号”,并且是在总数不为0的情况下。有些人习惯把两个结果都输出,即使总数是0也输出一个无效的行号。这属于没有仔细读题。如果总数是0,只输出一个0即可,多输出会被判格式错误。
顺便说一句,竞赛OJ的判题对空白字符很敏感。多一个空格、多一个空行都可能判Presentation Error,也就是格式错误。输出前最后检查一遍print的参数和换行。
4.3 错误三:行号计数范围搞错
有人会把“行号”定义成“关键词命中的第几条”,也就是命中第1条、第2条……然后输出命中的序号。这和题目要求的“输入中的行号”完全不是一回事。题目要的是“在全部输入中,命中关键词的第1行排在第几行”,所以计数的是输入的行序号,不是命中次数。
这个错误在样例数据不大时很难发现,因为很多样例恰好第一行就命中了,两个含义的结果都是1。建议自己构造一组数据测一下,比如第一行不命中、第二行命中,正确答案的first_line应该是2,如果程序输出1,就说明你统计错了。
4.4 我的排错流程心得
遇到这类题目报错,我一般按顺序排查:
- 先拿题目样例跑一遍,这是最基本的。
- 再自己构造几个极端的边界样例,比如空输入、第一行就是终止符、全部命中、全部不命中、关键词出现在行首、关键词出现在行尾。
- 检查输入终止逻辑,打印每个读入行和行号,确认循环何时退出。
- 检查输出逻辑,特别注意有无多余空格、空行、换行。
这套流程看起来简单,但能解决90%以上的基础题问题。很多人喜欢盯着算法想半天,其实错的往往是输入输出这种“低级”环节。
5. 从竞赛题到工程实践的思维迁移
5.1 关键词匹配机制的设计经验
这道题虽然只是一个“找关键词并计数”的小任务,但它背后对应的工程场景非常多。比如爬虫系统里要统计某个网页是否包含指定敏感词,日志系统里要筛选含有特定错误码的行,监控系统里要检测几个告警关键词在短期内的出现次数。
在这些场景里,你会发现“终止条件”往往会变成“超时时间”或“数据量上限”。比如说,你要统计一个持续流式输入的日志里,过去5分钟内出现了多少次“ERROR”。这时候不能用无限循环,得设定窗口,这和题目里“读到点号就停”的逻辑是同构的。
5.2 匹配方案的升级路径
如果将来要处理的数据量变大、关键词变多,你可以按这样升级方案:
- 单个短关键词、数据量小:直接用内置匹配。
- 单关键词、数据量大:用KMP或Boyer-Moore,甚至用SIMD指令加速。
- 多个关键词、数据量中等:用Trie树或多模式AC自动机,一次扫描完成多个关键词的匹配。
- 多个关键词、无固定集合:用正则表达式或外包给全文检索引擎。
我把这些路线列在下面,方便参考:
| 数据规模 | 关键词数量 | 推荐方案 | 理由 |
|---|---|---|---|
| 小(百行级) | 1 | 内置in/find | 代码简单,可读性强 |
| 大(百万行级) | 1 | KMP / BM | 线性复杂度,耗时可控 |
| 大 | 多(固定集合) | AC自动机 | 一次扫描匹配所有关键词 |
| 中大 | 多(动态变化) | 正则表达式或索引 | 灵活,可维护性好 |
在实际工程里,我一般先做性能预估,如果预估在可控范围内就直接用最简单的方案,只有撑不住了才上复杂算法。这道题考的就是这个判断力:你知道什么时候不用KMP,比知道怎么写KMP更重要。
5.3 个人调试小技巧
最后分享一个我刷基础题时常用的调试技巧:在代码里加一个调试开关,输出每次读入的行和当前的行号。
debug = True for line in sys.stdin: line = line.rstrip('\n') line_no += 1 if debug: print(f"DEBUG: line_no={line_no}, content={repr(line)}", file=sys.stderr) if line == ".": break ...把调试信息输出到stderr,这样不会污染OJ要求的stdout输出。本地测试时能看到完整流程,提交时把debug改成False或直接删掉即可。这个习惯帮我省了大量猜错的时间。
回头再看这道“吃火锅”题,它真正的价值不在于让你学会in或find,而在于让你体会“输入边界”的重要性。很多现实世界的数据处理任务,最后发现的bug都不是核心逻辑,而是“什么时候该停止”没想清楚。把这个习惯养好,你后面刷L2、L3的题会顺很多,写工程代码也会少踩很多坑。