这篇文章采用与 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 内部 " 隐藏 " 了检查键是否存在和初始化计数值这一步骤,让代码更简洁、易读。

What’s the difference between a Python dictionary and a Python Counter? - Computer Science - Codecademy Forums

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 对象通过 + 相加时,所有对应键的值会被求和。背后的机制是:

  1. Counter 重载了 __add__ 运算符,返回一个新 Counter 对象。
  2. 对于每个键(存在于两者中的任意一个),其计数为两 Counter 中该键的值之和。
  3. 只有计数结果为正值的键会被保留 (例如 c1 = Counter(a=2)c2 = Counter(a=-3),则 c1 + c2 会得到一个空 Counter,因为 a 的最终值为 -1,被过滤掉)。
  4. 这种行为符合统计场景的直观逻辑:计数相加后负值无意义,因此直接舍弃。
  • 使用减法时为何会忽略零和负数?在实际场景下这一特性又有什么实际意义?

从源码角度讲,其检查了 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 的高效不仅来自代码量少,更因其底层优化(字典哈希操作)和语义明确(统计场景原生支持)。