每日算法 — 使用java实现HyperLogLog:概率性基数估计与调和均值分桶策略

算法简介

HyperLogLog是一种概率性数据结构,用于在极小内存空间内估计大规模数据集中不同元素的数量(即基数,Cardinality)。仅需几KB内存即可精确估计数十亿级别数据的去重计数,误差率可控制在2%以内。该算法被广泛应用于大数据实时统计、网络流量分析、数据库去重计数等场景。

核心思想

HyperLogLog的核心思想基于概率统计:

  1. 哈希映射:将所有元素通过哈希函数映射为均匀分布的比特串
  2. 前导零统计:观察哈希值中从最高位开始的连续0的个数。直观上,看到更多前导零的概率随元素数量增加而增大
  3. 分桶平均:使用多个寄存器(桶)分别统计,通过调和均值消除极端值影响,提高估计精度
  4. 偏差修正:对小基数和大基数场景分别进行修正,确保全范围精度

算法的关键洞察:如果看到最长前导零长度为 $k$,则估计基数约为 $2^k$。多个桶的统计结果通过调和均值合并,得到更稳定的估计。

Java实现

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.BitSet;

/**
 * HyperLogLog基数估计算法实现
 * 
 * @author AlgorithmDaily
 */
public class HyperLogLog {

    // 寄存器数量参数 p,总寄存器数 m = 2^p
    private final int p;
    private final int m;

    // 寄存器数组,每个元素存储该桶观察到的最大前导零长度
    private final byte[] registers;

    // 哈希函数
    private final HashFunction hashFunction;

    // 偏差修正常数 alpha_m
    private final double alphaMM;

    /**
     * 哈希函数接口
     */
    interface HashFunction {
        long hash(String input);
    }

    /**
     * 构造HyperLogLog计数器
     * @param p 精度参数,寄存器数量为 2^p,通常取4-16
     */
    public HyperLogLog(int p) {
        if (p < 4 || p > 16) {
            throw new IllegalArgumentException("p must be between 4 and 16");
        }
        this.p = p;
        this.m = 1 << p;  // 2^p
        this.registers = new byte[m];
        this.hashFunction = new MurmurHash3();
        this.alphaMM = computeAlphaMM(m);
    }

    /**
     * 计算偏差修正常数
     */
    private double computeAlphaMM(int m) {
        switch (m) {
            case 16: return 0.673;
            case 32: return 0.697;
            case 64: return 0.709;
            default: return 0.7213 / (1 + 1.079 / m);
        }
    }

    /**
     * 添加一个元素到HyperLogLog
     * @param element 待统计的元素
     */
    public void add(String element) {
        long hash = hashFunction.hash(element);

        // 取前p位作为桶索引
        int bucketIndex = (int) (hash >>> (64 - p));

        // 剩余位用于计算前导零数量
        long remainingBits = hash & ((1L << (64 - p)) - 1);

        // 计算前导零数量(从最高位开始连续0的个数)
        int leadingZeros = countLeadingZeros(remainingBits, 64 - p);

        // 更新寄存器:保留最大值
        if (leadingZeros > registers[bucketIndex]) {
            registers[bucketIndex] = (byte) leadingZeros;
        }
    }

    /**
     * 计算long值的前导零数量
     * @param value 待计算的数值
     * @param bits 有效位数
     * @return 前导零数量(从最高位开始连续0的个数,至少为1)
     */
    private int countLeadingZeros(long value, int bits) {
        if (value == 0) return bits + 1;  // 全为0的特殊处理

        int zeros = 1;
        long mask = 1L << (bits - 1);

        while ((value & mask) == 0 && zeros <= bits) {
            zeros++;
            mask >>>= 1;
        }

        return zeros;
    }

    /**
     * 估计当前集合的基数(去重后的元素数量)
     * @return 基数估计值
     */
    public long count() {
        // 计算调和均值
        double sumInverse = 0.0;
        int zeroRegisters = 0;

        for (byte reg : registers) {
            sumInverse += 1.0 / (1L << reg);  // 2^(-register)
            if (reg == 0) zeroRegisters++;
        }

        double harmonicMean = m / sumInverse;
        double estimate = alphaMM * m * harmonicMean;

        // 小范围修正:当估计值小于 2.5*m 且存在空寄存器时
        if (estimate <= 2.5 * m && zeroRegisters > 0) {
            estimate = m * Math.log((double) m / zeroRegisters);
        }
        // 大范围修正:当估计值超过 2^32/30 时(64位哈希)
        else if (estimate > (1L << 32) / 30.0) {
            double correction = -Math.pow(2, 32) * Math.log(1 - estimate / Math.pow(2, 32));
            if (!Double.isNaN(correction)) {
                estimate = correction;
            }
        }

        return (long) estimate;
    }

    /**
     * 合并另一个HyperLogLog(必须具有相同的p参数)
     */
    public void merge(HyperLogLog other) {
        if (this.p != other.p) {
            throw new IllegalArgumentException("Cannot merge HLLs with different precision");
        }
        for (int i = 0; i < m; i++) {
            if (other.registers[i] > this.registers[i]) {
                this.registers[i] = other.registers[i];
            }
        }
    }

    /**
     * 获取当前内存使用量(字节)
     */
    public int getMemoryUsage() {
        return registers.length;
    }

    /**
     * MurmurHash3 64位哈希函数实现
     */
    static class MurmurHash3 implements HashFunction {
        private static final long C1 = 0x87c37b91114253d5L;
        private static final long C2 = 0x4cf5ad432745937fL;

        @Override
        public long hash(String input) {
            byte[] data = input.getBytes(StandardCharsets.UTF_8);
            int length = data.length;
            long h1 = 0;
            long seed = 0x9747b28c;

            int roundedEnd = (length & ~0x7);

            for (int i = 0; i < roundedEnd; i += 8) {
                long k1 = ((long) data[i] & 0xff)
                        | (((long) data[i + 1] & 0xff) << 8)
                        | (((long) data[i + 2] & 0xff) << 16)
                        | (((long) data[i + 3] & 0xff) << 24)
                        | (((long) data[i + 4] & 0xff) << 32)
                        | (((long) data[i + 5] & 0xff) << 40)
                        | (((long) data[i + 6] & 0xff) << 48)
                        | (((long) data[i + 7] & 0xff) << 56);

                k1 *= C1;
                k1 = Long.rotateLeft(k1, 31);
                k1 *= C2;
                h1 ^= k1;
                h1 = Long.rotateLeft(h1, 27);
                h1 = h1 * 5 + 0x52dce729;
            }

            long k1 = 0;
            switch (length & 0x7) {
                case 7: k1 ^= ((long) data[roundedEnd + 6] & 0xff) << 48;
                case 6: k1 ^= ((long) data[roundedEnd + 5] & 0xff) << 40;
                case 5: k1 ^= ((long) data[roundedEnd + 4] & 0xff) << 32;
                case 4: k1 ^= ((long) data[roundedEnd + 3] & 0xff) << 24;
                case 3: k1 ^= ((long) data[roundedEnd + 2] & 0xff) << 16;
                case 2: k1 ^= ((long) data[roundedEnd + 1] & 0xff) << 8;
                case 1: k1 ^= ((long) data[roundedEnd] & 0xff);
                    k1 *= C1;
                    k1 = Long.rotateLeft(k1, 31);
                    k1 *= C2;
                    h1 ^= k1;
            }

            h1 ^= length;
            h1 ^= h1 >>> 33;
            h1 *= 0xff51afd7ed558ccdL;
            h1 ^= h1 >>> 33;
            h1 *= 0xc4ceb9fe1a85ec53L;
            h1 ^= h1 >>> 33;

            return h1;
        }
    }

    /**
     * 主程序:演示HyperLogLog基数估计
     */
    public static void main(String[] args) {
        // 创建精度为14的HyperLogLog(16384个寄存器,约16KB内存)
        HyperLogLog hll = new HyperLogLog(14);

        // 测试场景1:精确已知基数
        int actualCardinality = 1_000_000;
        System.out.println("=== HyperLogLog基数估计测试 ===");
        System.out.println("实际去重基数: " + actualCardinality);

        // 添加100万个不同的元素
        for (int i = 0; i < actualCardinality; i++) {
            hll.add("user_" + i);
        }

        // 添加一些重复元素(不应影响计数)
        for (int i = 0; i < 500_000; i++) {
            hll.add("user_" + (i % actualCardinality));
        }

        long estimate = hll.count();
        double errorRate = Math.abs(estimate - actualCardinality) * 100.0 / actualCardinality;

        System.out.println("估计基数: " + estimate);
        System.out.printf("误差率: %.4f%%%n", errorRate);
        System.out.println("内存使用: " + hll.getMemoryUsage() + " 字节");

        // 测试场景2:合并两个HyperLogLog
        HyperLogLog hll1 = new HyperLogLog(14);
        HyperLogLog hll2 = new HyperLogLog(14);

        for (int i = 0; i < 500_000; i++) hll1.add("item_" + i);
        for (int i = 250_000; i < 750_000; i++) hll2.add("item_" + i);

        hll1.merge(hll2);
        System.out.println("\n合并后估计基数: " + hll1.count() + " (期望约500000)");
    }
}

复杂度分析

  • 时间复杂度:$O(1)$,每次添加元素只需一次哈希计算和常数时间的寄存器更新
  • 空间复杂度:$O(2^p)$,使用 $2^p$ 个字节的寄存器数组。例如 $p=14$ 时仅需16KB内存即可估计数十亿级基数
  • 查询复杂度:$O(2^p)$,需要遍历所有寄存器计算调和均值

算法特点与适用场景

优点:
– 极低的内存占用,几KB即可处理海量数据
– 支持合并操作,天然适合分布式场景
– 添加和查询均为常数时间复杂度

缺点:
– 概率性算法,存在一定估计误差(标准误差约1.04/√m)
– 无法获取具体的去重元素列表
– 对小基数的估计需要额外修正

适用场景:网站UV统计、数据库去重计数、网络流量分析、日志分析中的唯一IP统计、大规模数据集基数估计等。