所谓基数计数就是统计不重复元素的个数,典型的场景比如页面uv数,这类计数的特点是需要去重。
第一种思路——精确计数之set
即存储所有所有不重复元素的值,用一个set数据结构存。比如存储访问某一个页面的全部去重用户id,需要拿计数值时,只需要计算这个set的元素数目即可。
这个set根据场景,可能是内存中一个set数据结果,可能是mysql数据库中的一个表,也可能是redis里的set等等。
这个方案在基数值不大的情况下是可以的,毕竟方案本身足够简单,但是如果基数值很大时,就会有问题,这个set会很大,存储时需要花费很大空间。当然,如果说访问量不大,直接存db,倒也还行。但是如果访问量页很大,需要存入缓存时,比如redis的set,那么这个成本就很高了。所以说这个方案仅适用于基数不大的情况。
第二种思路——精确计数之bitmap
将所有不重复元素的值映射到bit位上,比如0101可以代表[0, 2]这个集合(从右向左,有两个1,分别是第0为和第2位,代表0和2)。这种方案,有多少种取值,就需要多少个bit位。举个例子,有1亿种不同取值,需要空间为:100,000,000/8/1000/1000 = 12.5MB空间,相比于第一种方案,已经很节约了。但是,该方案有一个弊端是:所需要空间大小与当前出现存储的值的数量无关,而与不同值的数目上限有关,也就是说,如果有1亿种取值,即便现在只出现了一个值,那么也需要分配12.5MB的空间,这对于稀疏值的场景并不友好。
第三种思路——概率计数之HLL
之前介绍的两种计数方案的优点是结果精确,缺点是需要较大的空间。如果某些需求对于结果的精确性要求不高,可以考虑概率计数,这种方案在计数结果上会有一定误差,但是需要较少的空间。具体怎么选,看业务场景。
这里先简单介绍下其背后概率原理。
将每一种出现的值转换为二进制数,定义k(a)为当前值二进制表示中最右侧的“1”出现的位置(最右或者最左都无所谓,一致就行)。举个例子:假设当前值是9,二进制表示为1001,最右侧1的位置时3,那么“5”这个取值的k(a)=3。
显然,每一种取值都能取到一个k(a),记其中最大的k(a)为kmax,那么基数可以按如下公式估计:
n = 2^kmax
为啥呢?
每一种取值的k(a)计算过程可以看成是一次伯努利过程:
假设现在有n个取值,那么相当于进行了n次伯努利过程。
设n次伯努利kmax值为k,注意k是变量,那么我们计算如下概率:
1.n次伯努利都不大于k的概率为:p1= (1-1/2^k)^n
2.n次伯努利至少有一次等于k的概率为:p2 = 1 - (1-1/2^k)^n
(关于2,看到很多博客写的是1 - (1-1/2^(k-1))^n,我推出来的是k,不知道别人为啥推出来是k-1,有了解的欢迎指正)。
关于推导过程不做深入,高中数学题。
有了这俩基本的公式,我们再来看下,假设现在有n的不同的值,并且k(a)的最大值为kmax',我们可以得出如下结论:
1.这n次伯努利实验的k(a)值一定不大于kmax',因为kmax'是目前统计到的最大值了,这是一个结论;
2.这n次伯努利实验的k(a)值一定至少有一个等于kmax',因为这是kmax'确确实实是我们统计到的值,所以一定存在,这是另一个结论;
将kmax'和n套到之前推到的公式里:
p1= (1-1/2^kmax')^n = 100%;
p2 = 1 - (1-1/2^kmax')^n = 100%
但是,假设n << 2^kmax',p2 ≈ 0,所以n << 2^kmax'不成立;假设n >> 2^max',p1 ≈ 0,所以n >> 2^max'不成立。这么说,n只能约等于 2^kmax'。证明了结论。
这个推理比较绕,我看了网上的介绍,似乎不是特别直观,这里加了一些自己的理解。另外这也只是一个大致的直观感受,严格的证明还得看论文。
再看下这个算法的空间复杂度:Space = O(log2(log2N))
为啥呢?
假设有N种可能的不同值,kmax的取值近似是log2N,而存kmax这个值需要的空间是log2kmax(比如kmax = 8,那么存8这个值,需要4个bit,8 = 1000),所以空间复杂度就是log2(log2N)。
这是HLL的基本原理,但是很显然,直接使用2^kmax来估计n是不准确的,但是如果我们多估计几次呢?估计次数越多,结果更精确。所以HLL里使用的是多次估计的一个调和平均值。
如何多次估计呢?
可以将hash空间分为多个组,将hash值的前m位当做组号,剩余的位用来计算k(a),这样每一个取值,就会进行2^m次k(a),然后将所有k(a)的调和平均值作为最终的k(a)。
redis使用
redis使用了16384个桶,12KB空间,0.81%的误差。
示例:
127.0.0.1:6379> pfadd site1_uv 10056
(integer) 1
127.0.0.1:6379> pfadd site1_uv 10057
(integer) 1
127.0.0.1:6379> pfadd site1_uv 10058
(integer) 1
127.0.0.1:6379> pfadd site1_uv 10059
(integer) 1
127.0.0.1:6379> pfcount site1_uv
(integer) 4
pfadd:往集合里加一个元素;
pfcount:返回基数;
pfmerge:格式为pfmerge targetSet sourceSet1 souceSet2....,可以将多个集合合并为一个。
参考:
https://blog.youkuaiyun.com/firenet1/article/details/77247649
http://fhanddx.top/index.php/2019/05/12/redisyuanmapouxihyperloglog/
https://mp.weixin.qq.com/s/h2xEbWIQJ8YuZuT6-tfmBQ
https://www.sohu.com/a/285007716_771850