Blog
Python collections 刷题速查手册:Counter、defaultdict、deque 一次掌握
面向 Python 与 LeetCode 初学者的 collections 速查:一周学习路线、Counter、defaultdict、deque 的场景、模板、易错点与练习清单。
做字符串、数组、哈希表题时,很多 Python 解法都会写:
from collections import Counter, defaultdict, deque
它们不是遥远的“高级语法”,而是三种很实用的数据工具:统计次数、自动补默认值、高效地从两端取元素。这篇文章不追求把 collections 的所有成员背下来,而是帮你建立刷题时能立刻调用的判断:这题该用哪个?为什么?怎么写才不容易错?
官方完整文档在这里:Python collections 文档。遇到陌生 API 时,优先查它;本文可以作为你做题时的中文索引。
先记住:三件工具解决三类问题
| 工具 | 一句话用途 | 高频题型 |
|---|---|---|
Counter |
统计每个元素出现了几次 | 异位词、频率比较、Top K、字符计数 |
defaultdict |
字典缺少键时自动提供默认值 | 分组、图邻接表、计数、前缀和 |
deque |
两端都能高效加入或取出 | BFS、滑动窗口、单调队列 |
一个非常实用的选择口诀:
先数次数用
Counter;边处理边建桶用defaultdict;需要先进先出或两端操作用deque。
普通 dict、list、set 仍然是基础。collections 只是让某些常见写法更短、更清楚,或把时间复杂度从“可能很慢”变成“稳定高效”。
一周学习路线:边学边做题
每天控制在 45~90 分钟。前半段写小例子,后半段做 1~2 道题;不要只看不运行。
| 天数 | 学习重点 | 产出 |
|---|---|---|
| 第 1 天 | dict、set、enumerate 复习;认识 Counter |
手写字符频率统计 |
| 第 2 天 | Counter 的比较、减法与 most_common() |
完成异位词和 Top K 题各一题 |
| 第 3 天 | defaultdict(int) 与 defaultdict(list) |
手写分组、图邻接表 |
| 第 4 天 | deque 队列与 BFS |
完成一题树或网格 BFS |
| 第 5 天 | deque 滑动窗口与单调队列 |
理解为什么不能用 list.pop(0) |
| 第 6 天 | 按题型混合练习 | 选 3 题,只写思路再编码 |
| 第 7 天 | 整理自己的模板与错题 | 写一页“什么时候用什么”笔记 |
学习时推荐采用这个循环:
- 用 5 行代码验证一个 API。
- 用它完成一道题的核心部分。
- 把容器替换成普通写法,比较代码和复杂度。
- 记录一个自己真正踩到的坑。
Counter:把“频次统计”写得更直接
Counter 是专门计数的字典。键是元素,值是它出现的次数。
from collections import Counter
s = "banana"
count = Counter(s)
print(count) # Counter({'a': 3, 'n': 2, 'b': 1})
print(count['a']) # 3
print(count['x']) # 0
什么时候该用 Counter
看到下面的词,就优先想到它:
- “每个字符/数字出现几次”
- “两个字符串是否由相同字符组成”
- “找出现频率最高的 K 个元素”
- “窗口内是否已经包含所需字符”
- “剩余哪些字符还没匹配”
高频 API
from collections import Counter
cnt = Counter([1, 1, 2, 3, 3, 3])
cnt[1] # 2:不存在的键读取为 0
cnt.most_common(2) # [(3, 3), (1, 2)]:出现最多的两个
cnt.update([2, 2]) # 批量增加计数
cnt.subtract([1, 3]) # 批量减少计数,结果可以是负数
模板 1:判断有效字母异位词
最清楚的版本就是比较两个计数器:
from collections import Counter
def is_anagram(s: str, t: str) -> bool:
return Counter(s) == Counter(t)
例如 "anagram" 和 "nagaram" 的每个字符次数都相同,所以结果为 True。
模板 2:找出现频率前 K 高的元素
from collections import Counter
def top_k_frequent(nums: list[int], k: int) -> list[int]:
return [num for num, _ in Counter(nums).most_common(k)]
这段适合快速解题和理解题意。面试或进阶题若要求比排序更优的复杂度,再学习桶排序或堆。
模板 3:滑动窗口中维护“还缺什么”
最小覆盖子串等题经常需要目标字符的数量:
from collections import Counter
need = Counter("AABC")
window = Counter()
for ch in "BAAC":
window[ch] += 1
# 判断时通常逐个比较需要的字符,而不是比较整个 Counter
is_valid = all(window[ch] >= need[ch] for ch in need)
这里 need 表示目标,window 表示当前窗口。窗口右移时加,左移时减。
Counter 的三个易错点
-
Counter可以保留 0 和负数计数。from collections import Counter cnt = Counter(a=1) cnt.subtract("aa") print(cnt) # Counter({'a': -1})这在“差值”场景有用;但如果你想删除无效项,需要自己清理,例如
+cnt会只保留正数计数。 -
most_common()的并列顺序不要当成题目保证。若题目要求并列时按数值或字母排序,要在结果上明确再排序。
-
并非所有计数题都必须用
Counter。滑动窗口里需要频繁
+= 1、-= 1时,defaultdict(int)常更直观;两者复杂度通常一样。
defaultdict:不用反复判断“这个键存在吗”
普通字典访问不存在的键会报错:
count = {}
# count['a'] += 1 # KeyError: 'a'
defaultdict 允许你指定“缺少键时应该生成什么默认值”。
from collections import defaultdict
count = defaultdict(int) # int() 的结果是 0
count['a'] += 1
count['b'] += 2
print(dict(count)) # {'a': 1, 'b': 2}
注意:defaultdict(int) 中传入的是 工厂函数 int,不是 int();需要默认空列表时写 defaultdict(list),而不是 defaultdict([])。
三种最常用默认值
from collections import defaultdict
count = defaultdict(int) # 不存在的键 -> 0
groups = defaultdict(list) # 不存在的键 -> []
seen = defaultdict(set) # 不存在的键 -> set()
什么时候该用 defaultdict
- 边遍历边计数:
defaultdict(int) - 按某个特征分组:
defaultdict(list) - 建图:
graph[u].append(v) - 记录某个值出现过的下标集合:
defaultdict(set) - 前缀和题中统计“这个前缀和出现了多少次”
模板 1:按键分组
例如按单词字母组成分组:
from collections import defaultdict
def group_anagrams(words: list[str]) -> list[list[str]]:
groups = defaultdict(list)
for word in words:
key = ''.join(sorted(word))
groups[key].append(word)
return list(groups.values())
groups[key] 第一次出现时自动得到新列表,因此可直接 .append()。
模板 2:邻接表建图
from collections import defaultdict
edges = [(0, 1), (0, 2), (1, 3)]
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # 无向图才需要这一行
print(graph[0]) # [1, 2]
图题中 graph[node] 即使没有邻居也会返回空列表,DFS/BFS 写起来很顺。
模板 3:前缀和 + 计数
统计和为 k 的连续子数组数量:
from collections import defaultdict
def subarray_sum(nums: list[int], k: int) -> int:
prefix_count = defaultdict(int)
prefix_count[0] = 1
prefix = ans = 0
for num in nums:
prefix += num
ans += prefix_count[prefix - k]
prefix_count[prefix] += 1
return ans
先查询 prefix - k 出现次数,再增加当前 prefix。顺序不能颠倒,否则在 k = 0 时可能把当前前缀和错误地算进去。
defaultdict 的三个易错点
-
读取不存在的键也会创建它。
from collections import defaultdict d = defaultdict(int) print(d['missing']) # 0 print('missing' in d) # True只想安全读取而不创建键时,使用
d.get(key, 0)。 -
不要把同一个可变列表当默认值共享。
正确:
defaultdict(list),因为每个新键都会调用一次list()创建新列表。 -
它并不会自动删除计数为 0 的键。
滑动窗口里减到 0 后保留键通常没问题;如果题目需要窗口不同元素个数,可在减到 0 时
del count[ch]。
deque:队列不要再用 list.pop(0)
deque 是双端队列(double-ended queue)。它适合在左右两端都频繁操作。
from collections import deque
q = deque([1, 2, 3])
q.append(4) # 右端加入
q.appendleft(0) # 左端加入
q.pop() # 右端取出,得到 4
q.popleft() # 左端取出,得到 0
list.append() 和 list.pop() 在右端也很快;但 list.pop(0) 需要移动后面所有元素,最坏是 O(n)。deque.popleft() 则是 O(1),这就是 BFS 和滑动窗口常选它的原因。
什么时候该用 deque
- “一层一层扩散”或“最少步数” → BFS 队列
- 窗口左端不断离开元素 → 滑动窗口
- 需要维护候选最大值/最小值 → 单调队列
- 需要固定长度队列 →
deque(maxlen=k)
模板 1:网格 BFS 的最短路径骨架
from collections import deque
def bfs(grid: list[list[int]], start: tuple[int, int]) -> int:
rows, cols = len(grid), len(grid[0])
q = deque([(start[0], start[1], 0)]) # 行、列、步数
visited = {start}
while q:
r, c, dist = q.popleft()
# 在这里判断是否到达终点
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited:
visited.add((nr, nc))
q.append((nr, nc, dist + 1))
return -1
关键习惯:入队时标记 visited,而不是出队时。这样同一个点不会被多个方向重复塞进队列。
模板 2:固定大小滑动窗口
from collections import deque
def moving_average(nums: list[int], k: int) -> list[float]:
window = deque()
total = 0
ans = []
for num in nums:
window.append(num)
total += num
if len(window) > k:
total -= window.popleft()
if len(window) == k:
ans.append(total / k)
return ans
这个模板把“加入右端”和“移除左端”写得非常直观。
模板 3:单调队列求滑动窗口最大值
单调队列难点不是 API,而是队列里保存下标,并保持对应数值从大到小:
from collections import deque
def max_sliding_window(nums: list[int], k: int) -> list[int]:
q = deque() # 保存下标;nums[q] 对应的值单调递减
ans = []
for i, num in enumerate(nums):
while q and q[0] <= i - k: # 队首已经离开窗口
q.popleft()
while q and nums[q[-1]] <= num: # 更小的候选永远不会成为最大值
q.pop()
q.append(i)
if i >= k - 1:
ans.append(nums[q[0]])
return ans
为什么存下标?因为既要知道值,也要判断它是否已经过期。每个下标最多进队和出队各一次,所以整体仍是 O(n)。
deque 的三个易错点
popleft()会在队列为空时报IndexError;循环前写while q:。- 单调队列里比较的是
nums[q[-1]],不是下标本身。 deque(maxlen=k)满时追加新元素会自动丢掉另一端元素。它适合固定历史记录,但算法题中若还要同步维护和、计数,手动popleft()通常更清楚。
Counter 和 defaultdict(int) 到底怎么选?
两者都能计数;选哪个主要看意图。
| 场景 | 更推荐 | 原因 |
|---|---|---|
| 一次性统计整个字符串或数组 | Counter(data) |
初始化就表达“统计频率” |
| 比较两个元素频率是否一致 | Counter |
可直接 Counter(a) == Counter(b) |
| 求最高频元素 | Counter |
有 most_common() |
| 遍历过程中持续增减计数 | defaultdict(int) |
读写逻辑简单、意图明确 |
| 前缀和、图、分组等非纯计数 | defaultdict |
默认值可为 int、list、set 等 |
例如“最长无重复子串”可以写成 defaultdict(int):
from collections import defaultdict
def length_of_longest_substring(s: str) -> int:
left = ans = 0
count = defaultdict(int)
for right, ch in enumerate(s):
count[ch] += 1
while count[ch] > 1:
count[s[left]] -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
其中 count[s[left]] -= 1 的意思是:left 是当前窗口左边界,左边界准备右移之前,先把即将离开窗口的字符从计数中移除。
按题型选择工具:刷题地图
| 题型或关键词 | 第一反应 | 代表题目 |
|---|---|---|
| 是否为异位词、字符频率 | Counter |
有效的字母异位词、赎金信 |
| 频率最高的 K 个元素 | Counter().most_common()(入门) |
前 K 个高频元素 |
| 按规律分组 | defaultdict(list) |
字母异位词分组 |
| 图的边、课程依赖、账户合并 | defaultdict(list) |
课程表、克隆图 |
| 前缀和出现次数 | defaultdict(int) |
和为 K 的子数组 |
| 最短步数、层序遍历、腐烂扩散 | deque |
二叉树层序遍历、腐烂的橘子 |
| 固定窗口最大/最小值 | deque 单调队列 |
滑动窗口最大值 |
| 去重、快速判断是否存在 | set |
两数之和、最长连续序列 |
不要为了使用某个工具而使用它。看到题目后先问:我需要维护的是次数、分组关系、还是等待处理的顺序?答案通常就会指向合适的容器。
其他成员:现在只需知道它们的位置
namedtuple
namedtuple 可以创建“有字段名的元组”:
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p = Point(2, 3)
print(p.x, p.y) # 2 3
它比 (2, 3) 可读性更好,但新 Python 代码通常优先考虑 dataclasses.dataclass。LeetCode 中知道即可,出现频率远低于前三者。
ChainMap
ChainMap 可以把多个字典当作一个“按顺序查找”的映射来读:
from collections import ChainMap
defaults = {'color': 'blue', 'size': 'M'}
user = {'color': 'black'}
settings = ChainMap(user, defaults)
print(settings['color']) # black
print(settings['size']) # M
它在配置覆盖场景有用,刷题中较少。初学阶段不需要刻意练。
建议完成的练习清单
按顺序做,重点是“写完后能解释为什么选这个容器”。题号以 LeetCode 为例。
Counter:242 有效的字母异位词、383 赎金信、347 前 K 个高频元素。defaultdict(list):49 字母异位词分组、133 克隆图。defaultdict(int):3 无重复字符的最长子串、560 和为 K 的子数组。dequeBFS:102 二叉树的层序遍历、994 腐烂的橘子、542 01 矩阵。deque单调队列:239 滑动窗口最大值。
做完每题后,给自己回答这四个问题:
- 为什么普通
dict/list不够直接或不够快? - 容器中的键、值或下标分别代表什么?
- 每次循环不变的条件是什么?
- 空输入、重复元素、窗口边界会怎样?
最后的速记卡
from collections import Counter, defaultdict, deque
# 统计
freq = Counter(nums)
# 自动从 0 开始计数
count = defaultdict(int)
count[x] += 1
# 自动创建分组列表 / 图邻接表
groups = defaultdict(list)
groups[key].append(value)
# BFS 队列 / 从左端弹出
q = deque([start])
node = q.popleft()
现在不必记住 collections 的全部 API。把 Counter、defaultdict、deque 用熟,再回头阅读官方 collections 文档时,你会发现每一个方法都有具体使用场景,而不再只是一串需要背诵的名字。