如何判断一个元素在亿级数据中是否存在?

前言

最近有朋友问我这么一个面试题目:java

如今有一个很是庞大的数据,假设全是 int 类型。如今我给你一个数,你须要告诉我它是否存在其中(尽可能高效)。git

需求其实很清晰,只是要判断一个数据是否存在便可。github

但这里有一个比较重要的前提:很是庞大的数据面试

常规实现

先不考虑这个条件,咱们脑海中出现的第一种方案是什么?算法

我想大多数想到的都是用 HashMap 来存放数据,由于它的写入查询的效率都比较高。数据库

写入和判断元素是否存在都有对应的 API,因此实现起来也比较简单。数组

为此我写了一个单测,利用 HashSet 来存数据(底层也是 HashMap );同时为了后面的对比将堆内存写死:缓存

-Xms64m -Xmx64m -XX:+PrintHeapAtGC -XX:+HeapDumpOnOutOfMemoryError

为了方便调试加入了 GC 日志的打印,以及内存溢出后 Dump 内存。数据结构

@Test
    public void hashMapTest(){
        long star = System.currentTimeMillis();

        Set<Integer> hashset = new HashSet<>(100) ;
        for (int i = 0; i < 100; i++) {
            hashset.add(i) ;
        }
        Assert.assertTrue(hashset.contains(1));
        Assert.assertTrue(hashset.contains(2));
        Assert.assertTrue(hashset.contains(3));

        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - star));
    }

当我只写入 100 条数据时天然是没有问题的。函数

仍是在这个基础上,写入 1000W 数据试试:

执行后立刻就内存溢出。

可见在内存有限的状况下咱们不能使用这种方式。

实际状况也是如此;既然要判断一个数据是否存在于集合中,考虑的算法的效率以及准确性确定是要把数据所有 load 到内存中的。

Bloom Filter

基于上面分析的条件,要实现这个需求最须要解决的是如何将庞大的数据 load 到内存中。

而咱们是否能够换种思路,由于只是须要判断数据是否存在,也不是须要把数据查询出来,因此彻底没有必要将真正的数据存放进去。

伟大的科学家们已经帮咱们想到了这样的需求。

Burton Howard Bloom 在 1970 年提出了一个叫作 Bloom Filter(中文翻译:布隆过滤)的算法。

它主要就是用于解决判断一个元素是否在一个集合中,但它的优点是只须要占用很小的内存空间以及有着高效的查询效率。

因此在这个场景下在合适不过了。

Bloom Filter 原理

下面来分析下它的实现原理。

官方的说法是:它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。

听起来比较绕,可是经过一个图就比较容易理解了。

如图所示:

  • 首先须要初始化一个二进制的数组,长度设为 L(图中为 8),同时初始值全为 0 。
  • 当写入一个 A1=1000 的数据时,须要进行 H 次 hash 函数的运算(这里为 2 次);与 HashMap 有点相似,经过算出的 HashCode 与 L 取模后定位到 0、2 处,将该处的值设为 1。
  • A2=2000 也是同理计算后将 四、7 位置设为 1。
  • 当有一个 B1=1000 须要判断是否存在时,也是作两次 Hash 运算,定位到 0、2 处,此时他们的值都为 1 ,因此认为 B1=1000 存在于集合中。
  • 当有一个 B2=3000 时,也是同理。第一次 Hash 定位到 index=4 时,数组中的值为 1,因此再进行第二次 Hash 运算,结果定位到 index=5 的值为 0,因此认为 B2=3000 不存在于集合中。

整个的写入、查询的流程就是这样,汇总起来就是:

对写入的数据作 H 次 hash 运算定位到数组中的位置,同时将数据改成 1 。当有数据查询时也是一样的方式定位到数组中。 一旦其中的有一位为 0 则认为数据确定不存在于集合,不然数据可能存在于集合中

因此布隆过滤有如下几个特色:

  1. 只要返回数据不存在,则确定不存在。
  2. 返回数据存在,但只能是大几率存在。
  3. 同时不能清除其中的数据。

第一点应该都能理解,重点解释下 二、3 点。

为何返回存在的数据倒是可能存在呢,这其实也和 HashMap 相似。

在有限的数组长度中存放大量的数据,即使是再完美的 Hash 算法也会有冲突,因此有可能两个彻底不一样的 A、B 两个数据最后定位到的位置是如出一辙的。

这时拿 B 进行查询时那天然就是误报了。

删除数据也是同理,当我把 B 的数据删除时,其实也至关因而把 A 的数据删掉了,这样也会形成后续的误报。

基于以上的 Hash 冲突的前提,因此 Bloom Filter 有必定的误报率,这个误报率和 Hash 算法的次数 H,以及数组长度 L 都是有关的。

本身实现一个布隆过滤

算法其实很简单不难理解,因而利用 Java 实现了一个简单的雏形。

public class BloomFilters {

    /**
     * 数组长度
     */
    private int arraySize;

    /**
     * 数组
     */
    private int[] array;

    public BloomFilters(int arraySize) {
        this.arraySize = arraySize;
        array = new int[arraySize];
    }

    /**
     * 写入数据
     * @param key
     */
    public void add(String key) {
        int first = hashcode_1(key);
        int second = hashcode_2(key);
        int third = hashcode_3(key);

        array[first % arraySize] = 1;
        array[second % arraySize] = 1;
        array[third % arraySize] = 1;

    }

    /**
     * 判断数据是否存在
     * @param key
     * @return
     */
    public boolean check(String key) {
        int first = hashcode_1(key);
        int second = hashcode_2(key);
        int third = hashcode_3(key);

        int firstIndex = array[first % arraySize];
        if (firstIndex == 0) {
            return false;
        }

        int secondIndex = array[second % arraySize];
        if (secondIndex == 0) {
            return false;
        }

        int thirdIndex = array[third % arraySize];
        if (thirdIndex == 0) {
            return false;
        }

        return true;

    }


    /**
     * hash 算法1
     * @param key
     * @return
     */
    private int hashcode_1(String key) {
        int hash = 0;
        int i;
        for (i = 0; i < key.length(); ++i) {
            hash = 33 * hash + key.charAt(i);
        }
        return Math.abs(hash);
    }

    /**
     * hash 算法2
     * @param data
     * @return
     */
    private int hashcode_2(String data) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < data.length(); i++) {
            hash = (hash ^ data.charAt(i)) * p;
        }
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        return Math.abs(hash);
    }

    /**
     *  hash 算法3
     * @param key
     * @return
     */
    private int hashcode_3(String key) {
        int hash, i;
        for (hash = 0, i = 0; i < key.length(); ++i) {
            hash += key.charAt(i);
            hash += (hash << 10);
            hash ^= (hash >> 6);
        }
        hash += (hash << 3);
        hash ^= (hash >> 11);
        hash += (hash << 15);
        return Math.abs(hash);
    }
}
  1. 首先初始化了一个 int 数组。
  2. 写入数据的时候进行三次 hash 运算,同时把对应的位置置为 1。
  3. 查询时一样的三次 hash 运算,取到对应的值,一旦值为 0 ,则认为数据不存在。

实现逻辑其实就和上文描述的同样。

下面来测试一下,一样的参数:

-Xms64m -Xmx64m -XX:+PrintHeapAtGC
@Test
    public void bloomFilterTest(){
        long star = System.currentTimeMillis();
        BloomFilters bloomFilters = new BloomFilters(10000000) ;
        for (int i = 0; i < 10000000; i++) {
            bloomFilters.add(i + "") ;
        }
        Assert.assertTrue(bloomFilters.check(1+""));
        Assert.assertTrue(bloomFilters.check(2+""));
        Assert.assertTrue(bloomFilters.check(3+""));
        Assert.assertTrue(bloomFilters.check(999999+""));
        Assert.assertFalse(bloomFilters.check(400230340+""));
        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - star));
    }

执行结果以下:

只花了 3 秒钟就写入了 1000W 的数据同时作出来准确的判断。


当让我把数组长度缩小到了 100W 时就出现了一个误报,400230340 这个数明明没在集合里,却返回了存在。

这也体现了 Bloom Filter 的误报率。

咱们提升数组长度以及 hash 计算次数能够下降误报率,但相应的 CPU、内存的消耗就会提升;这就须要根据业务须要自行权衡。

Guava 实现

刚才的方式虽然实现了功能,也知足了大量数据。但其实观察 GC 日志很是频繁,同时老年代也使用了 90%,接近崩溃的边缘。

总的来讲就是内存利用率作的很差。

其实 Google Guava 库中也实现了该算法,下面来看看业界权威的实现。

-Xms64m -Xmx64m -XX:+PrintHeapAtGC

@Test
    public void guavaTest() {
        long star = System.currentTimeMillis();
        BloomFilter<Integer> filter = BloomFilter.create(
                Funnels.integerFunnel(),
                10000000,
                0.01);

        for (int i = 0; i < 10000000; i++) {
            filter.put(i);
        }

        Assert.assertTrue(filter.mightContain(1));
        Assert.assertTrue(filter.mightContain(2));
        Assert.assertTrue(filter.mightContain(3));
        Assert.assertFalse(filter.mightContain(10000000));
        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - star));
    }

也是一样写入了 1000W 的数据,执行没有问题。

观察 GC 日志会发现没有一次 fullGC,同时老年代的使用率很低。和刚才的一对比这里明显的要好上不少,也能够写入更多的数据。

源码分析

那就来看看 Guava 它是如何实现的。

构造方法中有两个比较重要的参数,一个是预计存放多少数据,一个是能够接受的误报率。 我这里的测试 demo 分别是 1000W 以及 0.01。

Guava 会经过你预计的数量以及误报率帮你计算出你应当会使用的数组大小 numBits 以及须要计算几回 Hash 函数 numHashFunctions

这个算法计算规则能够参考维基百科。

put 写入函数

真正存放数据的 put 函数以下:

  • 根据 murmur3_128 方法的到一个 128 位长度的 byte[]
  • 分别取高低 8 位的到两个 hash 值。
  • 再根据初始化时的到的执行 hash 的次数进行 hash 运算。
bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);

其实也是 hash取模拿到 index 后去赋值 1.

重点是 bits.set() 方法。

其实 set 方法是 BitArray 中的一个函数,BitArray 就是真正存放数据的底层数据结构。

利用了一个 long[] data 来存放数据。

因此 set() 时候也是对这个 data 作处理。

  • set 以前先经过 get() 判断这个数据是否存在于集合中,若是已经存在则直接返回告知客户端写入失败。
  • 接下来就是经过位运算进行位或赋值
  • get() 方法的计算逻辑和 set 相似,只要判断为 0 就直接返回存在该值。

mightContain 是否存在函数

前面几步的逻辑都是相似的,只是调用了刚才的 get() 方法判断元素是否存在而已。

总结

布隆过滤的应用仍是蛮多的,好比数据库、爬虫、防缓存击穿等。

特别是须要精确知道某个数据不存在时作点什么事情就很是适合布隆过滤。

这段时间的研究发现算法也挺有意思的,后续应该会继续分享一些相似的内容。

若是对你有帮助那就分享一下吧。

本问的示例代码参考这里:

https://github.com/crossoverJie/JCSprout

你的点赞与分享是对我最大的支持

相关文章
相关标签/搜索