刷 UVa 的老题,就像翻一本老程序员留下的笔记本:题面短、描述怪,可一旦弄明白,背后往往就是最经典的模型。UVa 10580 的 Ransom Note 就是这样一道题,它问的是:你手上有一份报纸的文本,能不能用报纸里出现的字母,原封不动地拼出一封勒索信?如果某个字母在信里出现 3 次,而报纸里只有 2 次,那这封信就写不成。这个“够不够”的检查,听起来简单,真正写起来却有一堆输入输出和边界条件的门道,也是很多新手在 OJ 上反复 WA(Wrong Answer)的根源。
我当年第一次做这题时,顺着“字符串”三个字去琢磨 KMP、哈希匹配,结果绕了一大圈,最后发现核心连字符串比较都用不上。这题非常适合入门级竞赛选手拿来练习“把问题抽象成数据结构模型”的能力,也适合准备校招笔试的同学,因为它其实就是面试里常考的“赎金信”问题。下面我把完整的拆解思路、实现细节和踩坑经验整理出来,希望对你有实际帮助。
1. 题面拆解:这不是字符串查找,而是资源配给
1.1 题意中的关键词决定了解法
先仔细看题面的关键词:给一段报纸文字,给一段勒索信文字,问能不能用报纸里的字母拼出信的字母。注意,这里说的是“拼出”,不是“按顺序找出”。意思是,报纸里的字符可以被随意打乱、重新排列,不需要保持原来的顺序。
这一点直接排除了大部分“子序列”、“子串匹配”的思路。如果题目要求“信里的单词必须按顺序在报纸原文中出现”,那我们要研究的是字符串查找;但这里说的是“拼”,那就等于把报纸拆成一个个字符,自由取用,取完为止。换个生活化的说法:你有一抽屉混在一起的字母磁贴,题目问你能不能用这些磁贴贴出勒索信上的全部单词。磁贴不看顺序,只看每个字母有多少个。
因此,这道题的真正问题是:报纸里每个字母的数量,是否都不小于勒索信里对应字母的数量?这就是一个典型的资源配给问题。把字符串当成“字符多重集合”来思考,方向立刻就对了。
1.2 从“字母够不够”到多重集合比较
计算机科学里有一个概念叫多重集合,简单说就是“允许重复元素的集合”。普通的集合只关心元素在不在,而多重集合还关心元素出现了多少次。字符串天然就是字符的多重集合:"hello"包含了 1 个 h、1 个 e、2 个 l、1 个 o。
判断“报纸能不能拼出勒索信”,等价于判断“报纸这个多重集合是否包含勒索信这个多重集合”。包含的定义是:对每一个字符 c,报纸中 c 的出现次数 ≥ 勒索信中 c 的出现次数。
所以解法非常直白:统计两端文本的字符频率,然后逐字母比较。只要有一个字母,报纸里的数量小于信里的需要量,答案就是不能。这比“逐字符扫描”、“回溯搜索”要高效得多,因为比较的对象不是文本本身,而是两组长度为字符集大小的计数数组。
1.3 为什么排序和哈希表不是最优解
有的同学可能会想:干脆把报纸和信各自排序,然后从左往右比,不也能判断吗?从结果看确实可以,但成本不一样。假设勒索信长度为 n,报纸长度为 m,排序的时间复杂度是 O(m log m + n log n),而一次频率统计是 O(m + n)。在竞赛题目里,m 和 n 都可能到几十万甚至上百万,排序带来的常数开销和内存移动都不划算。
同样,用哈希表统计频率也可以,但哈希表有哈希计算、冲突处理的开销,而且对只有 26 个或 256 个可能字符的场景来说,数组是碾压级的最优解。用数组,统计和查询都是 O(1) 的直接索引,整个程序的耗时基本就看输入输出的速度了。这也是为什么老练的 OJ 选手一看这题就直接开int cnt[26],而不是map<char, int>。
2. 输入处理:老 OJ 题最常见的失分点
2.1 用流提取还是逐行读入
UVa 早期题目一个很大的特点是:输入格式描述往往很随意,不会像现代 OJ 给得那么精确。Ransom Note 这类题,文本中会出现空格,而且很可能一篇文章就是一行超长内容。如果你用cin >> note去读,空格作为分隔符会让你的字符串被拦腰截断,后半段全丢,统计结果必然出错。
正确的做法是逐行读取。C++ 用getline(cin, s),Java 用BufferedReader.readLine(),Python 用sys.stdin.readline()。千万别用默认的空格分隔输入方式处理完整文本,这是做这类“全文处理”题目最基本的自觉。
有个细节:输入可能不止一组测试数据,而是不断给出“一行信、一行报纸”直到 EOF。这种情况下,读取节奏要设计成“先读一行,成功后再读下一行”。如果只读一行就开始统计,遇到最后一行后残留的 EOF 状态,容易被循环条件带偏。
2.2 大小写、标点与不可见字符
题面如果不说大小写是否敏感,默认最稳妥的做法是统一小写。勒索信和报纸都是新闻类文本,出现大写和小写都很正常,但字母无论大小写,本质上是同一个字母,所以统计时统一做tolower()转换。
标点和空格要不要统计?从题意上说,“用报纸里的字母拼出勒索信”,那空格和标点本来就可以忽略不计。你不可能从报纸里“取”一个空格来贴到信里,因为空格不是字母;但反过来,信里的空格也不需要你从报纸里取,它只是排版间隔。因此,最符合题意的实现是:只统计字母,忽略数字、标点、空白字符。
当然,实际实现前最好瞄一眼题面样例,看它有没有把大小写和标点算进去。UVa 的题目资料有时会在描述中直接写明“忽略大小写和标点”,按说明处理即可。如果没有明确说明,用“只统计字母,统一转小写”的策略通常不会出错。
2.3 多组数据的读取节奏
老题还有一个特征:可能不止一组输入。我的习惯是写一个while (getline(cin, note))的外层循环,然后在循环体里再读一次getline(cin, newspaper),如果第二次读取失败就跳出。这个写法的好处是:不会因为前一组数据读完后残留的换行符导致后一组数据错位。
还有更极端的输入格式,比如用单独一行包含 “END” 之类的标记表示结束。遇到这种变体,外层循环里需要多一层判断:读到的内容如果是结束标记,break。我在本地测试时,一般会准备一个带空行的文件,故意测试程序在空行面前的表现,因为 OJ 测试数据里偶尔会混入空行,不处理好的话,空行会被当成一组数据,进而导致整个测试点失败。
3. 核心实现:频率统计的完整代码
3.1 整体流程与复杂度
实现流程分成四步:
- 读入勒索信文本到字符串
note。 - 读入报纸文本到字符串
newspaper。 - 分别统计
note和newspaper中 26 个小写字母的出现次数。 - 从 a 到 z 检查每组计数,只要
need[i] > have[i],直接输出不能。
复杂度是 O(n + m + 26),其中 n 是信的长度,m 是报纸的长度。最终的比较步骤固定只有 26 次,所以整个程序的实际瓶颈是输入读取和字符遍历。如果输入总长度在几十万字符以内,这个方案几乎是瞬间完成。
3.2 C++17 参考代码(逐段注释)
下面是我常用的 C++17 实现,稳定性和可读性兼顾。
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); string note, newspaper; // 外层循环:尝试读一行作为勒索信 while (getline(cin, note)) { // 如果紧接着读不到报纸文本,说明输入已经结束 if (!getline(cin, newspaper)) break; // need 记录勒索信需要的字母数,have 记录报纸提供的字母数 int need[26] = {0}; int have[26] = {0}; // 统计勒索信:只统计字母,统一转小写 for (char c : note) { if (isalpha(c)) { need[tolower(c) - 'a']++; } } // 统计报纸:同样只统计字母 for (char c : newspaper) { if (isalpha(c)) { have[tolower(c) - 'a']++; } } bool ok = true; // 逐字母比较,任意一个字母不满足就失败 for (int i = 0; i < 26; ++i) { if (need[i] > have[i]) { ok = false; break; } } cout << (ok ? "Yes" : "No") << '\n'; } return 0; }这里有几个细节值得多说两句。
第一,while (getline(cin, note))这个条件本身就隐含了“只要还能读取一行就继续”的逻辑,配合内部的if (!getline(...)) break;,能完整覆盖“逐对读取直到 EOF”的场景。
第二,isalpha(c)判断要注意char的符号问题。如果某个字符的 ASCII 码大于 127,直接传给isalpha会有未定义行为风险,稳妥的做法是先把c转成unsigned char再判断。不过在这个题目场景下,输入基本都是 ASCII 可见字符,风险较低,但我在写通用模板时会养成习惯。
第三,tolower(c) - 'a'的写法假设字符编码里小写字母连续排列,这在 ASCII 环境里是成立的。如果你用 UTF-8 编码处理中文字符,这个假设就不成立,所以代码里用isalpha先过滤掉非英文字母,也就是提前规避了这个问题。
3.3 Python 快速验证版
本地调试时,我反而更爱用 Python 快速验证思路,因为写起来快,逻辑一眼就能看穿。
import sys def can_build(note, newspaper): cnt_note = [0] * 26 cnt_news = [0] * 26 for c in note: if c.isalpha(): cnt_note[ord(c.lower()) - ord('a')] += 1 for c in newspaper: if c.isalpha(): cnt_news[ord(c.lower()) - ord('a')] += 1 return all(cnt_note[i] <= cnt_news[i] for i in range(26)) def main(): lines = [line.rstrip('\n') for line in sys.stdin] i = 0 while i < len(lines): note = lines[i] if i + 1 >= len(lines): break newspaper = lines[i + 1] print("Yes" if can_build(note, newspaper) else "No") i += 2 if __name__ == "__main__": main()Python 版本适合用来和 C++ 版本做对拍。先在本地生成随机字符串,分别用两个程序跑,再对比输出,能快速发现边界逻辑是否一致。正式提交到 UVa 时,我还是建议用 C++,因为 UVa 的老评测机对 Python 的反馈速度有时候不够理想。
4. 现场排查:我踩过的坑和处理方案
4.1 CRLF 与空行带来的诡异输出
有段时间我在自己电脑上测试样例,结果怎么都对,一提交就是 WA。后来用十六进制编辑器看测试文件才发现,Windows 环境下文件换行是\r\n,getline会把结尾的\r也读进字符串里。这道题只统计字母,\r不会影响计数,所以最终没出错;但如果题目的变体要求“每个可见字符都算”,那\r就会变成一个多余字符,比较结果立刻崩掉。
处理办法很简单:读取每一行后,调用s.erase(remove(s.begin(), s.end(), '\r'), s.end());把回车符删掉。至于空行,它会被读成一个空字符串,字母计数全为 0。如果勒索信是空行,那答案天然是 Yes,因为不需要任何字母。如果报纸是空行,但信里有字母,答案就是 No。这个逻辑可以自动处理,不必额外写分支。
4.2 字符索引越界和符号问题
我见过很多新手写cnt[c - 'a']++,然后输入里混入大写字母时,c - 'a'变成了负数,数组越界,程序出现了不可名状的错误。要解决这个问题,统计前必须先统一大小写,或者用一个更大范围的数组,比如int cnt[128],把所有 ASCII 字符都统计进去,再只比较字母部分。
这里提醒一句:如果你的统计维度是“所有可见字符”,那空格、标点也要算在里面。大多数变体不会这样处理,因为勒索信和报纸的排版不一样,空格数量天然对不上。所以我还是坚持“只统计字母、统一转小写”的原则,这也是此类题目的默认公共约定。
4.3 数组复用与初始化遗漏
如果你把need和have数组定义在while循环外面,就必须在每组数据开始前用memset或fill清零。否则上一组的统计数据会残留,导致误判。最不容易出错的写法是把数组定义在循环体内部,每次循环都是一个全新的局部数组,自动初始化为 0,省掉手动清零的步骤。
C++ 里int need[26] = {0};只对前 26 个元素显式初始化为 0,但实际效果是整个数组都归零。如果你改成int need[26];而不赋初值,那就完全依赖编译器行为,内存里可能是脏数据,这种错误最难排查,因为它只在特定数据量下随机出现。
4.4 输出格式与大小写细节
UVa 判题是极其死板的。题目要求输出 Yes,你写 YES,判 WA;要求大写开头、小写结尾,多一个空格、少一个换行,照样 WA。我习惯在写完核心逻辑后,专门回头检查输出语句的每一个字符。这道题的典型输出是Yes和No,首字母大写,后面小写,每行一组答案。
如果实在不确定,可以看样例输出的精确形态。样例里给出的输出格式就是 OJ 接受的格式。不要想当然地按自己喜好美化输出,OJ 不关心你写得好不好看,只关心字符串是否一字不差。
5. 一个模型,多条延伸的路
5.1 变体:按单词拆分
有时候题目会把“字符”变成“单词”。例如,给一堆报纸单词卡片,每张卡片上是一个完整的单词,问能不能用这些卡片拼出勒索信的全部单词。核心思路仍然是频率统计,只不过统计单位从字母变成字符串。需要用一个哈希表或字典维护“单词出现次数”,然后逐个检查。
这时哈希表的优势就体现出来了,因为单词的数量没有固定上限,用数组并不现实。但如果你用的是 C++,unordered_map<string, int>会比map<string, int>更快,因为它基于哈希而不是红黑树。
5.2 变体:多组询问与前缀计数
如果报纸只有一份,但要回答大量“能不能拼出某封信”的询问,每次都重新统计整份报纸就太浪费了。一个优化是:先预处理报纸里每个字母出现次数的前缀和,询问时快速得到某个子区间内各字母的数量,再比较。这种“预处理 + O(1) 查询”的思路,在字符串算法里很通用。
具体做法是维护一个二维数组prefix[pos][26],其中prefix[i][j]表示报纸前 i 个字符里字母 j 出现了多少次。这样任意子区间[l, r]的字母频率都能通过prefix[r] - prefix[l - 1]算出来。代价是 O(m × 26) 的空间,换取每次查询 O(26) 的时间。如果字符集扩大到 256,空间就会明显上涨,需要权衡。
5.3 变体:带代价的拼凑问题
更进阶的版本会给每个字符一个“代价”,比如从报纸里取一个字母 a 要 1 块钱,取 b 要 3 块钱,问能否在预算内拼出勒索信,甚至问最小花费是多少。这就不再是简单的“够不够”,而是一个带约束的资源分配问题,往往要用动态规划或贪心策略处理。
这种变体再次印证了一个观点:很多看似复杂的压轴题,底层都是最基础的“频率统计 + 资源比较”模型。把 UVa 10580 吃透,等于给这类问题打下了一个扎实的思维底座,以后再遇到“能否组成”、“最少需要多少”之类的字眼,第一反应就会是计数和比较,而不是盲目搜索。
5.4 后续可以这样练
如果你想把这类题刷到烂熟于心,我建议做三件事。第一,自己在本地写一个随机数据生成器,生成各种长度、各种大小写混排、夹杂标点和空行的输入,用参考程序对拍。第二,把输入改成流式处理,一次只读一个字符,不存整个字符串,体验一下内存受限时的写法。第三,把这题用 Java、Python、C 各写一遍,对比语言特性对实现方式的影响。
遇到老 OJ 题,心态上要“老实”。不要觉得代码短就掉以轻心,UVa 的坑往往藏在题目描述的那一两句模糊话里。Ransom Note 这道题真正教会我的不是怎么统计字母,而是如何把一个看似字符串的题目,精准转化成一个频率数组的比较问题。有了这个思维,以后再面对杂乱的文本匹配问题,至少不会被“必须在原文中找到完整句子”这种错觉带偏。
最后分享一个我自己的习惯:提交之前,一定先在本地跑一遍样例,并把输出重定向到文件,用diff和标准答案比对。很多人 WA 之后第一反应是去改算法,其实算法早就是对的,输出来自输入处理或者输出格式。UVa 10580 这种题目,代码量不到五十行,最值得花时间的不是代码本身,而是对题面和输入输出细节的精读。把这一步做扎实,WA 的概率会直线下降。