在繁忙的菜市场里,老王每天要面对几百种蔬菜水果,他想知道“西红柿今天卖了多少份”。可摊位上不可能为每种菜都准备一个专属计数器——几百种菜就要几百个格子,换成上亿种呢?老王的办法出人意料:他挂起一个只有两排、每排八个格子的木头柜子,请两位“记账员”用完全不同的规则记账,每卖出一份菜,就同时在与自己相关的格子上画一笔。想查某种菜卖了多少?让两位记账员分别报数,取两人中报出的较小值。这就是 Count-Min Sketch 的智慧:用一块小得惊人的格子板,聪明地处理海量流式数据,代价只是“偶尔稍微报高一点”。本文带你走进这个概率数据结构的世界,看它如何用“格子记账本”的巧思,解决流量统计、热词排名、网络监控中的真实难题。
掌握 Count-Min Sketch,你就拿到了海量数据实时统计的通用解法——无论万亿级流量日志、全网热搜还是网络监控,它都是后端技术团队离不开的实用工具。
目录
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
算法思想:给整条街的菜记一本固定的账
老王最初想得很简单——每种菜一个账本格,卖一份画一笔“正”字。可真正开张后麻烦大了:菜市场每天有几百种菜,每卖一笔都要翻到专属的那页;等季节变换、新菜上市,账本越加越厚。最要命的是早高峰,顾客排着长队,老王翻账本的速度远远跟不上卖菜的速度。他意识到:精确记账在品类规模巨大时是一种奢侈,他真正需要的,是“大概什么量级、哪种菜受欢迎”的答案。
把场景换成互联网,“菜品”变成了数据。一个大型 App 每秒产生百万次点击,一台骨干交换机每秒转发的数据包以亿计。若用传统哈希表为每个出现过的键维护计数器,键的种类越多内存越大,最终必然撞上物理内存的墙。Count-Min Sketch(CMS)的思路是绕开“一个键一个计数器”,改用“固定大小的网格 + 多个哈希函数”,让任意长度的数据流都落在同一块板子上。它属于“概率数据结构”家族——与布隆过滤器、HyperLogLog 同门,用可量化的微小误差换回数量级的空间节省。
Formally,CMS 的承诺是:无论流有多长,空间占用恒定;对元素 $i$ 的查询结果 $\hat{f}_i$ 不会低于真实频率 $f_i$,并且大概率不会高得离谱:
所以老王的格子板不必记每笔账,只需保证:“你问的菜,报数或许虚高,但高得有限,而且绝大多数时候都靠谱。”这正是 CMS 用空间换精度的商业逻辑——把“精确但昂贵”换成“够用且便宜”。
- 估计频率符号$\hat{f}_i$ 是 CMS 返回的估计值,因为碰撞污染,它通常比真实值大一点。
- 真实频率符号$f_i$ 是元素 $i$ 在数据流中真正出现的次数,是“标准答案”。
- 误差参数$\varepsilon$ 控制相对误差,格子越宽 $\varepsilon$ 越小,误差上限越低。
- 总数据量$\|v\|_1$ 表示数据流长度,也就是所有元素出现次数之和,简记为 $N$。
- 置信概率$1-\delta$ 表示“这条误差保证”成立的概率,$\delta$ 越小越稳,靠增加行数来换取。
计数矩阵与最小值估计:两个记账员的默契
老王请来两位记账员,各守一排格子。第一位按“名字笔画数”归类,第二位按“名字最后一个字的拼音首字母”归类。生意好的时候,每卖出一份“西红柿”,两位记账员同时在各自规则指定的格子里画一笔。老王想知道销量时,就分别问两个人:“西红柿在那格记了多少?”两人报数后,老王永远取较小的那个数字。
这个“两排格子”就是 CMS 的计数矩阵:$d$ 行、$w$ 列。每个元素 $i$ 到达时,分别计算 $d$ 个哈希函数 $h_0(i), h_1(i), \dots, h_{d-1}(i)$,在每一行的对应列上执行加一:
查询元素 $i$ 的频率时,同样计算 $d$ 个哈希值,读取出 $d$ 个格子里的数字,取最小值作为估计:
两位记账员各自独立记账,一个人看走眼(碰撞),另一个人大概率不会看走眼。老王放心地取较小值:这个数字总是“宁可少说,也不乱说”。
- 行数$j$ 从 0 到 $d-1$,代表 $d$ 个互相独立的哈希函数,“每行一个记账员”。
- 第 j 行计数器$C_j$ 是第 $j$ 行的计数器数组,长度为 $w$,即 $w$ 个连续格子。
- 哈希映射$h_j(i)$ 把元素 $i$ 映射到第 $j$ 行的某一列,取值范围 $[0, w-1]$。
- 更新操作每个元素到达时,$d$ 行各有一个格子加一,这保证了“行内总计数恒等于总数据量”。
- 最小值估计$\min$ 是因为所有行的计数都只可能因碰撞被污染而偏高,取最小等于挑出“最干净的目击证人”。
误差界分析:老王敢打包票的底气
老王拍着胸脯说:“我保证误差不超过 5%,100 次里有 95 次都准。”这份底气从哪来?秘密就在格子板尺寸的决定公式里。CMS 的误差参数 $\varepsilon$ 由每行格子数 $w$ 决定,置信参数 $\delta$ 由行数 $d$ 决定。给定目标误差与失败概率,参数按下式设置:
总空间就是 $d \times w$ 个计数器。例如想让误差不超过 $1\%$($\varepsilon = 0.01$)且失败概率为 $5\%$($\delta = 0.05$),只需要大约 $272 \times 3 = 816$ 个计数器——无论数据流是 1 万条还是 10 亿条,格子板都是这么大。
误差界公式给出的是“最坏情况”的承诺,实际中碰撞带来的偏差通常远小于上界。老王的打包票不是在吹牛,而是数学保证:格子板尺寸选对了,误差就是可控的。
- 每行格子数$w$ 决定误差 $\varepsilon$,$w$ 越大每个格子平均承受的碰撞越少、误差越小。
- 欧拉常数$e \approx 2.718$ 来自理论推导中的常数因子,工程实现中直接使用。
- 矩阵行数$d$ 决定置信度 $\delta$,行数越多,所有行同时“看走眼”的概率越低。
- 取整符号$\lceil \cdot \rceil$ 表示向上取整,确保实际参数不弱于理论要求。
- 总空间$d \times w$ 个计数器,空间是常数,与数据流长度完全无关。
哈希函数选择:记账员的口诀必须足够“散”
记账员的口诀如果不好,比如两位都按“颜色”分类,那“红苹果”“红柿子”“红辣椒”全挤在同一个格子里,账目必然乱成一锅粥。CMS 对哈希函数的要求正是“散得均匀”:每个元素被映射到哪一格应当尽量随机、彼此独立,这样碰撞才会被摊平到所有格子上。
假设第 $j$ 行的哈希函数足够均匀,那么对于元素 $i$,其他所有元素的计数被“平均摊”到 $w$ 个格子上,单行估计的期望高估量不超过:
也就是说,每一行带来的污染期望是总数据量的 $w$ 分之一。再结合 $d$ 行取最小值,最终用 Markov 不等式收紧为 $P(\hat{f}_i \ge f_i + \varepsilon \|v\|_1) \le e^{-d}$,这就是误差界的来源。
实际工程中,CMS 常选用两个相互独立的哈希函数(如 MurmurHash 的两个种子)组合出 $d$ 路哈希,而不是真的准备 $d$ 个昂贵的随机哈希族。口诀够散,账才记得准——这是老王的格子板能成立的根本前提。
- 期望记号$E[\cdot]$ 表示随机期望,因为哈希函数是随机选取的,不同行碰撞情况不同。
- 单行计数器$C_j[h_j(i)]$ 是第 $j$ 行、元素 $i$ 映射到的那个格子里的值。
- 真实频率$f_i$ 是元素 $i$ 的真实出现次数,它是格子里“属于自己的部分”。
- 污染上限$\|v\|_1 / w$ 是其他元素摊到每个格子的平均计数,$w$ 越大污染越小。
- 求和不变量每行所有格子的计数总和恒等于总数据量 $\|v\|_1$,这是污染计算的基石。
Heavy Hitters 高频检测:找出菜市场里的“明星菜品”
老王经营久了发现,真正赚钱的往往就是那几样“明星菜”:西红柿、土豆、白菜,可能占据了三成营业额。他想自动找出这些高频项,这就是 Heavy Hitters 问题——找出频率超过某个阈值的元素。正式定义:给定相对阈值 $\phi$,若元素 $i$ 的真实频率满足
则称 $i$ 是 $\phi$-Heavy Hitter。CMS 做高频检测非常自然:由于估计值永远不小于真实值,所以真正的高频项一定不会被漏掉(不会漏报)。做法是让每个元素过一遍 CMS,凡是估计值超过阈值 $\phi \|v\|_1$ 的元素都进候选列表,再用精确计数器二次确认。
CMS 检测高频项时会有少量“虚高报名”(低频项因碰撞混进候选),但配合精确计数器复核,就能在常数空间内维护一份可靠的 Top-K 榜单。老王的菜市场热卖榜,就这样用一块小格子板支撑起来了。
- 阈值定义$\phi$ 是相对阈值,通常取 $0.1\% \sim 1\%$,即频率占比达到万分之几到百分之几。
- 总流量$\phi \|v\|_1$ 是把相对阈值换算成绝对次数,比如总流量 100 万、$\phi=0.01$ 则阈值为 1 万。
- 判定条件$f_i \ge \phi \|v\|_1$ 说明元素 $i$ 频率占比达标,是“明星菜”。
- 最小误差设置工程上令 $\varepsilon = \phi / 2$,使 CMS 的误差低于阈值的一半,避免高频项被噪声淹没。
与 HyperLogLog 协同:从“每样卖多少”到“一共多少样”
老王还有个常问的问题:“今天到底上了多少种菜?”这个“种类数”叫基数。格子板擅长回答“某样菜卖了多少次”,却不擅长回答“一共有多少种”——因为碰撞会让种类数虚高。HyperLogLog(HLL)恰好是解决基数估计的专业户:只用 1KB 左右就能估计亿级数据的去重数量。CMS 与 HLL 是一对天然搭档。
两者协同有一个漂亮的守恒性质:CMS 的每一行,所有格子的计数之和恒等于总数据量 $N$。借助 HLL 估计出的基数 $\hat{M}$,可以瞬间得到“平均每种菜卖了多少次”:
一句话总结协同:CMS 回答“每样多少次”,HLL 回答“有多少样”,两者合璧就能画出数据全貌。老王的菜市场管理台上,格子板报销量,另一台小仪器报品种数,两个数字一除,经营状况尽在掌握。
- 行守恒每行计数总和等于总数据量 $N$,无论碰撞多么严重,这个恒等式永远成立。
- 总数据量$N$ 是流中所有元素出现次数之和,可由 CMS 任一行求和得到。
- HLL 基数估计$\hat{M}_{\text{HLL}}$ 是 HyperLogLog 对去重数量(菜品品种数)的估计值。
- 平均频率$N / \hat{M}_{\text{HLL}}$ 给出“每种菜平均卖了多少份”,是平台运营的宏观指标。
实践应用:从菜市场到数据中心
老王的故事,其实是每一个大型系统都在面对的现实。在真实世界里,CMS 的三类典型场景贯穿了数据链路的各个环节。第一个是网络流量统计:交换机每秒处理百万级数据包,无法为每个 IP 建计数器;CMS 在数据平面实时累加,控制平面定期扫描,快速揪出流量异常的“大嘴”IP。第二个是实时热词与 Top-K 维护:微博热搜、电商大促榜单都面临每秒数十万次点击,CMS 先粗筛候选,再以最小堆维护 Top-K,兼顾速度与准确率。
第三个场景是日志错误聚合:分布式系统的错误码动辄上万种,日志像洪水一样涌来,CMS 让 SRE 一眼看出哪种错误码占比最高。在这些场景中,核心是权衡取舍公式:若希望检测占比超过 $\phi$ 的高频项,取
即在“误差上界 = 阈值一半、失败概率 = 总样本分之一”的条件下,用理论最小空间换取可靠的检测结果。
老王最终明白了一个道理:做生意不需要记住每一分钱,但需要知道大方向。CMS 的取舍哲学——用 1% 的误差换 100 倍的空间节省——正是大数据时代的生存智慧。
- 误差减半$\varepsilon = \phi / 2$ 保证 CMS 引入的误差不超过阈值的一半,高频项不会被噪声淹没。
- 失败概率$\delta = 1/N$ 让“保证失效”的概率低于总样本的倒数,工程上足够保守。
- 空间落地代入参数公式得 $w = \lceil 2e/\phi \rceil$,$d = \lceil \ln N \rceil$,在精度与成本之间取得平衡。
AI 应用场景
网络流量实时监控
阶段:流式数据预处理与异常检测
解决的问题:传统方案为每个 IP 维护精确计数器,在 DDoS 攻击或流量突增时内存瞬间爆炸;CMS 用固定格子在数据平面实时累加,毫秒级识别“大流量 IP”,配合 HLL 还能同时掌握连接种类数,快速定位异常行为。
具体案例:某云厂商的骨干交换机每秒转发 500 万包,团队部署 CMS 统计源 IP 频率,设置 $\phi = 0.1\%$,仅用 2MB 内存就实现了全网 Top-100 流量 IP 实时榜单,在攻击流量达到阈值 3 秒内触发告警。
电商实时热卖榜
阶段:实时特征工程与推荐排序
解决的问题:大促期间商品点击量每秒数十万,精确计数需要超大内存且写入瓶颈严重;CMS 将点击流压缩为固定矩阵,每 5 分钟扫描一次即可产出热卖 Top-K,让推荐系统拿到最新鲜的“流行信号”。
具体案例:某电商平台双十一期间,用 CMS 维护 10 万件候选商品的点击频率,内存占用从原来的 1.2GB 降到 3MB,热卖榜更新延迟从 10 分钟降到 1 分钟。
分布式日志错误聚合
阶段:可观测性与故障诊断
解决的问题:微服务日志每秒产生数十万条,错误码类型繁杂,精确正则统计 CPU 开销大;CMS 对错误类型做频率估计,快速给出“错误分布直方图”,让 SRE 优先处理占比最高的故障源。
具体案例:某金融系统夜间突发接口超时,SRE 用 CMS 聚合半小时日志,5 秒内发现“超时错误码”占比高达 47%,顺藤摸瓜定位到数据库连接池耗尽,避免了业务资损。
Python 演示
import mathimport random class CountMinSketch: """菜市场格子记账本: Count-Min Sketch 频率估计器""" def __init__(self, eps=0.01, delta=0.05): # w 决定误差上界, d 决定置信概率 self.w = int(math.ceil(math.e / eps)) self.d = int(math.ceil(math.log(1.0 / delta))) self.table = [[0] * self.w for _ in range(self.d)] def _hash(self, key, salt): # FNV 风格哈希: 为每个行号 salt 生成不同的映射 h = 2166136261 for ch in key: h = ((h ^ (ord(ch) + salt)) * 16777619) & 0xFFFFFFFF return h % self.w def add(self, key, count=1): # 每行各加一: 每个记账员都记一笔 for j in range(self.d): self.table[j][self._hash(key, j)] += count def estimate(self, key): # 查询: 取所有行对应格子的最小值 return min(self.table[j][self._hash(key, j)] for j in range(self.d)) def main(): random.seed(2024) words = ["apple", "banana", "cherry", "date", "elderberry", "fig", "grape", "kiwi"] truth = {} sketch = CountMinSketch(eps=0.01, delta=0.05) stream = [random.choice(words) for _ in range(5000)] for word in stream: sketch.add(word) truth[word] = truth.get(word, 0) + 1 print("word real est diff ratio") for word in sorted(truth, key=truth.get, reverse=True): est = sketch.estimate(word) print(f"{word:10s} {truth[word]:5d} {est:5d} {est - truth[word]:5d} {est / truth[word]:.3f}") rare = "zebra" print(f"\n未出现单词 '{rare}' 的估计: {sketch.estimate(rare)} (应为 0 或很小的噪声)") print(f"内存占用: {sketch.d} x {sketch.w} = {sketch.d * sketch.w} 个计数器") if __name__ == "__main__": main()
self.w = int(math.ceil(math.e / eps))每行格子数由误差参数 ε 决定,ε 越小格子越多。self.d = int(math.ceil(math.log(1.0 / delta)))行数由置信度 δ 决定,δ 越小行数越多。self.table = [[0] * self.w for _ in range(self.d)]创建 d 行 × w 列的二维计数矩阵。def _hash(self, key, salt):用 FNV 哈希加盐,为每一行生成不同的哈希函数。self.table[j][self._hash(key, j)] += count数据到达时,每一行各有一个格子加一。return min(self.table[j][self._hash(key, j)] for j in range(self.d))查询取所有行中的最小值,抵消碰撞污染。est / truth[word]打印估计值与真实值的比值,可直观看到误差比例。
常见误区
误区“我用 CMS 查到的频率有时候比真实值低,是不是实现有 bug?”
正解CMS 只会高估,绝不会低估。原因是格子里的计数只会因碰撞而被“多加”,绝不会被“减掉”;取最小值只是挑出污染最小的那一行,结果仍然不会低于真实值。如果看到偏低的估计,几乎可以肯定是哈希实现错误或参数设置不符合公式要求。
为何常见:人们看到“估计”二字就会默认误差是双向的,再加上“取最小值”的直觉,容易误以为是“压低”,其实 min 是在“多个偏高值里挑最低”.
误区“我加 100 行哈希,误差肯定趋近于零!”
正解行数 d 只影响置信概率(失败概率 δ = e^{-d}),对误差上界 ε 毫无贡献。d 从 3 加到 10,失败概率从 5% 降到万分之四;从 10 加到 100,收益微乎其微,内存却线性暴涨。真正减少误差要靠加大每行格子数 w。
为何常见:直觉上“多重保险”总能提升效果,忽略了两个参数各管一摊的事实,导致资源浪费。
误区“数据过期了,我直接对它映射的格子减一不就行了?”
正解基础 CMS 只支持增量,不支持删除。每个格子被许多元素共享,对格子减一会“伤及无辜”——其他映射到同一格子的元素估计值被压低,从而违反“估计 ≥ 真实”的核心保证。需要删除支持时应采用 Count-Mean-Min、CU sketch 等变体,或配合外部计数器做定期重建。
为何常见:常规哈希表都天然支持删除,惯性思维让人忽略了共享格子的“互相牵连”。
误区“CMS 也是哈希表,我能不能拿它做用户余额查询?”
正解CMS 是“近似聚合”结构,它回答“大概是多少、排名如何”,不回答“精确是多少”。逐笔精确场景(如金融对账)必须用精确结构;CMS 适合 Top-K、Heavy Hitters、流量分布等统计型需求,且应接受它的误差界。
为何常见:名字里带“哈希”再加上能快速查询,容易让人默认它是精确字典,忽略了“概率数据结构”的本质。
误区“既然叫空间高效,那我设置 w=64、d=2 就行了,省内存!”
正解空间与精度是此消彼长的硬约束,参数由公式 $w = \lceil e/\varepsilon \rceil$、$d = \lceil \ln(1/\delta) \rceil$ 决定。省得太多会导致误差失控,估计值几乎全是噪声;工程上应先用公式算理论最小值,再留 2~4 倍余量,才是性价比最高的配置。
为何常见:看到“节省空间”就以为没有代价,忽视了概率保证本质上是“用空间换取精度”的契约。
面试题
Q1. 关于 Count-Min Sketch 的估计值与真实频率的关系,下列说法正确的是?
- A.一定等于真实频率
- B.一定大于等于真实频率← 正确答案
- C.一定小于等于真实频率
- D.与真实频率完全无关
答案:B。CMS 的格子只能因碰撞被多加、不会少加,所以估计值永远不小于真实频率。取最小值只是在多个偏高值中挑最接近真相的一个。A 是精确结构的性质,C 方向反了,D 显然错误。
Q2. Count-Min Sketch 查询某个元素频率的时间复杂度是?
- A.O(log n)
- B.O(n)
- C.O(d),其中 d 为行数(固定常数,常表述为 O(1))← 正确答案
- D.O(w × d)
答案:C。查询过程只需计算 d 个哈希值、读取 d 个格子、取最小值。d 是初始化时确定的常量,与数据流长度无关,因此工程上认为查询是 O(1) 或严格写为 O(d)。
Q3. 用 CMS(w=2048, d=7)统计 100 万条数据,某元素真实频率为 300,以下哪个估计值不可能出现?
- A.300
- B.299← 正确答案
- C.305
- D.321
答案:B。CMS 的估计值永远不会低于真实频率,299 < 300 违背了这个基本性质。300 表示恰好无碰撞,305 和 321 都是可能的碰撞高估结果。
Q4. CMS 查询时为什么取各行计数器的最小值,而不是平均值?
- A.平均值计算太慢,min 更快
- B.碰撞只会让计数器偏高,最小值是噪声最小的估值← 正确答案
- C.最小值保证结果一定非负
- D.平均值会低估真实频率
答案:B。每行的计数器值都是“真实值 + 非负碰撞噪声”,取平均值会把各行噪声也平均进去,系统性偏高;取最小值则挑出污染最小、最接近真实频率的那一行。min 不是更快,而是更准。
Q5. 若一个元素从未出现在数据流中,CMS 查询它的频率最可能是?
- A.恒等于 0
- B.大于等于 0,且可能因碰撞产生小幅噪声← 正确答案
- C.小于 0
- D.等于数据流总长度
答案:B。CMS 不记录键本身,只更新哈希格子。从未出现的元素虽然没有任何“自己的加一”,但可能与其他元素碰撞,导致某些格子里有计数,因此返回一个小噪声值;该值仍不小于 0,更不可能为负。
Q6. 其他条件不变,只增大每行计数器个数 w,会带来什么效果?
- A.误差上界变小,空间占用变大← 正确答案
- B.误差上界变大,空间占用变小
- C.置信概率提升
- D.没有任何影响
答案:A。误差上界 ε = e/w,w 越大每个格子平均碰撞越少、误差越小,但内存按 w 线性增长。置信概率由行数 d 决定,w 增大会降低误差而非提升置信度。
Q7. 下列哪些场景适合使用 Count-Min Sketch?
- A.每秒百万条数据的网络流量 Top IP 统计← 正确答案
- B.金融交易逐笔精确对账系统
- C.电商大促实时热卖榜单的粗粒度统计← 正确答案
- D.需要频繁删除任意元素的消息队列计数器
答案:AC。A 和 C 是“海量数据 + 可接受近似”的典型场景。B 要求逐笔精确、误差不可容忍,不适合概率结构;D 需要删除操作,基础 CMS 不支持,强行删除会破坏误差保证。
Q8. 关于用 CMS 做 Heavy Hitters 检测,下列说法正确的有?
- A.频率超过阈值 φN 的元素称为 Heavy Hitter← 正确答案
- B.可以用 CMS 先筛候选,再用精确计数器复核← 正确答案
- C.CMS 一定能准确判断每个元素是否高频
- D.CMS 筛出的候选可能包含实际频率未达阈值的元素← 正确答案
答案:ABD。A 是 Heavy Hitter 的定义;B 是工业界的标准两阶段做法;D 正确,碰撞会让低频项“虚高”混入候选。C 错误,CMS 存在误报——它会漏报高频项吗?不会,但会误报低频项,所以“准确判断每个元素”不成立。
Q9. 关于 Count-Min Sketch 与 HyperLogLog 的区别,正确的是?
- A.HLL 估计每个元素的频率,CMS 估计元素种类数
- B.CMS 估计每个元素的频率,HLL 估计数据集的不同元素数量(基数)← 正确答案
- C.两者都能精确返回每个元素的出现次数
- D.两者空间占用都随数据量线性增长
答案:B。CMS 解决“每个元素多少次”,HLL 解决“一共有多少种不同元素”,两者恰好互补。C 错在二者都是概率结构;D 错在二者空间都是常数级,与数据量无关。
Q10. 若对 CMS 执行“减一”操作(将某元素映射的格子数减 1),会导致什么后果?
- A.无影响,CMS 天然支持删除
- B.会使该元素的估计值变成负数
- C.会让所有映射到这些格子的其他元素估计值被低估,破坏误差保证← 正确答案
- D.会让计数矩阵发生溢出错误
答案:C。CMS 的每个格子被多个元素共享,对格子减一等于同时“扣减”了所有映射到该格子的元素,导致这些元素的估计值低于真实值,直接违反“估计 ≥ 真实”的核心保证。B 不一定为负,D 与整数溢出无关,A 完全错误。