这篇文章采用与 AI 问答的形式。。基于费曼学习法示了 Python Counter 类的核心概念和使用技巧。
prompt: 我正在学习数据结构与算法, 其中用到了 python 的 counter 类,这是我第一次接触,请你和我一起利用费曼学习法进行学习,问我一些问题以加深理解
如果需要统计一段文本中每个单词出现的次数,用普通的 Python 字典手动实现大概步骤是怎样的?Counter 是如何简化这个过程的?
# Q1
# Count the number of sentences in the string using common dictionary
text = "This is a sentence. And this is another sentence. This is the third sentence."
cnt_dict = {}
for c in text.split():
cnt_dict[c] = cnt_dict.get(c, 0) + 1
print(cnt_dict)
# Count the number of sentences in the string using counter class
cnt_counter = Counter(text.split())
- 普通字典实现时需要手动查找和更新计数,Counter 内部是如何 " 简化 " 这一过程的?
这是个很好的问题,好到我第一时间都不知道到底应该去哪里找资料回答
发现 perplexity 很适合,主要是可以看他的参考文献,这个准确度和速度确实比我自己部署的 OpenWebUI+searxng 强很多
o3-mini: 使用普通字典时,每次更新计数都必须写类似 d[key] = d.get(key, 0) + 1 的代码,而 Counter 的内部实现通过继承 dict 并重写 getitem (或 missing )方法,使得当访问一个不存在的键时,自动返回 0。这样当你对某个键进行加法运算时,不必显式地先判断该键是否存在,从而大大简化了代码。简而言之,Counter 内部 " 隐藏 " 了检查键是否存在和初始化计数值这一步骤,让代码更简洁、易读。
Why is Python Collections.counter so much faster than my code to count frequencies? : r/leetcode
cpython/Lib/collections/__init__.py at 3.10 · python/cpython
从源码中确实可以看出来,对
__miss__
方法的重写才能使得访问不存在的 key 而不会抛出错误
主要的区别/不同在于:可以自动初始化,而不用利用 loop;当试图访问不存在的 key 的时候,可以返回 0,而不是 KeyError;
- 使用 Counter 带来了哪些代码上的优势?
体现在无需手动初始化,检查不存在的 key,支持算术运算,性能比手写的 dict 要强 - 因为 python 语言的优化,更 pythonic、简洁
当用 from collections import Counter
后,想创建一个计数器来统计 ["apple", "banana", "apple", "orange"]
的元素出现次数,代码应该怎么写?此时直接打印这个 Counter 对象会输出什么?
# Q2
words = ["apple", "banana", "apple", "orange"]
cnt = Counter(words)
print(cnt)
如果我有一个计数器 c = Counter(a=3, b=2)
,当访问 c["c"]
时会发生什么错误吗?为什么这与普通字典不同?如何安全地获取一个可能不存在元素的计数?
# Q3
c = Counter(a=3, b=2)
print(c["c"]) # not KeyError, just return 0
- 请你阐述这是如何实现的,以及这种设计对使用计数器时有什么好处?
通过重写 __missing__
,使其返回 0,而不是 Error. 好处就是不必担心不存在的 key,可以少写一个 Error 的捕获
这种设计在频繁更新计数过程中也提高了代码的健壮性和可读性,同时也简化了很多统计任务的编程复杂度。
假设已经创建了计数器 word_counts = Counter(text.split())
,现想获取出现频率最高的前 2 个单词,应该调用什么方法?返回的数据结构是怎样的?
# Q4
word_counts = Counter(text.split())
print(
word_counts.most_common(2)
) # return a list of n most common elements and their counts [(word, count), …]
- 它返回的数据结构是什么?
返回了一个 list,其中每个 element 是一个 tuple,有两个元素 (二元组),一个是 word,一个是其对应的 count
可以补充说明列表中元素的排列顺序是从计数最高到最低
- 在什么场景下,这个方法对问题求解(例如算法题)非常有帮助?
例如返回一串字符中,出现频率最高的几个,统计词频
这种方法在需要排序、分布分析、或寻找 " 小众 " 元素(比如低频词)等问题时非常有帮助,因为它直接返回按频率排序的数据
当调用 counter.elements()
方法时,返回的是什么样的迭代器?比如对于 Counter(a=2, b=3)
,list(elements())
的结果是什么?
# Q5
cnt = Counter(a=2, b=3)
cnt.elements() # return an iterator over elements repeating each as many times as its count
print(
list(cnt.elements())
) # a list contains all elements in the counter in order and repeated by their count
- 你能解释一下为什么 Counter 需要这个方法吗?
我认为这个方法的目的,就是可以按照计数多少,以迭代器的方式,访问元素
这个方法主要用于将 Counter 展开成一种 " 多重集合 “(multiset)的形式,即让你可以按照每个元素的计数重复出现。这对于需要根据计数还原数据、随机抽取或其他需要遍历所有计数个数的场景很有用。这样做避免了你必须手动写循环将每个元素复制多次。
- 举例说明,对于 Counter(a=2, b=3) 返回的迭代器提取成列表后,元素排列是否一定有固定顺序?为何会这样?
以我目前测试的 3.12 来看,顺序是一定的,“Elements are returned in the order first encountered.” 是 [文档](collections — Container datatypes — Python 3.13.2 documentation) 中明确说明的。
至于返回列表中元素的顺序,尽管在你测试的 Python 3.12 中看起来顺序是固定的(例如总是先出现 ‘a’ 后出现 ‘b’),但官方并没有保证这种顺序。Counter.elements() 的返回顺序是基于内部字典的存储顺序,而这种顺序目前是插入顺序,但不能作为 API 的依赖特性。所以,我们应当认为该顺序是任意的,不应该依赖于特定的排序或顺序安排
如何合并两个 Counter 对象(例如 c1 = Counter(a=3)
和 c2 = Counter(a=2, b=5)
),使其合并后的计数器是将对应键的值相加?如果要做值相减呢?(提示:+
和 -
运算符的作用)
# Q6
c1 = Counter(a=3)
c2 = Counter(a=2, b=5, c=-4)
# add two counters together
print(c1 + c2) # Counter({'a': 5, 'b': 5})
# ignore zero and negative counts
print(c1 - c2) # Counter({'c': 4, 'a': 1})
- 当使用加法运算时,Counter 内部如何处理相同 key 的值?
从源码以及测试中可以发现,对于 key 相同,例如 c1 中 a=3,c2 中 a=2,Counter 会将他们的 value 相加,得到 a=5。这就好像,计数相加。
您的理解是正确的。当两个
Counter
对象通过+
相加时,所有对应键的值会被求和。背后的机制是:
Counter
重载了__add__
运算符,返回一个新Counter
对象。- 对于每个键(存在于两者中的任意一个),其计数为两
Counter
中该键的值之和。- 只有计数结果为正值的键会被保留 (例如
c1 = Counter(a=2)
,c2 = Counter(a=-3)
,则c1 + c2
会得到一个空Counter
,因为a
的最终值为-1
,被过滤掉)。- 这种行为符合统计场景的直观逻辑:计数相加后负值无意义,因此直接舍弃。
- 使用减法时为何会忽略零和负数?在实际场景下这一特性又有什么实际意义?
从源码角度讲,其检查了 newcount = count - other[elem]
的输出结果 newcount
,只有大于 0 才输出。同时也保证了被减数 (c2) 中的负数计数可以在减法下,输出正数。在实际场景中,这种特性应该与实际业务有关,例如人的计数,只能有非负数个人,而不能有负数。这里的负数人数很可能由于网络、数据库、或者其他部分的过多减少计数而产生,counter 忽略非负数可以在这种实际业务中,减少 bug 的产生。
减法运算符与
subtract()
方法的区别: 减法运算符-
的行为类似集合差集,生成的新Counter
会过滤掉非正值的键,如c1 = Counter(a=3)
,c2 = Counter(a=2)
,则c1 - c2
的结果是Counter(a=1)
;若c1 = Counter(a=1)
,则c1 - c2
会生成空Counter
(结果-1
被丢弃)。 而subtract()
方法 不会丢弃负值,会保留所有键的实际计算结果,例如:
c = Counter(a=3)
c.subtract(Counter(a=5))
print(c["a"]) # 输出 -2
此时不会过滤键,甚至可以手动保留负值以用于后续处理(例如溯源错误)。
实际应用意义:
-
运算符的设计满足了统计场景中 " 仅关注有效计数 " 的直觉。例如在资源分配问题中,结果若为负值可能表示资源不足,直接过滤后可简化后续处理。subtract()
则适用于需要跟踪完整变化的场景,例如某商品的库存变化可能需记录超额销售的错误值。
如果想把 Counter 转换为一个普通字典,有哪些方法?哪种转换方式会保留计数值为 0 的元素?
# Q7
cnt = Counter(a=3, b=2, c=0)
print(cnt)
# convert counter to a regular dictionary
# remains zero counts
print(dict(cnt))
- Counter 会保留计数值为 0 的键,转换为 dict 时会怎样?
从结果来看,转换为 dict 的时候,value 为 0 的键也有所保留,并没有删除。
因为 Counter 本质上是一个特殊的字典子类,它的行为与字典一致。这个设计是因为 Counter 的用途是统计计数,保留 0 值键可以明确表示一个元素的计数曾经存在但当前已被减为零。这对于可能需要追溯或动态调整的统计场景非常重要。
- 在遇到不再需要统计信息时,转换为什么数据结构可能更合适?
如果意思是,不再需要计数的数据结构,那 list 或者 set 只记录 key 就挺好
需要非零键: 过滤出
dict
,例如{k: v for k, v in cnt.items() if v > 0}
在处理计数时,counter.update()
和直接使用 counter = counter1 + counter2
的主要区别是什么?哪个会修改原对象,哪个创建新对象?
# Q8
cnt = Counter(a=3, b=2, c=0)
cnt2 = Counter(a=1, b=2, c=3)
# using update, update original counter
cnt.update(cnt2)
print(cnt)
# using +, create a new counter
cnt_plus = cnt + cnt2
print(cnt_plus)
- 哪个方法会修改原有 Counter 对象,哪种方法会返回一个新的对象?
update
会修改原有对象返回 None
,而 +
则会返回一个新 counter 对象,保持原对象
- 在修改过程中,应该注意哪些副作用?
如果两个大的 counter 对象合并,产生一个新的更大的 counter 对象,而不去释放无用的原来的 counter 对象的占用空间,可能会造成一些问题
数据一致性风险 :
update()
修改原对象,可能导致其他代码引用该对象时出现不符合预期的结果。例如:
original = Counter(a=1)
backup = original # backup 和 original 指向同一对象
original.update(b=2)
print(backup) # backup 会同步更新,可能引发其他逻辑错误!
如果其他代码依赖于
backup
的独立性,这种设计可能导致错误。
内存与性能 : 使用
+
创建新Counter
时会生成独立对象,原数据不变。多次操作可能积累大量临时对象 ,若程序内存有限需及时清理。对于非常大的Counter
,频繁使用+
合并可能导致内存峰值,此时手动释放不再使用的对象是关键:
c = Counter(…)
c = c + other # 原 c 引用对象可能未被立即释放
# 建议改用 c.update(other) 直接修改,节省内存
Counter 对象有个令人惊讶的特性:若将某个键的计数设置为 0,该键会 “保留”;如果想彻底删除这个键需要用 del 方法。(试过实际代码吗?)
# Q9
cnt = Counter(a=3, b=2, c=1)
print(cnt)
cnt["c"] = 0
print(cnt)
del cnt["c"]
print(cnt)
# print common dict with zero value
print({"a": 3, "b": 2, "c": 0})
- 解释为什么 Counter 选择保留计数为 0 的键,而普通字典可能直接不显示该键。
因为 counter 的计数为 0 本身是有意义的,代表着这个键对应的计数时 0。而普通字典,如上面的代码中,print({"a": 3, "b": 2, "c": 0})
是同样会保留 0 的。
在哪些算法场景(例如力扣题目)中使用 Counter 会明显比手动统计更高效?你能想到至少两个具体应用场景吗?
counter in LeetCode, such as Q904 and Q76, sliding window and counter
正如上面所一直关心的,counter 的存在本身,我更倾向于其是为了实际背景服务的,也就是说需要类似计数情况是,couter 的保留 0 计数、允许访问未声明的 key、可以过滤负数计数等特性才是有意义的。
Counter 的高效不仅来自代码量少,更因其底层优化(字典哈希操作)和语义明确(统计场景原生支持)。