如何判斷一個元素在億級數據中是否存在?

前言

最近有朋友問我這麼一個面試題目: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

你的點贊與分享是對我最大的支持

相關文章
相關標籤/搜索