神奇的HyperLogLog算法

原文连接:http://rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html?utm_source=tuicool&utm_medium=referralhtml

神奇的HyperLogLog算法

 

基数计数基本概念

基数计数(cardinality counting)一般用来统计一个集合中不重复的元素个数,例如统计某个网站的UV,或者用户搜索网站的关键词数量。数据分析、网络监控及数据库优化等领域都会涉及到基数计数的需求。 要实现基数计数,最简单的作法是记录集合中全部不重复的元素集合S_uS​u​​,当新来一个元素x_ix​i​​,若S_uS​u​​中不包含元素x_ix​i​​,则将x_ix​i​​加入S_uS​u​​,不然不加入,计数值就是S_uS​u​​的元素数量。这种作法存在两个问题:git

  1. 当统计的数据量变大时,相应的存储内存也会线性增加
  2. 当集合S_uS​u​​变大,判断其是否包含新加入元素x_ix​i​​的成本变大

大数据量背景下,要实现基数计数,首先须要肯定存储统计数据的方案,以及如何根据存储的数据计算基数值;另外还有一些场景下须要融合多个独立统计的基数值,例如对一个网站分别统计了三天的UV,如今须要知道这三天的UV总量是多少,怎么融合多个统计值。github

基数计数方法

B树

B树最大的优点是插入和查找效率很高,若是用B树存储要统计的数据,能够快速判断新来的数据是否已经存在,并快速将元素插入B树。要计算基数值,只须要计算B树的节点个数。 将B树结构维护到内存中,能够快速统计和计算,但依然存在问题,B树结构只是加快了查找和插入效率,并无节省存储内存。例如要同时统计几万个连接的UV,每一个连接的访问量都很大,若是把这些数据都维护到内存中,实在是够呛。redis

bitmap

bitmap能够理解为经过一个bit数组来存储特定数据的一种数据结构,每个bit位都能独立包含信息,bit是数据的最小存储单位,所以能大量节省空间,也能够将整个bit数据一次性load到内存计算。 若是定义一个很大的bit数组,基数统计中每个元素对应到bit数组的其中一位,例如bit数组 001101001001101001表明实际数组[2,3,5,8][2,3,5,8]。新加入一个元素,只须要将已有的bit数组和新加入的数字作按位或 (or)(or)计算。bitmap中1的数量就是集合的基数值。算法

bitmap有一个很明显的优点是能够轻松合并多个统计结果,只须要对多个结果求异或就能够。也能够大大减小存储内存,能够作个简单的计算,若是要统计1亿个数据的基数值,大约须要内存: 100000000/8/1024/1024 \approx≈ 12M
若是用32bit的int表明每一个统计数据,大约须要内存:
32*100000000/8/1024/1024 \approx≈ 381M数据库

bitmap对于内存的节约量是显而易见的,但仍是不够。统计一个对象的基数值须要12M,若是统计10000个对象,就须要将近120G了,一样不能普遍用于大数据场景。数组

几率算法

实际上目前尚未发现更好的在大数据场景中准确计算基数的高效算法,所以在不追求绝对准确的状况下,使用几率算法算是一个不错的解决方案。几率算法不直接存储数据集合自己,经过必定的几率统计方法预估基数值,这种方法能够大大节省内存,同时保证偏差控制在必定范围内。目前用于基数计数的几率算法包括:网络

  • Linear Counting(LC):早期的基数估计算法,LC在空间复杂度方面并不算优秀,实际上LC的空间复杂度与上文中简单bitmap方法是同样的(可是有个常数项级别的下降),都是O(N_{max})O(N​max​​);
  • LogLog Counting(LLC):LogLog Counting相比于LC更加节省内存,空间复杂度只有O(log_2(log_2(N_{max})))O(log​2​​(log​2​​(N​max​​)))
  • HyperLogLog Counting(HLL):HyperLogLog Counting是基于LLC的优化和改进,在一样空间复杂度状况下,可以比LLC的基数估计偏差更小。

下面将着重讲HLL的原理和计算过程。数据结构

HyperLogLog的惊人表现

上面咱们计算过用bitmap存储1一亿个统计数据大概须要12M内存;而在HLL中,只须要不到1K内存就能作到;redis中实现的HyperLogLog,只须要12K内存,在标准偏差0.81%的前提下,可以统计2^{64}2​64​​个数据。首先容我感叹一下数学的强大和魅力,那么几率算法是怎样作到如此节省内存的,又是怎样控制偏差的呢?app

首先简单展现一下HLL的基本作法,HLL中实际存储的是一个长度为mm的大数组SS,将待统计的数据集合划分红mm组,每组根据算法记录一个统计值存入数组中。数组的大小mm由算法实现方本身肯定,redis中这个数组的大小是16834,mm越大,基数统计的偏差越小,但须要的内存空间也越大。

这里有个HLL demo能够看一下HLL究竟是怎么作到这种超乎想象的事情的。

hyperloglog

  1. 经过hash函数计算输入值对应的比特串
  2. 比特串的低 t(t=log_2^m)t(t=log​2​m​​)位对应的数字用来找到数组SS中对应的位置 ii
  3. t+1t+1位开始找到第一个1出现的位置 kk,将 kk 记入数组S_iS​i​​位置
  4. 基于数组SS记录的全部数据的统计值,计算总体的基数值,计算公式能够简单表示为:\hat{n}=f(S)​n​^​​=f(S)

看到这里内心应该有无数个问号,这样真的就能统计到上亿条数据的基数了吗?我总结一下,先抛出三个疑问:

  1. 为何要记录第一个1出现的位置?
  2. 为何要有分桶数组 SS ?
  3. 经过分桶数组 SS 计算基数的公式是什么?

hyperloglog原理理解

举一个咱们最熟悉的抛硬币例子,出现正反面的几率都是1/2,一直抛硬币直到出现正面,记录下投掷次数kk,将这种抛硬币屡次直到出现正面的过程记为一次伯努利过程,对于nn次伯努利过程,咱们会获得nn个出现正面的投掷次数值k_1k​1​​,k_2k​2​​……k_nk​n​​,其中最大值记为k_{max}k​max​​,那么能够获得下面结论:

  1. nn次伯努利过程的投掷次数都不大于k_{max}k​max​​
  2. nn次伯努利过程,至少有一次投掷次数等于k_{max}k​max​​

对于第一个结论,nn次伯努利过程的抛掷次数都不大于k_{max}k​max​​的几率用数学公式表示为: 
P_n(X \le k_{max})=(1-1/2^{k_{max}})^nP​n​​(X≤k​max​​)=(1−1/2​k​max​​​​)​n​​

第二个结论至少有一次等于k_{max}k​max​​的几率用数学公式表示为: 
P_n(X \ge k_{max})=1-(1-1/2^{k_{max}-1})^nP​n​​(X≥k​max​​)=1−(1−1/2​k​max​​−1​​)​n​​

当n\ll 2^{k_{max}}n≪2​k​max​​​​时,P_n(X \ge k_{max})\approx0P​n​​(X≥k​max​​)≈0,即当nn远小于2^{k_{max}}2​k​max​​​​时,上述第一条结论不成立; 
当n\gg 2^{k_{max}}n≫2​k​max​​​​时,P_n(X \le k_{max})\approx0P​n​​(X≤k​max​​)≈0,即当nn远大于2^{k_{max}}2​k​max​​​​时,上述第二条结论不成立。 所以,咱们彷佛就能够用2^{k_{max}}2​k​max​​​​的值来估计nn的大小。

以上结论能够总结为:进行了nn次进行抛硬币实验,每次分别记录下第一次抛到正面的抛掷次数kk,那么能够用n次实验中最大的抛掷次数k_{max}k​max​​来预估实验组数量nn: \hat{n} = 2^{k_{max}}​n​^​​=2​k​max​​​​原型图 (1).png-18.1kB能够经过一组小实验验证一下这种估计方法是否基本合理。

回到基数统计的问题,咱们须要统计一组数据中不重复元素的个数,集合中每一个元素的通过hash函数后能够表示成0和1构成的二进制数串,一个二进制串能够类比为一次抛硬币实验,1是抛到正面,0是反面。二进制串中从低位开始第一个1出现的位置能够理解为抛硬币试验中第一次出现正面的抛掷次数kk,那么基于上面的结论,咱们能够经过屡次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,一样能够能够经过第一个1出现位置的最大值k_{max}k​max​​来预估总共有多少个不一样的数字(总体基数)。

这种经过局部信息预估总体数据流特性的方法彷佛有些超出咱们的基本认知,须要用几率和统计的方法才能推导和验证这种关联关系。HyperLogLog核心在于观察集合中每一个数字对应的比特串,经过统计和记录比特串中最大的出现1的位置来估计集合总体的基数,能够大大减小内存耗费。

如今回到第二节中关于HyperLogLog的第一个疑问,为何要统计hash值中第一个1出现的位置?
第一个1出现的位置能够类比为抛硬币实验中第一次抛到正面的抛掷次数,根据抛硬币实验的结论,记录每一个数据的第一个出现的位置kk,就能够经过其中最大值{k_{max}}k​max​​推导出数据集合的基数:\hat{n} = 2^{k_{max}}​n​^​​=2​k​max​​​​。

hyperloglog算法讲解

分桶平均

HLL的基本思想是利用集合中数字的比特串第一个1出现位置的最大值来预估总体基数,可是这种预估方法存在较大偏差,为了改善偏差状况,HLL中引入分桶平均的概念。 
一样举抛硬币的例子,若是只有一组抛硬币实验,运气较好,第一次实验过程就抛了10次才第一次抛到正面,显然根据公式推导获得的实验次数的估计偏差较大;若是100个组同时进行抛硬币实验,同时运气这么好的几率就很低了,每组分别进行屡次抛硬币实验,并上报各自实验过程当中抛到正面的抛掷次数的最大值,就能根据100组的平均值预估总体的实验次数了。

分桶平均的基本原理是将统计数据划分为mm个桶,每一个桶分别统计各自的{k_{max}}k​max​​并能获得各自的基数预估值 \hat{n}​n​^​​ ,最终对这些 \hat{n}​n​^​​ 求平均获得总体的基数估计值。LLC中使用几何平均数预估总体的基数值,可是当统计数据量较小时偏差较大;HLL在LLC基础上作了改进,采用调和平均数,调和平均数的优势是能够过滤掉不健康的统计值,具体的计算公式为:

回到第二节中关于HLL的第二个疑问,为何要有分桶数组  ?分桶数组是为了消减因偶然性带来的偏差,提升预估的准确性。那么分桶数组的大小怎么肯定呢? 
这是由算法实现方本身设定的,例如上面HLL demo中,设定统计数组的大小,若是函数获得的比特串是32位,须要其中6()位定位分桶数组中的桶的位置,还剩下26位(须要记录的出现1的位置的最大值是26),那么数组中每一个桶须要5()位记录1第一次出现的位置,整个统计数组须要花费的内存为: 
 
也就是用32bit的内存可以统计的基数数量为。

误差修正

上述通过分桶平均后的估计量看似已经很不错了,不过经过数学分析能够知道这并非基数n的无偏估计。所以须要修正成无偏估计。这部分的具体数学分析在“Loglog Counting of Large Cardinalities”中。

其中系数由统计数组的大小  决定,具体的公式为:

根据论文中分析结论,HLL与LLC同样是渐进无偏估计,渐进标准偏差表示为:

所以,统计数组大小  越大,基数统计的标准偏差越小,但须要的存储空间也越大,在 的状况下,HLL的标准偏差为1.1%。

虽然调和平均数可以适当修正算法偏差,但做者给出一种分阶段修正算法。当HLL算法开始统计数据时,统计数组中大部分位置都是空数据,而且须要一段时间才能填满数组,这种阶段引入一种小范围修正方法;当HLL算法中统计数组已满的时候,须要统计的数据基数很大,这时候hash空间会出现不少碰撞状况,这种阶段引入一种大范围修正方法。最终算法用伪代码能够表示为以下。

 
  1. m = 2^b # with b in [4...16]

  2.  
  3. if m == 16:

  4. alpha = 0.673

  5. elif m == 32:

  6. alpha = 0.697

  7. elif m == 64:

  8. alpha = 0.709

  9. else:

  10. alpha = 0.7213/(1 + 1.079/m)

  11.  
  12. registers = [0]*m # initialize m registers to 0

  13.  
  14. ###########################################################################

  15. # Construct the HLL structure

  16. for h in hashed(data):

  17. register_index = 1 + get_register_index( h,b ) # binary address of the rightmost b bits

  18. run_length = run_of_zeros( h,b ) # length of the run of zeroes starting at bit b+1

  19. registers[ register_index ] = max( registers[ register_index ], run_length )

  20.  
  21. ##########################################################################

  22. # Determine the cardinality

  23. DV_est = alpha * m^2 * 1/sum( 2^ -register ) # the DV estimate

  24.  
  25. if DV_est < 5/2 * m: # small range correction

  26. V = count_of_zero_registers( registers ) # the number of registers equal to zero

  27. if V == 0: # if none of the registers are empty, use the HLL estimate

  28. DV = DV_est

  29. else:

  30. DV = m * log(m/V) # i.e. balls and bins correction

  31.  
  32. if DV_est <= ( 1/30 * 2^32 ): # intermediate range, no correction

  33. DV = DV_est

  34. if DV_est > ( 1/30 * 2^32 ): # large range correction

  35. DV = -2^32 * log( 1 - DV_est/2^32)

redis中hyperloglog实现

redis正是基于以上的HLL算法实现的HyperLogLog结构,用于统计一组数据集合中不重复的数据个数。 redis中统计数组大小设置为,hash函数生成64位bit数组,其中  位用来找到统计数组的位置,剩下50位用来记录第一个1出现的位置,最大位置为50,须要 位记录。

那么统计数组须要的最大内存大小为:  基数估计的标准偏差为。能够学习一下redis中HyperLogLog的源码实现


参考阅读
Redis new data structure: the HyperLogLog
HyperLogLog — Cornerstone of a Big Data Infrastructure
解读Cardinality Estimation算法(第四部分:HyperLogLog Counting及Adaptive Counting)

相关文章
相关标签/搜索