做者:林冠宏 / 指尖下的幽灵html
掘金:juejin.im/user/587f0d…java
博客:www.cnblogs.com/linguanh/python
GitHub : github.com/af913337456…git
腾讯云专栏: cloud.tencent.com/developer/u…github
虫洞区块链专栏:www.chongdongshequ.com/article/153…golang
PS:本人技术书籍:《区块链以太坊DApp开发实战》已经出版并能够网购了redis
若是要实现这么一个功能:算法
统计 APP或网页 的一个页面,天天有多少用户点击进入的次数。同一个用户的反复点击进入记为 1 次。编程
聪明的你可能会立刻想到,用 HashMap
这种数据结构就能够了,也知足了去重。的确,这是一种解决方法,除此以外还有其它的解决方案。数组
问题虽不难,但当参与问题中的变量达到必定数量级的时候,再简单的问题都会变成一个难题。假设 APP 中日活用户达到百万
或千万以上级别
的话,咱们采用 HashMap
的作法,就会致使程序中占用大量的内存。
咱们下面尝试估算下 HashMap
的在应对上述问题时候的内存占用。假设定义HashMap
中 Key
为 string
类型,value
为 bool
。key
对应用户的Id
,value
是是否点击进入
。明显地,当百万不一样用户访问的时候。此HashMap
的内存占用空间为:100万 * (string + bool)
。
能够说,在上述问题目前现有的解决方案中,HashMap
是内存占用量最多的一种。若是统计量很少,那么可使用这种方法解决问题,实现起来也简单。
除此以外还有B+ 树
,Bitmap 位图
,以及该文章主要介绍的 HyperLogLog
算法解决方案。
在必定条件容许下,若是容许统计在巨量数据面前的偏差率在可接受的范围内,1000万浏览量容许最终统计出少了一两万这样子,那么就能够采用HyperLogLog
算法来解决上面的计数相似问题。
HyperLogLog
,下面简称为HLL
,它是 LogLog
算法的升级版,做用是可以提供不精确的去重计数。存在如下的特色:
Redis
中实现的 HyperLogLog
,只须要12K
内存就能统计2^64
个数据。辅助计算因子
进行下降。稍微对编程中的基础数据类型内存占用有了解的同窗,应该会对其只须要12K
内存就能统计2^64
个数据而感到惊讶。为何这样说呢,下面咱们举下例子:
取 Java
语言来讲,通常long
占用8字节,而一字节有8位,即:1 byte = 8 bit,即long
数据类型最大能够表示的数是:2^63-1
。对应上面的2^64
个数,假设此时有2^63-1
这么多个数,从 0 ~ 2^63-1
,按照long
以及1k = 1024字节
的规则来计算内存总数,就是:((2^63-1) * 8/1024)K
,这是很庞大的一个数,存储空间远远超过12K
。而 HyperLogLog
却能够用 12K
就能统计完。
在认识为何HyperLogLog
可以使用极少的内存来统计巨量的数据以前,要先认识下伯努利试验
。
伯努利试验
是数学几率论
中的一部份内容,它的典故来源于抛硬币
。
硬币拥有正反两面,一次的上抛至落下,最终出现正反面的几率都是50%。假设一直抛硬币,直到它出现正面为止,咱们记录为一次完整的试验,间中可能抛了一次就出现了正面,也可能抛了4次才出现正面。不管抛了多少次,只要出现了正面,就记录为一次试验。这个试验就是伯努利试验
。
那么对于屡次的伯努利试验
,假设这个屡次为n
次。就意味着出现了n
次的正面。假设每次伯努利试验
所经历了的抛掷次数为k
。第一次伯努利试验
,次数设为k1
,以此类推,第n
次对应的是kn
。
其中,对于这n
次伯努利试验
中,必然会有一个最大的抛掷次数k
,例如抛了12次才出现正面,那么称这个为k_max
,表明抛了最多的次数。
伯努利试验
容易得出有如下结论:
最终结合极大似然估算的方法,发如今n
和k_max
中存在估算关联:n = 2^(k_max)
。这种经过局部信息预估总体数据流特性的方法彷佛有些超出咱们的基本认知,须要用几率和统计的方法才能推导和验证这种关联关系。
例以下面的样子:
第一次试验: 抛了3次才出现正面,此时 k=3,n=1
第二次试验: 抛了2次才出现正面,此时 k=2,n=2
第三次试验: 抛了6次才出现正面,此时 k=6,n=3
第n 次试验:抛了12次才出现正面,此时咱们估算, n = 2^12
复制代码
假设上面例子中实验组数共3组,那么 k_max = 6,最终 n=3,咱们放进估算公式中去,明显: 3 ≠ 2^6 。也便是说,当试验次数很小的时候,这种估算方法的偏差是很大的。
在上面的3组例子中,咱们称为一轮的估算。若是只是进行一轮的话,当 n 足够大的时候,估算的偏差率会相对减小,但仍然不够小。
那么是否能够进行多轮呢?例如进行 100 轮或者更多轮次的试验,而后再取每轮的 k_max,再取平均数,即: k_mx/100
。最终再估算出 n。下面是LogLog
的估算公式:
上面公式的DVLL
对应的就是n
,constant
是修正因子,它的具体值是不定的,能够根据实际状况而分支设置。m
表明的是试验的轮数。头上有一横的R
就是平均数:(k_max_1 + ... + k_max_m)/m
。
这种经过增长试验轮次,再取k_max
平均数的算法优化就是LogLog
的作法。而 HyperLogLog
和LogLog
的区别就是,它采用的不是平均数
,而是调和平均数
。调和平均数
比平均数
的好处就是不容易受到大的数值的影响。下面举个例子:
求平均工资:
A的是1000/月,B的30000/月。采用平均数的方式就是: (1000 + 30000) / 2 = 15500
采用调和平均数的方式就是: 2/(1/1000 + 1/30000) ≈ 1935.484
明显地,调和平均数
比平均数
的效果是要更好的。下面是调和平均数
的计算方式,∑
是累加符号。
上面的内容咱们已经知道,在抛硬币的例子中,能够经过一次伯努利试验中出现的k_max
来估算n
。
那么这种估算方法如何和下面问题有所关联呢?
统计 APP或网页 的一个页面,天天有多少用户点击进入的次数。同一个用户的反复点击进入记为 1 次
HyperLogLog
是这样作的。对于输入的数据,进行下面几个步骤:
经过hash
函数,将数据转为比特串
,例如输入5,便转为:101。为何要这样转化呢?
是由于要和抛硬币对应上,比特串
中,0 表明了反面,1 表明了正面,若是一个数据最终被转化了 10010000
,那么从右往左,从低位往高位看,咱们能够认为,首次出现 1 的时候,就是正面。
那么基于上面的估算结论,咱们能够经过屡次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,一样也就能够根据存入数据中,转化后的出现了 1 的最大的位置 k_max 来估算存入了多少数据。
分桶就是分多少轮。抽象到计算机存储中去,就是存储的是一个以单位是比特(bit),长度为 L 的大数组 S ,将 S 平均分为 m 组,注意这个 m 组,就是对应多少轮,而后每组所占有的比特个数是平均的,设为 P。容易得出下面的关系:
在 Redis
中,HyperLogLog
设置为:m=16834,p=6,L=16834 * 6。占用内存为=16834 * 6 / 8 / 1024 = 12K
形象化为:
第0组 第1组 .... 第16833组
[000 000] [000 000] [000 000] [000 000] .... [000 000]
复制代码
如今回到咱们的原始APP页面统计用户的问题中去。
在这个统计问题中,不一样的用户 id 标识了一个用户,那么咱们能够把用户的 id 做为被hash
的输入。即:
hash(id) = 比特串
不一样的用户 id,必然拥有不一样的比特串
。每个比特串
,也必然会至少出现一次 1 的位置。咱们类比每个比特串
为一次伯努利试验
。
如今要分轮
,也就是分桶
。因此咱们能够设定,每一个比特串
的前多少位转为10进制后,其值就对应于所在桶的标号。假设比特串
的低两位用来计算桶下标志,此时有一个用户的id的比特串
是:1001011000011。它的所在桶下标为:11(2) = 1*2^1 + 1*2^0 = 3
,处于第3个桶,即第3轮中。
上面例子中,计算出桶号后,剩下的比特串
是:10010110000,从低位到高位看,第一次出现 1 的位置是 5 。也就是说,此时第3个桶,第3轮的试验中,k_max = 5
。5 对应的二进制是:101,又由于每一个桶有 p 个比特位。当 p>=3 时,即可以将 101 存进去。
模仿上面的流程,多个不一样的用户 id,就被分散到不一样的桶中去了,且每一个桶有其 k_max。而后当要统计出 mian
页面有多少用户点击量的时候,就是一次估算。最终结合全部桶中的 k_max,代入估算公式,便能得出估算值。
下面是 HyperLogLog
的结合了调和平均数的估算公式,变量释意和LogLog
的同样:
首先,在 Redis 中,HyperLogLog 是它的一种高级数据结构。提供有包含但不限于下面两条命令:
回想一下,原始APP页面统计用户的问题。若是 key 对应页面名称,value 对应用户id。那么问题就刚恰好对应上了。
前面咱们已经认识到,它的实现中,设有 16384 个桶,即:2^14 = 16384,每一个桶有 6 位,每一个桶能够表达的最大数字是:2^5+2^4+...+1 = 63 ,二进制为: 111 111
。
对于命令:pfadd key value
在存入时,value 会被 hash 成 64 位,即 64 bit 的比特字符串,前 14 位用来选择这个 value 的比特串中从右往左
第一个 1 出现的下标位置数值要存到那个桶中去,即前 14 位用来分桶。设第一个1出现位置的数值为 index 。当 index=5 时,就是: ....10000 [01 0000 0000 0000]
之因此选 14位
来表达桶编号是由于,分了 16384 个桶,而 2^14 = 16384,恰好地,最大的时候能够把桶利用完,不形成浪费。假设一个字符串的前 14 位是:00 0000 0000 0010 (从右往左看) ,其十进制值为 2。那么 index 将会被转化后放到编号为 2 的桶。
index 的转化规则:
首先由于完整的 value 比特字符串是 64 位形式,减去 14 后,剩下 50 位,那么极端状况,出现 1 的位置,是在第 50 位,即位置是 50。此时 index = 50。此时先将 index 转为 2 进制,它是:110010 。
由于16384 个桶中,每一个桶是 6 bit 组成的。恰好 110010 就被设置到了第 2 号桶中去了。请注意,50 已是最坏的状况,且它都被容纳进去了。那么其余的不用想也确定能被容纳进去。
由于 fpadd 的 key 能够设置多个 value。例以下面的例子:
pfadd lgh golang
pfadd lgh python
pfadd lgh java
复制代码
根据上面的作法,不一样的 value,会被设置到不一样桶中去,若是出现了在同一个桶的,即前 14 位值是同样的,可是后面出现 1 的位置不同。那么比较原来的 index 是否比新 index 大。是,则替换。否,则不变。
最终地,一个 key 所对应的 16384 个桶都设置了不少的 value 了,每一个桶有一个k_max
。此时调用 pfcount 时,按照前面介绍的估算方式,即可以计算出 key 的设置了多少次 value,也就是统计值。
value 被转为 64 位的比特串,最终被按照上面的作法记录到每一个桶中去。64 位转为十进制就是:2^64,HyperLogLog
仅用了:16384 * 6 /8 / 1024 K
存储空间就能统计多达 2^64 个数。
在估算的计算公式中,constant
变量不是一个定值,它会根据实际状况而被分支设置,例以下面的样子。
假设:m为分桶数,p是m的以2为底的对数。
// m 为桶数
switch (p) {
case 4:
constant = 0.673 * m * m;
case 5:
constant = 0.697 * m * m;
case 6:
constant = 0.709 * m * m;
default:
constant = (0.7213 / (1 + 1.079 / m)) * m * m;
}
复制代码
由简单的抛硬币试验能够引导出如此的震撼的算法,数学之强大。
感谢下面两遍博文的指引:
本文全部图片来源于:
本文内容参考于:
www.rainybowe.com/blog/2017/0…
手动直观观察 LogLog
和 HyperLogLog
变化的网站: