刚把 PTA 第 7 章这道“词频统计 (30 分)”刷完,想写点东西记录一下。这道题在 Python 章节里算是个小 boss,分值给到 30 分,说明它不只是让你写个Counter就完事,而是把字符串处理、字典操作、排序规则、格式输出这几个基础能力一次性串起来考。我印象里不少同学在这道题上反复提交,卡分卡得最狠的往往不是算法,而是对题意的理解和输出格式的细节。这篇就按我的解题复盘来写,从题目解读、思路设计、代码实现到避坑经验,一条线捋下来,希望能帮到正在刷题的你。
1. 题目到底在考什么
1.1 第一眼看上去是词频,实际上是“文本清洗 + 字典 + 多关键字排序”
很多人看到“词频统计”四个字,第一反应就是正则表达式、split()提取单词、Counter数数,最多三五行代码搞定。但 PTA 这道题能设 30 分,说明它的输入不是一个干干净净的英文句子,而是带各种标点、大小写混杂、甚至跨多行的文本块。你真正要处理的不只是“多少个单词”,而是“哪些算单词、哪些算分隔符、统计完之后怎么排序输出”。
我拆解下来,核心考点有四个:
- 单词的切分规则:标点符号不能算进单词里,但标点旁边挨着的字母要正常提取。
- 大小写归一化:统计前必须统一转成小写,不然 The 和 the 会被当成两个单词。
- 字典计数:用字典(或
defaultdict)来累积频次,这是最自然的存储结构。 - 排序规则:先按频次降序,频次相同再按单词字典序升序,这是个双关键字排序。
这四个点拆开看都不难,但合成一道题并且要求输出格式与评测完全一致时,细节就会开始咬人。我的经验是,30 分题大多数不是考你的智商,而是考你的耐心和审题能力。
1.2 输入输出细节决定成败
这道题的细节主要在输入读取和输出格式上。
输入侧,通常是一段英文文章,可能换行,可能有空行,最后有一行特定的结束标志(PTA 常见的是!!!!!)。你读入的时候不能把结束标志也当成文本内容去统计。如果你用while循环input()逐行读取,读到结束标志就break,这个逻辑本身不难,但难在你要把多行文本拼成一个大字符串再统一做处理,而不是一行一行分别处理完就完了——因为单词可能被换行截断,比如说上一行末尾是 “in- ”,下一行开头是 “dependent”,按原文它应该是一个完整的词。当然,PTA 这道题里一般的设定是单词不会跨行截断,但你不拼接就处理,后面遇到类似问题时会养成坏习惯。
输出侧,常见要求是先输出“文章中的总单词数”和“不同单词的种类数”,一般格式是总单词数 不同单词数,然后按频次降序列出出现次数最多的前若干个单词(题目会给一个阈值,比如输出前 10% 或前 N 个),每行格式类似单词:次数或单词 次数。这一步的坑在于:单词排序时第二关键字必须是字典序升序,也就是 a 排在 b 前面;如果你只按次数排序,相同频次的单词顺序不稳定,评测就会判错。
我这里的描述是基于 PTA 这类题目的常见版本,你实际遇到的题面细节可能略有差异。但我建议把这几个信息点作为“审题检查清单”:结束标志是什么、输出是频率降序还是字典序优先、输出列表有没有数量限制、单词定义是否包含数字、连字符算不算单词的一部分,每个点都确认过再动手写代码。
2. 解题方案的选型:为什么普通方法就是对的
2.1 不要一上来就正则,先想清楚需求再选工具
拿到文本统计需求,大家会想到两条技术路线:
- 路线 A:
re.findall(r'[a-zA-Z]+', text.lower())直接抽出所有字母串,一行代码完成分词。 - 路线 B:遍历字符串,把字母逐个拼成单词,碰到非字母就截断,手动完成分词。
这两条路线都可以做,但选哪条取决于你对题面中“单词”的定义。如果题里明确说“单词由连续字母组成”,数字和标点一律视为分隔符,那么正则的一行方案非常优雅,而且实测速度不差。但如果题面把连字符、撇号内的字母串也算作一个单词(比如don't算一个词、well-known算一个词),那么单纯按非字母分割就会把词拆碎,这时候反而要手动处理。
我自己的做题顺序是:先看题面有没有给“单词”的明确定义,给了就直接按定义写;没给就默认 PTA 最常见的规则——连续的英文字母构成一个单词,大小写不敏感,其他字符都是分隔符。在这个默认规则下,用正则[a-zA-Z]+不仅能正确分词,还天然处理了it's这类含撇号的词——它会被分成 it 和 s。
不过我这里要提醒一句:用正则是为了简化实现,但你必须清楚它正在背后做什么。如果你自己都说不清[a-zA-Z]+会匹配什么,那这道题你并没有真正吃透。我建议正则方案只作为实现技巧,思维上还是要能手工写出逐字符扫描的版本。
2.2 字典、defaultdict 还是 Counter?
统计频次的时候,Python 有至少三种方式:
- 原生 dict + 手动判断:
defaultdict(int),直接freq[word] += 1;collections.Counter,构造后一行most_common()。
就这道题而言,三个方案都能拿满分,但踩过坑之后我更推荐defaultdict(int)或普通 dict。原因有两个。第一,Counter 的most_common(n)返回的排序是“按频次降序,同频次序由首次插入顺序决定”,它并不能直接实现“频次相同按字典序升序”的双关键字排序,你拿到结果后还是得重新sorted()。既然都要重新排序,Counter 的便捷反而体现不出来。第二,有些 PTA 版本会要求输出全部单词而不是 top N,这时候 Counter 的大招也没多大用,反而让你少思考了一步排序规则。
普通 dict 的手动判断其实也很清晰:
freq = {} for word in words: freq[word] = freq.get(word, 0) + 1这段代码没有任何魔法,适合新手理解“统计”的本质。我更喜欢defaultdict(int)的原因是少写一个.get(),代码可读性更好一些,而且对新手来说更容易把注意力放在后面的排序上。但本质都一样,选哪个不算决策点,真正算决策点的是排序方法的选择。
2.3 排序:单关键字永远不够,要用 tuple 做复合排序
排序是这道题真正的灵魂。按次数降序、次数相同按字典序升序,这个描述如果翻译成 Python,就是:
sorted(freq.items(), key=lambda x: (-x[1], x[0]))原理很简单:key返回的是一个二元组,Python 会先比较第一个元素-x[1],第一个元素相同再比较第二个元素x[0]。把次数取负数,是为了把“降序”变成“升序”,因为sorted()默认是升序。x[0]是单词本身,字符串之间直接比较大小就是字典序。
我反复提醒过自己:字典序排序是直接对字符串对象比大小,不要手动去转 ASCII,也不要觉得a在A前面就担心大小写问题——因为统计前已经统一转小写了,这里的字符串全是干净的纯小写字母串,直接比就行。
如果你不想用负数技巧,也可以分两步走:
items = sorted(freq.items()) items.sort(key=lambda x: x[1], reverse=True)先按单词字典序升序排一遍,再按频次降序排一遍。因为 Python 的sort是稳定排序,第二遍排序不会打乱第一遍排序形成的字典序关系。这个写法对初学者来说更好理解,而且完全正确。
3. 代码实现:从零搭一个能拿满分的版本
3.1 框架先搭出来,不要一上来就堆逻辑
我写代码的习惯是先搭骨架,把输入、处理、输出三个大块分开,每个大块里再填逻辑。这道题我的骨架大概长这样:
import sys def main(): lines = [] while True: line = sys.stdin.readline() if not line: break line = line.strip() if line == '!!!!!': break lines.append(line) text = ' '.join(lines).lower() words = extract_words(text) freq = count_words(words) result = sort_words(freq) output(result) if __name__ == '__main__': main()先别急着写extract_words和count_words,把程序分块后,你脑子里会清楚很多:哪一步出了问题,直接定位到那个函数调试就行,不用整个代码从头看到尾。这里的sys.stdin.readline()用法和input()等价,但用sys.stdin在数据量大一点时性能更好,我习惯直接用。
3.2 输入读取要小心结束标志混进来的问题
输入读取看起来简单,最容易出问题的是你拼接文本时把结束标志也拼进去了。上面代码里我已经在拼接前做了判断,这是最基本的防错。但还有一个隐藏坑:如果题目允许文本中间有空行,strip()后空字符串''会被当成空行加进lines里,导致后续join出现多余空格。这个其实不影响分词,因为多余空格也是分隔符,但你要理解自己在干什么,别让空行干扰你对输入结构的判断。
我见过一种写法是每读一行就当场处理这一行,而不是先存起来再统一处理:
res = [] while True: s = input() if s == '!!!!!': break res.extend(re.findall(r'[a-zA-Z]+', s.lower()))这种写法在“单词不会跨行截断”的前提下是成立的,而且省一次拼接,逻辑更紧凑。但坏处是你看不到全貌,如果哪天遇到跨行截断的规则你就得重写。我个人的倾向是:先拼接再处理,因为这样最贴近“文本处理”的语义,以后扩展也更稳。
3.3 分词模块:正则写法与手工写法的取舍
分词我用正则版本:
import re def extract_words(text): return re.findall(r'[a-zA-Z]+', text)就这么一行。很多新手会关心性能问题,觉得正则慢。实际上对这个量级(PTA 的测试文本一般也就是几十 K 到几百 K 字符),正则的匹配速度完全可以忽略。真正要注意的反而是:你正则写的是[a-zA-Z]+,不是\w+。\w在 Python 正则里默认匹配字母、数字和下划线,如果用了它,123会被当成单词,而按题意它通常不是。我见过好几个同学栽在\w+上,就是因为没注意到数字也会被匹配进去。
如果你不想引入正则,也可以手工分词:
def extract_words(text): words = [] current = [] for ch in text: if ch.isalpha(): current.append(ch) else: if current: words.append(''.join(current)) current = [] if current: words.append(''.join(current)) return words这段代码的逻辑也非常清晰:遇到字母就攒着,遇到非字母就把攒好的字母串作为单词交出去。ch.isalpha()在纯英文小写场景下行为正确,但如果你已经提前.lower(),其实也可以直接判断'a' <= ch <= 'z'。两者都行,isalpha()更通用,但要注意它对中文也是返回 True 的。如果你不 care 中文,那没事;如果文本里可能有中文,你就得用'a' <= ch <= 'z'来严格限定。PTA 这道题一般不会给你中文,但我养成习惯用后者更保险。
3.4 统计和排序:两段代码各自说清楚
统计模块:
from collections import defaultdict def count_words(words): freq = defaultdict(int) for word in words: freq[word] += 1 return freq排序模块:
def sort_words(freq): return sorted(freq.items(), key=lambda x: (-x[1], x[0]))到这里,核心逻辑基本完工。以total_words = len(words),unique_words = len(freq)就能拿到总单词数。输出方面按题目要求的格式拼字符串即可。我通常这样处理:
total = len(words) unique = len(freq) print(total, unique) for word, count in sorted_items[:limit]: print(f"{word}:{count}")其中limit根据题目给你的阈值来计算,比如如果题目说“输出出现次数最多的前 20% 个单词”,那就是limit = max(1, int(unique * 0.2))或者按具体规则取整。这个细节你要看题面,按它的定义来。
3.5 完整代码放在一起看
把上面几块拼起来,一个干净完整的版本长这样:
import sys import re from collections import defaultdict def extract_words(text): return re.findall(r'[a-zA-Z]+', text) def count_words(words): freq = defaultdict(int) for word in words: freq[word] += 1 return freq def main(): lines = [] while True: line = sys.stdin.readline() if not line: break line = line.strip() if line == '!!!!!': break lines.append(line) text = ' '.join(lines).lower() words = extract_words(text) if not words: return freq = count_words(words) sorted_items = sorted(freq.items(), key=lambda x: (-x[1], x[0])) print(len(words), len(freq)) for word, count in sorted_items: print(f"{word}:{count}") if __name__ == '__main__': main()这个版本对大多数标准题面的词频统计都能稳定拿满。当然具体输出格式(比如分隔符是冒号还是空格,是否需要输出前 N 个)还是以你实际题目要求为准,这块需要你自己订正。
4. 我踩过的坑和你可能也会踩的坑
4.1 排序不稳定,测试点就挂
第一次裸写这道题的时候,我用的是sorted(freq.items(), key=lambda x: x[1], reverse=True),然后自信满满提交,结果挂了一个测试点。当时没想明白,后来查到“同频次单词应按字典序输出”这个要求,才反应过来:reverse=True排序后的顺序并不保证同频次间按字典序升序,因为原始freq.items()的顺序是插入顺序,不是字典序。
这个问题我不止在一道题上遇到,Python 的sort(key=..., reverse=True)和“复合排序”之间的差异,对我来说算是刷题时最常见的误区之一。这里我的建议是:不要记住结论,而是理解原理。稳定排序只是保证“相等元素相对顺序不变”,并不代表它会自动按第二关键字调整。要同时满足两个排序条件,要么传 tuple key,要么分两次稳定排序。
4.2 读入结束标志时多统计了一行
还有一个很低级的错:把!!!!!也join进了文本再统一处理,导致最后面多出一个奇怪的“单词”或者把标点算进去。这个错属于“读入逻辑和统计逻辑耦合”导致的。
我的教训是:读入阶段就把结束标志拦截干净,不要指望后面处理阶段能自动滤掉它。虽然[a-zA-Z]+不会匹配!,但如果题目的结束标志是比如END这种字母词,你拼接后再分词,它就会变成一个虚假单词end,直接污染统计结果。所以拦截动作务必放在读入阶段。
4.3 大小写转换的位置
另一个容易犯的错是先分词再转小写,比如:
words = extract_words(text) freq = count_words([w.lower() for w in words])这也能工作,但没必要绕圈子。不如在提取前直接text.lower(),这样后面所有字符串都是小写,逻辑上“归一化”提前完成了。我习惯把大小写归一化当作文本清洗的第一步,因为后续所有处理都建立在“干净的文本”上,心智负担最小。
4.4 空输入和空统计的处理
如果文本全部是标点符号,words会是空列表。此时直接print(len(words), len(freq))也能运行,输出两个 0。但如果你的代码里没有if not words: return这种保护,后面for word, count in sorted_items也会正常跳过,所以不会报错。不过我还是建议把空输入分支显式写出来,因为 PTA 某些测试点喜欢考边界,而你一旦少处理这种特判,可能出现 UnboundLocalError 之类的连锁问题。
4.5 别在输出末尾添加多余空格或换行
PTA 的判题对输出格式比较严格。我见过同学用print(word, count)输出,结果变成单词 次数,题目要求的是单词:次数,于是全部 WA。也有同学在循环输出时用print(f"{word}:{count}, "),末尾多个逗号也判错。刷这种题时我总会提醒自己:先把题目给的输出样例抄一遍放在编辑器旁边,写完代码一行行对照样例格式,空格、冒号、换行都不能差。
5. 解题复盘与进一步思考
5.1 从这道题延伸出去:这类统计题的核心套路
词频统计其实是一个典型且高频的文本处理模型,面试、笔试、真实项目里都很常见。它背后是“清洗 -> 映射 -> 归约 -> 排序”这条通用流水线:
- 清洗:大小写归一化,移除无效字符,按规则切分。
- 映射:每个单词映射到一个键。
- 归约:相同键的值累积。
- 排序:按业务规则输出最关心的部分。
学会了这条流水线,你再遇到统计 IP 出现次数、统计用户操作类型频率、统计日志错误码数量,都可以套用同一套思路。区别只在于“清洗规则”变了,“归约逻辑”可能从计数变成求和,“排序规则”也可能变成按时间或者按其他维度。
这也是为什么我强调不要用Counter一行糊弄过去,因为那刚好跳过了中间所有思考环节。刷题的目的不是为了提交通过,而是为了训练自己在面对新问题时能快速梳理出流水线。
5.2 如果数据量变大,该怎么优化
PTA 的测试数据规模有限,但如果你把这道题迁移到真实场景,比如统计一个几 GB 的日志文件,有几个点会有明显性能差异:
- 逐行读取不要一次性
read()大文件,Python 里用for line in file迭代最省内存。 - 字典累积是线性的,没问题;瓶颈在排序,但纯 Python 的
sort本身就是优化过的 Timsort,几百万键也扛得住。 - 如果键特别多,内存吃紧,可以考虑用
Counter的most_common(n)截断,它内部用堆排序,在只需要 TopK 时比全排序更划算。
这道题要求全量输出,所以全排序是最自然的。但如果题目改为“只输出频次最高的前 10 个”,你就可以用heapq.nlargest来避免对全部键排序。这个优化在 PTA 题目里通常不需要,但值得自己练一下。
5.3 正则与手写的性能对比:实测有感
我在本地做了一个简单的性能测试,用一段大约 200KB 的英文文本分别跑正则版本和手写版本,结果两者都在 0.1 秒以内完成,肉眼几乎看不出差别。这让我确定了一点:在 PTA 这种数据规模下,正则性能和手写性能无实质差异,选择哪个完全是代码风格和可读性的取舍。
但在更大的文本上(几十 MB),手写版本可能会稍快一点,因为正则引擎本身也有匹配开销。反过来,正则版本代码极简,不容易出人为 bug。我的取舍标准是:代码量少、逻辑清晰的那个优先,性能等真遇到瓶颈再优化。
5.4 我的一些刷题习惯
这次解这道题时,我还顺手给自己定了几条刷题规矩,分享给你参考:
- 第一遍先读懂题目,把所有输入输出样例手动跑一遍,弄明白规则再写代码。
- 写完代码先本地测试样例,再用自己编的边界测试(全标点、空行、大小写混合、只出现一次的词、频次相同的一批词)验证。
- 提交前检查一遍每一行输出是否与题面样例格式完全一致。
- 如果 WA,不要盲目改代码,先在本地故意造一个“同频次多个词”的测试,确认排序逻辑是在按字典序输出的。
这几条习惯帮我避开了很多没必要的重交和罚时,我觉得比多会一个函数、多背一个 API 有用得多。
5.5 代码还能怎么迭代
如果你已经能稳定拿下这道题,可以做两个小扩展强迫自己深入理解。第一个是把extract_words修改成同时支持don't这类带撇号的词被完整保留为一个 token 的规则,做法是正则改成[a-zA-Z]+(?:'[a-zA-Z]+)*,或者手工判断撇号后继续拼接。第二个是改成“只输出前 K 个高频词”的版本,尝试用heapq.nlargest实现,并比较它与全排序的结果是否一致。
实际工作中,文本处理的需求永远比教科书复杂:可能有 Unicode、可能有 emoji、可能需要保留数字、可能大小写规则特殊。你在这道题里建立的“清洗-映射-归约-排序”心智模型,以后面对这些变体时依然能帮你快速拆解,不会慌。
对我来说,PTA 第 7 章这道 30 分题最有价值的地方,不是那几分,而是它逼着我把一段普通的代码写出了工程感:输入边界怎么处理、排序规则怎么设计、代码块怎么组织,每一步都在为清晰和可靠服务。如果你现在正卡在这道题上,别急,按上面这几个模块一步步拆,先把输入读对,再把分词跑通,最后盯死排序规则,一般就能稳过。踩过几个测试点之后你会觉得,这道题确实不难,但确实配得上 30 分。