这其实是个概率问题,一个例子揭秘 HyperLogLog 的高效计数技巧 !

背景

当我们浏览博客文章时,经常会看到诸如浏览次数、阅读次数、收藏次数这类统计信息。类似地,我还想到其它网站的一些使用场景,例如抖音某个视频的点赞数量、电子商务网站某件商品访问者数量等等。这激发了我的好奇心,我想知道如何通过程序来统计网站的这些计数。

简单快速的解决方案

首先,我能想到的最有效的数据结构是集合(Set),因为集合不允许元素重复,因此集合的大小就可以表示集合内不重复元素的计数。下面是一段利用集合来统计不重复访客数量的代码:

public class UniqueVisitors {
    private Set<String> visitors = new HashSet<>();

    public void addVisitor(String ip) {
        visitors.add(ip);
    }

    public int getUniqueVisitorCount() {
        return visitors.size();
    }

    public static void main(String[] args) {
        UniqueVisitors uniqueVisitors = new UniqueVisitors();
        uniqueVisitors.addVisitor("192.168.0.1");
        uniqueVisitors.addVisitor("192.168.0.2");
        uniqueVisitors.addVisitor("192.168.0.1");
        System.out.println("Unique Visitors: " + uniqueVisitors.getUniqueVisitorCount());
    }
}

//输出
Unique Visitors: 2

任务完成了吗?

刚写完上面的代码时,我的反应就像这个著名的“成功小子”一样得意。然而,如果进一步思考:每次有新访客访问页面时,我们都需要将这个访客的信息存储在内存中(可以根据IP地址/会话等来识别新用户)。如果网站的规模像微信、淘宝这样庞大,每天有数亿用户访问,这个解决方案还能有效吗?

上面的解决方案需要与用户数量成比例的内存空间。假设我们有1亿访客,让我们做个简单的计算:

访问者对象的空间 = 100 B
访问者数量 = 1亿
所需总空间 = 1亿 * 100 B ≈ 9.31 GB

这意味着我们需要大约9.31 GB的空间来获取唯一访客的计数。因此,这个算法在大规模应用场景并不高效——浪费了很多空间。

我在谷歌上搜索互联网大厂是如何解决这个问题的,令人惊讶的是,这个看起来很简单的问题竟然是计算机科学中最著名的问题之一,称为“计数独特问题”。

在搜索这个问题过程中,我接触到了HyperLogLog算法,它可以高效地计算集合中的不同元素数量,并且消耗的空间很少。

下面让我们一起看看这个算法 !

HyperLogLog

正如我们在前面的例子中看到的那样,如果使用Set统计计数,会得到一个准确的计数值,但是,随着规模的增加,消耗的内存空间也会增加。

其实,对于像博客文章点赞,转发量这些场景其实并不需要显示百分百准确的计数,一定误差(例如小于3%)是完全可以接受的。

HyperLogLog算法可以做到用不到1.5KB内存来计算海量数据集基数的问题,并且误差估计小于2%。

基本工作原理

在深入研究 HyperLogLog 的内部工作原理之前,先假设您正在和朋友在玩一个游戏,在这个游戏中,你朋友会将一块硬币抛很多次,并记录下最多连续出现正面的次数n。然后,他会将这个n告诉你,让你猜测他大概抛了多少次硬币。

例如,如果你朋友告诉你n=1,那么他大概抛了2次, 因为抛一次硬币出现正面的概率是1/2,那平均来说,你朋友需要大约抛2次硬币才能得到一次正面。第一次掷可能是正面,也可能不是,所以大约需要2次尝试。

如果你朋友告诉你n=2,那么他大概抛了4次,因为连续出现2次正面的概率是 1/2 * 1/2 = 1/4。因为只有1/4的可能性能够连续两次掷出正面,平均来说,你需要掷4次硬币才能达到这个结果。

这是一个简单的概率应用:如果某个事件的概率是1/n,那么通常你需要尝试n次才能期待发生一次这种事件。

因此,如果连续出现了 n 次正面(概率为1/(2^n)),那么预计需要的总掷硬币次数大约是 2^n 次。

HyperLogLog算法使用了相同的原理,不同之处在于HyperLogLog算法中使用1和0代替正面和反面。

哈希函数的输出通常是均匀分布的,这意味着每一个可能的输出值出现的概率是相同的。对于一个N位的二进制哈希输出,每个位都有1/2的概率是0或1。

在均匀分布的情况下,得到一个有k个前导0的哈希值的概率是1/2k。如果我们集合中n个元素得到的n个哈希值中最大的前导0个数是k,意味着我们需要大约2k个元素来“填满”这个概率空间,也就是说n为2k,即集合中元素的个数大概是2k个

总结一下,如果我们在哈希结果集中观察到的前导零最大个数是k,就可以估计出这个数据集的基数大约为2k。也就是说,数据集中不同元素的数量约为2k。

让我们回顾一下之前计算网站上唯一访客数量的例子。为简单起见,我们使用用户的 IP 地址作为用户标识符。此 IP 地址传递给哈希函数,n个用户IP对应n个哈希值,其中哈希值中前导零最大数量为20 ,那么就可以估算出用户数量为2²⁰ 。

下面是这种方法的两个缺点:

  1. 计数总是2的幂次方数。所以有效值只能是{1,2,4,8,16,32,64,128,256,512,1024…等}。但事实上,我们要统计的指标如点赞数其数量可以是任意数值。这导致准确性差。

  2. 这种方法的变异性很大。如果我们只有一个访客,幸运的话其哈希值10个连续的前导0,根据算法就会错误地估计有 2^10 = 1024 个访客。而且错的离谱。

提高准确性和减少变异

在上述方法中,我们做一个小小的改变:不依赖于单个哈希函数,而是使用多个哈希函数来减少变异。对于每个哈希函数,我们计算前导零的数量,然后找到算术平均值,并使用这个平均值来得到基数。

例如,如果我们使用10个哈希函数,其中一个哈希函数的输出有20个前导零,而其他9个哈希函数可能有一个前导零。在这种情况下,平均值将是(201+19)/10 =2.9,基数是2^(2.9),比2²⁰要好。

除了使用多个哈希函数之外,还有一种替代方法是利用Durand 和 Flajolet提出的解决方法。

下面是这个方法的主要流程:

1. 哈希值分桶,记录前导零

首先,将哈希函数的输出分成多个桶。桶记录遇到的最大连续零的数量。

假设有个用户的 IP 地址是 192.168.0.1。我们把这个 IP 地址通过哈希函数得到的哈希值是 10110000100001001000。我们使用哈希值的前四位来决定它属于哪个桶。这里前四位是 1011,转换为十进制是 11,所以它属于第 11 号桶。

接着,我们从右边开始数哈希值中的连续零的数量。在这个例子中,哈希值 10110000100001001000 从右边开始有 3 个连续的零。因此把第 11 号桶的值更新为 3。

2. 更新桶中的值

当我们处理新的数据时,比如另一个用户的 IP 地址(如 192.168.0.2),再次通过哈希函数得到新的哈希值,比如 1011000010000100000。我们用这个新哈希值的前四位 1011 发现它仍然属于第 11 号桶。此时,我们从右边开始数,发现哈希值 1011000010000100000 中有 5 个连续的零。

由于新哈希值中连续零的数量(5)比第 11 号桶当前记录的数量(3)多,我们更新第 11 号桶的值为 5。

3. 计算基数

最终,我们计算所有桶的最大前导零数量的调和平均数,然后使用这个调和平均数来估算数据集的基数。

计算基数的公式是:

上式中的常数为 0.7942,用于纠正偏差。m是桶数。第三项是调和平均值,Rjs 表示从最左边的位开始的最大零的数量。

误差估计为 1.05/(√m),其中 m 是桶数。当 m=2048 时,误差估计降低至 2.32%。对于 100 万人口,我们的估计值将在 976798 和 1023201 之间。

Java 中 HLL 的实际实现

在下面的代码中,使用了 Google 的 guava java 库提供的 Murmur3_32 哈希函数。迭代了数百万个整数并计算每个整数的哈希值。由于输出是 32 位整数,因此前 5 位用于将哈希值分配给存储桶。我们有 2⁵ = 32 个存储桶,每个存储桶保存从最右边的位开始的最大连续零。

在计算出哈希所属的存储桶之后,我们将获得该哈希的最大尾随零,如果哈希的最大尾随零大于存储桶中存储的零,则更新存储桶(第 19-23 行)。

我们使用上一节提到的基数公式来估计元素的数量。

public class HyperLogLog {
    public static void main(String[] args) throws Exception{
        HashMap<Integer, Integer> buckets = new HashMap<>();
        HashFunction hashFunction = Hashing.murmur3_32();

        for (int x = 0; x < 1000000; ++x) {
            int msb = hashFunction.hashInt(x).asInt() & 0xf8000000;
            msb = msb >>> 27;
            int trailingZeroes = trailingZeroes(hashFunction.hashInt(x).asInt());
            if (!buckets.containsKey(msb)) {
                buckets.put(msb, trailingZeroes);
            } else if (trailingZeroes > buckets.get(msb)) {
                buckets.put(msb, trailingZeroes);
            }
        }
        buckets.forEach((key, value) -> {
            System.out.println("bucket = " + key + " leading zeroes = " + value);
        });
        System.out.println("Harmonic mean = " + harmonicMean(new ArrayList<>(buckets.values())));
        System.out.println(32*0.79*harmonicMean(new ArrayList<>(buckets.values())));
    }
    static float harmonicMean(List<Integer> arr)
    {
        float sum = 0;
        for (int i = 0; i < arr.size(); i++)
            sum = sum + (float)1 / (int)Math.pow(2, arr.get(i));

        return (float)32/sum;
    }

    static int trailingZeroes(int number) {
        int count = 0;
        for (int index = 1; index <= 27; ++index) {
            if (getNthBit(number, index) == 1)
                break;
            ++count;
        }
        return count;
    }

    static int getNthBit(int num, int n) {
        return ((num >> (n - 1)) & 1);
    }
}
  • 29
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值