前言
在很久(好像也没多久,4个月)之前,我曾经写了一篇和主业无关的有点意思的小文章《基数估计探秘:Linear Counting与Flajolet-Martin算法》。但是这篇文章讲的两个算法都已经老掉牙了,实际应用最广泛的基数估计算法是HyperLogLog(HLL)算法。
最近笔者基于Flink搞了些从超大行为数据集计算UV的工作,用到了Redis的HyperLogLog,感觉是时候写个续篇了。在读本文之前,强烈建议看官先读之前的那篇,就算不深挖细节,也能够获得一些基数估计方面的背景知识。
LogLog Counting(LLC)算法
等等,不是叫HyperLogLog么,怎么这里变LogLog了?很简单,HLL是基于LLC的改进,所以我们有必要了解LLC。该算法由M. Durand、P. Flajolet在2003年的论文《Loglog Counting of Large Cardinalities》中首次提出。下面看看这个算法是怎么操作的。
首先我们得有一个哈希函数H(x),并且满足如下条件:
- 哈希结果尽量服从均匀分布;
- 碰撞概率尽量小;
- 结果是长度固定为L的二进制串。
然后定义符号ρ(y),它代表将数y表示成二进制串后,从左向右读遇到的第一个“1”的位置下标(下标从1开始,忽略全0的情况),也就是前导0的个数+1。
LogLog Counting算法的流程如下:
- 对每个待测集合中的元素x,计算它的哈希值y=H(x)。
- 将哈希值y通过它们的前k=logm个比特分组(即分桶),作为桶的编号,即一共有m个桶。
- 将y的后L - k个比特作为真正参与基数估计的串s,计算并记录下所有桶内的ρ(s)。
- 令M[k]表示第k个桶内所有元素中最大的那个ρ值,那么该集合基数的估计量为:
Ñ = αm · m · 2ΣM[i] / m
其中αm是修正参数。有没有觉得这个算法流程有点似曾相识的意思?
实际上,它与前面说过的Flajolet-Martin算法的PCSA变种非常相似,也采用了分桶平均的思想,只不过在精确度方面又做了一些其他工作。因此与它相关的细节(比如为什么ρmax可以作为估计量)可以参考上一篇文章。
那么α是怎么来的呢?这个过程极其复杂,直接说结论吧。根据之前对伯努利实验的分析,可以知道:
Pn{X=k} = (1 - 1/2k)n - (1 - 1/2k-1)n
它是一个无穷递推数列,采用指数生成函数和泊松化的方法处理,得到估计量的泊松期望和方差如下:
进而推出算法流程第4步的估计量。这是一个渐进无偏估计量,其中:
这是LLC比F-M算法更精细的地方之一。
LLC的空间复杂度是多少呢?不难得知,在F-M算法中,我们可以用logN个比特来存储哈希值,而LLC算法只存储下标(即ρ值)就够用了,所以可以降低为log(logN)个比特。再加上分桶数为m,亦即它的空间复杂度是:
O[m · log(logN)]
这也就是LogLog Counting这个名字的由来。
根据论文中给出的数据,LLC的误差在m不算太小(大于64)时,大概是:
StdError ≈ 1.30 / √m
虽然LLC的误差常数比F-M算法的还大(F-M是0.78),但是由于空间复杂度从log级别降低到了loglog级别,所以相同误差下实际的空间开销要更小。
分桶数m完全由可接受的精确度决定。但这个误差的前提是实际基数N要远远大于分桶数m(因为Ñ是渐近无偏的),所以在集合比较小时,LLC算法的效果要打折扣,这点与F-M-PCSA是相同的。
LLC算法本质上不是个新的算法,并且也从未大规模地使用过。它的主要意义有三:
- 与F-M算法相比,经过了非常完备的实验分析与验证;
- 在精确度和空间占用之间取得了更好的平衡;
- 作为后续HyperLogLog算法产生的基础。
HyperLogLog(HLL)算法
HLL由四位大佬P. Flajolet、É. Fusy、O. Gandouet、F. Meunier(全是法语名字)在2007年的论文《HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm》中提出。从论文题目可以得知,这个算法已经相当优化了。实际上它的算法流程与LLC仍然几乎相同,那么它到底“hyper”在哪里呢?
一是分桶平均的时候,采用调和平均数。我们知道,调和平均数的定义是:
n / [1/x1 + 1/x2 + ... + 1/xn] = n / [Σ 1/xi]
在LLC算法中,采用的是ρmax的算术平均数,并且它是作为2的指数,所以本质上是几何平均数。但是几何平均数受离群值(即偏离均值很大的值)的影响非常大,分桶的空桶越多,LLC的估计值就越不准确。调和平均数就不太有这种困扰,所以集合基数的估计量就会变成:
Ñ = αm · m2 / Σ 2-M[i]
修正参数是:
为了实际应用起来方便,一般采用如下的近似值:
二是根据基数估计值的大小,采用不同的估计方法进行修正。论文中给出了在常见情况——即被估集合的基数在亿级别以下,m取值在2[4, 16]区间——下的修正的算法,如下图所示。
翻译成人话:
- 在估计值Ñ ≤ 5m/2时,使用前面讲过的Linear Counting算法估计,因为它在基数较小时偏差也较小;
- 5m/2 < Ñ ≤ 232/30时,直接使用HLL的结果;
- Ñ > 232/30时,结果为-232log(1 - Ñ / 232)。
HLL算法的空间复杂度与LLC相同,而标准差为:
StdError ≈ 1.04 / √m
可见,HLL算法的精度确实比LLC更高。根据论文中的描述,估计亿级别集合的基数,在偏差大约2%的情况下,只需要耗费大约1.5kB内存。
HLL算法在很多框架中都有实现,其中尤以Redis的实现方法最为有名,但它的代码量极大,如果写在文章里的话会特别冗长,所以只是简单说说吧。
Redis使用了214=16384个桶,按照上面的标准差,误差为0.81%,精度相当高。Redis使用一个long型哈希值的前14个比特用来确定桶编号,剩下的50个比特用来做基数估计。而26=64,所以只需要用6个比特表示下标值,在一般情况下,一个HLL数据结构占用内存的大小为16384 * 6 / 8 = 12kB,Redis将这种情况称为密集(dense)存储。
既然有了密集存储,自然就会有稀疏(sparse)存储。当多数桶的值为全0时,为了节省空间,Redis会将连续的全0桶压缩成0桶计数值。该计数值可以用单字节或双字节表示,高2bit为标志位,因此可以分别表示连续64个全0桶和连续16384个全0桶。
Redis在HLL稀疏存储中用ZERO宏表示单字节全0桶,XZERO宏表示双字节全0桶,VAL表示中途的少数非0桶。举个例子,假设只有第10001个桶和第10047个桶的值为1,其余都为0,那么整个存储布局就是:
XZERO (10000) | VAL (1) | ZERO (45) | VAL (1) | XZERO (6337)
只需要5字节就能存储了。当稀疏存储的总大小超过hll_sparse_max_bytes
参数指定的大小(默认为3kB)时,就会自动转换成密集存储。Redis还会在HLL数据结构的头部信息中缓存上一次计算出来的基数估计值,这样可以避免不必要的重算,提高效率。
除了插入元素的PFADD指令之外,Redis提供的计数指令PFCOUNT和合并指令PFMERGE都支持将多个HLL合并起来。由于HLL的桶只存储下标值,因此合并时只需要按桶取最大值就可以了。Redis的HLL算法在上述标准算法的基础上做了一点改进,比如预打表计算2-M[i]的值,以及用多项式回归修正结果等。
最后推荐一个国外大佬做的HLL算法的动态Demo,简单直观,有助于理解,下面给出截图以及网页地址。
The End
晚安晚安。