1. 引言:从一道经典的面试题说起
在海量数据处理领域,有一个几乎被问烂了的问题:
“给你 10 亿个 IP 地址,内存限制只有1MB,如何计算其中有多少个唯一的 IP(基数统计,Cardinality Estimation)?”
如果使用 std::set 或 HashSet,光是存储这 10 亿个 IP 可能就会耗尽内存。即便使用 Bitmap,在数据稀疏的情况下依然不够节省。
今天我们聊聊 HyperLogLog (HLL)。它是一种概率算法,仅需几KB内存,就能以标准误差小于 2% 的精度估算 10 亿级数据的基数。
2. 直觉:抛硬币的“黑魔法”
想象你在玩一个简单的抛硬币游戏:反面记为 0,正面记为 1。规则很简单:你不断抛硬币,直到出现 第一个正面(1) 时,本轮游戏结束。
根据概率论,我们可以观察到一组有趣的现象:
- 第 1 次就抛出正面的概率是 \(\frac{1}{2}\);
- 连续 2 次反面后才出现正面的概率是 \(\frac{1}{8}\);
- 连续 \(n\) 次反面后才出现正面的概率是 \(\frac{1}{2^{n+1}}\)。
这里隐藏着一个核心逻辑: 如果你观测到了一个极其罕见的序列,比如连续出现了 20 个反面才看到正面(概率约为百万分之一),那么直觉会告诉你:为了碰上这么一次“狗屎运”,你肯定已经在大后方默默尝试了上百万次。
我们将这个直觉转化为推断公式:
如果我们在实验中观测到的“连续反面”的最大长度为 \(n\),那么我们可以大胆推测,总共大约进行了 \(2^{n+1}\) 次实验。
注意: 这只是一个“合理的推测”而非精确值。正如你不可能通过一次好运就断定自己是亿万富翁一样,HyperLogLog 也是通过这种概率分布来做近似估算。
3. 从抛硬币到比特流:HyperLogLog 的数学底座
在计算机的世界里,万物皆比特(0 和 1)。我们可以巧妙地将数据对应的二进制串,看作一系列“抛硬币”的结果:0 代表反面,1 代表正面。
我们规定:从右向左扫描(从低位向高位),直到遇见第一个 1(正面)时,本轮“游戏”结束。
- 序列
...01:第一次就抛出正面,游戏结束。抛掷次数 = 1。 - 序列
...010:第一次反面,第二次正面。抛掷次数 = 2。(注意:第一个1之后的所有位,在概率推断中均可忽略,因为“游戏”已经结束了)。 - 序列
...1000:前三次均为反面,第四次才看到正面。抛掷次数 = 4。 - 序列
...1后面跟着 \(n\) 个0:说明连续出现了 \(n\) 次反面。
现在我们来做一道概率题: 在随机分布的比特流中,连续出现 \(n\) 个 0 才结束游戏的概率是多少?答案很简单:\(\frac{1}{2^{n+1}}\)。
逆向思维: 假设我手里有一堆二进制串,其中我观察到的最长连续零长度是 \(n\)。根据前面的结论,我们可以反推:为了撞见这个“奇迹”,我大约已经看过了 \(2^{n+1}\) 个不同的二进制串。
这就是 HyperLogLog 的核心原理:
- 观察极端值:不记录数据本身,只记录所有数据中观测到的最大连续零的长度(记为 \(\rho\))。
- 基数推测:利用这个最大长度反推数据的唯一数量(基数)。
一句话总结: 看到连续的 \(n\) 个 \(0\),就说明大概率已经有 \(2^{n+1}\) 种不同的数据来过这里。
4. 落地中的挑战:从理论走向工程
4.1 问题一:打破规律性(为什么需要 Hash)
刚才的数学推导建立在一个 “强假设” 之上:比特流中出现 0 和 1 的概率必须严格满足 1:1,且位与位之间相互独立。换句话说,这必须是一枚 “绝对公平的硬币”。
但在现实工程中,原始数据往往是极其“不公平”的。试想我们要统计一组连续的 User ID:1000, 1001, 1002... 它们的二进制表示在长段前缀上几乎完全相同:
1000->...0011111010001001->...001111101001
如果这些 ID 本身恰好带有大量的连续零(比如低位 ID),HLL 就会产生严重的幻觉,误以为观测到了概率极其微小的“稀有事件”,从而给出一个天文数字般的错误估算。
为了修补这个漏洞,我们必须引入 Hash 函数。
虽然本文不深入探讨各种哈希算法的实现,但请务必记住它的核心使命:将规律的数据“打散”,转化为均匀分布、随机扰动的比特流。 在 HyperLogLog 的世界里,哈希不是为了加密,而是为了确保我们抛出的每一枚硬币都是“公平”的。

4.2. 问题二:如何消除“暴发户”偏差
即便硬币是公平的,概率依然存在极端情况。万一你运气爆棚,第一轮就抛出了 30 个连续的 0 呢?
算法会天真地认为你已经拥有了 10 亿条数据,而实际上你可能只插入了一条。这种单点极端值带来的偏差,在统计学上是灾难性的。
为了让结果回归理性,HyperLogLog 引入了两大“降噪”利器:分桶(Bucketing) 与 调和平均(Harmonic Mean)。
4.2.1 分桶:不把鸡蛋放在一个篮子里
既然一个人的“运气”不可靠,那我们就观察一群人的平均水平。
我们将 64 位的哈希值(或原始二进制流)切分为两部分:
- 高 \(b\) 位(桶索引):用来决定这个数据该去哪个“房间”。如果 \(b=10\),我们就拥有 \(m = 2^{10} = 1024\) 个独立的桶(Registers)。
- 剩余位(抛硬币):在对应的桶里记录它所看到的最大连续零的长度。
这样一来,原本的一个“超级大猜测”,被拆分成了 1024 个“独立小猜测”。即使某个桶因为运气爆棚记录了一个异常大的值,它也只能影响 1024 分之一的决策权重,无法左右大局。

4.2.2 调和平均:对“暴发户”的降维打击
汇总这 1024 个桶的结果时,如果使用简单的算术平均,效果会非常糟糕。
举个例子:
假设 1023 个桶的值都是 1,只有一个桶因为极端运气变成了 100。
- 算术平均:\((1 \times 1023 + 100) / 1024 \approx 1.1\)。看起来波动不大?别忘了,HLL 的估算是指数级的!原本是 \(2^1\),现在变成了 \(2^{1.1}\),误差会被瞬间放大。
为此,HLL 采用了调和平均数(Harmonic Mean):
调和平均数有一个非常重要的特性:它对极大的异常值(暴发户)非常不敏感,而对较小的值更敏感。

通过这种方式,那些因为偶然“运气好”而产生的极端大值会被有效地平滑掉,从而让估算结果稳稳地落在真实基数附近。
4.2.3 修正参数:消除算法的“自带偏差”
即便有了分桶和调和平均,数学家们在实验中发现,HLL 的原始估算公式(直接用调和平均反推)依然存在一个系统性的固有偏差。
简单来说,就是算法在逻辑上会习惯性地“高估”或“低估”真实基数,且这种偏差与桶的数量 \(m\) 有关。
为了让估算值尽可能逼近真实值,HLL 引入了一个修正常数 \(\alpha_m\)。在不同的分桶精度下,这个常数有所不同:
- 当 \(m=16\) 时,\(\alpha_m = 0.673\)
- 当 \(m=32\) 时,\(\alpha_m = 0.697\)
- 当 \(m \ge 128\) 时,\(\alpha_m\) 趋于稳定值: $$\alpha_m \approx \frac{0.7213}{1 + 1.079/m}$$
你在代码中看到的0.7213 / (1 + 1.079 / m)这一串数字,就像是给这台精密的天平做最后的 “调零”。如果没有它,HLL 的误差曲线会整体偏移。
5. 具体实现
在实现 HyperLogLog 时,我们需要重点关注两个工程细节:高效的位运算和小范围修正。
核心实现要点:
- 硬件加速:我们使用了 GCC 内置的
__builtin_ctzll指令。它能直接调用 CPU 级别的指令(如 x86 上的BSF或TZCNT)来统计末尾零的个数,这比手写循环判断位要快得多。 - 内存效率:每个寄存器(Register)仅占用 8 bits(
uint8_t),因为 64 位整数的连续零长度不可能超过 64。在 1024 个桶的配置下,整个数据结构仅占用 1 KB 内存。 - Linear Counting 修正:概率算法在数据量极小时(桶还没被填满)误差较大。因此,当估算值较小时,我们切换到 Linear Counting 算法,利用空桶的比例来计算基数,从而保证全量程的准确性。
#include <stdio.h>
#include <stdint.h>
#include <math.h>
#include <string.h>// 精度参数 k (桶数为 2^10 = 1024)
#define HLL_PRECISION 10
#define HLL_REGISTERS (1 << HLL_PRECISION)
#define HLL_MASK (HLL_REGISTERS - 1)typedef struct {uint8_t registers[HLL_REGISTERS];
} HyperLogLog;/*** 简单的 64 位哈希函数 (MurmurHash3 混合部分)* 在生产环境中,建议直接使用完整的 MurmurHash3 或 CityHash*/
uint64_t hash_64(const void *key, int len) {uint64_t h = 0x12345678abcdefLL;const uint8_t *data = (const uint8_t *)key;for (int i = 0; i < len; i++) {h ^= data[i];h *= 0xbf58476d1ce4e5b9LL;h ^= h >> 33;}return h;
}/*** 获取第一个 1 出现的位置 (ρ 值)* 剔除掉用于分桶的位,统计剩余位中末尾 0 的个数*/
uint8_t get_rho(uint64_t hash) {uint64_t v = hash >> HLL_PRECISION;if (v == 0) return 64 - HLL_PRECISION;// 使用内置函数统计末尾 0 的个数,+1 表示第一个 1 出现的位置return (uint8_t)__builtin_ctzll(v) + 1;
}// 初始化:将所有寄存器清零
void hll_init(HyperLogLog *hll) {memset(hll->registers, 0, HLL_REGISTERS);
}// 添加元素:更新对应桶的最大 ρ 值
void hll_add(HyperLogLog *hll, const void *data, int len) {uint64_t hash = hash_64(data, len);int index = hash & HLL_MASK; // 提取低位作为桶索引uint8_t rho = get_rho(hash);if (rho > hll->registers[index]) {hll->registers[index] = rho;}
}// 估算基数:调和平均数汇总 + 偏差修正
double hll_count(HyperLogLog *hll) {double m = HLL_REGISTERS;// αm 修正系数double alpha_m = 0.7213 / (1 + 1.079 / m);double sum = 0.0;for (int i = 0; i < HLL_REGISTERS; i++) {// 计算 1 / 2^R[i]sum += 1.0 / (1ULL << hll->registers[i]);}double estimate = alpha_m * m * m / sum;// 小范围修正 (Linear Counting):// 如果估算值小于 2.5 * 桶数,利用空桶率进行修正if (estimate <= 2.5 * m) {int zeros = 0;for (int i = 0; i < HLL_REGISTERS; i++) {if (hll->registers[i] == 0) zeros++;}if (zeros > 0) {estimate = m * log(m / (double)zeros);}}return estimate;
}int main() {HyperLogLog hll;hll_init(&hll);printf("开始模拟插入 100,000 个唯一元素...\n");for (int i = 0; i < 100000; i++) {char buf[32];sprintf(buf, "element_%d", i);hll_add(&hll, buf, strlen(buf));}printf("实际元素: 100000\n");printf("估算基数: %.2f\n", hll_count(&hll));printf("误差率: %.2f%%\n", fabs(hll_count(&hll) - 100000) / 100000 * 100);return 0;
}
运行结果:

6. 结语:数学、工程与 AI 的浪漫交响
HyperLogLog 这个略显怪诞的名字,实则镌刻着算法进化的三部曲,如同一场宏大的交响乐章:
- Log(对数): 核心律动。算法捕捉哈希值中连续零的位长 \(\rho\),将宏大的基数映射为优雅的对数比例。
- LogLog(双重对数): 承前启后。2003 年,Philippe Flajolet 证明了仅需极小空间,便能窥见海量数据的轮廓。
- Hyper(超越): 最终华章。2007 年,调和平均数与偏差修正的引入,将精度推向了“超越”极限的高度。
解决工程瓶颈的钥匙,往往并非单纯的“暴力算力”,而是深邃的“数学模型”。 这种“以虚御实”的哲学,在当今 AI 的参数压缩与高效检索中依然大放异彩。
从一枚硬币的随机博弈,到十亿级数据的基数估计,HyperLogLog 完美诠释了计算机科学的终极浪漫:以方寸内存,容纳寰宇信息;以概率之“朦胧”,换取性能之“巅峰”。
一位优秀的工程师能让代码无误;而一位卓越的工程师,则能借数学之美,令程序触碰物理极限。
本文深入浅出地解析了 HyperLogLog算法的核心原理。从“抛硬币”的直觉出发,揭示了如何利用哈希比特流中的极端观测值推断海量数据的基数。并提供了一套基于 C 语言的实现方案。展示了如何通过严谨的数学模型,在极低内存下实现十亿级数据的高精度估算。