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

来源:https://www.cnblogs.com/crossoverJie/archive/2018/11/26/10018231.html
-Advertisement-
Play Games

最近有朋友問我這麼一個面試題目: 現在有一個非常龐大的數據,假設全是 int 類型。現在我給你一個數,你需要告訴我它是否存在其中(儘量高效)。 ...


前言

最近有朋友問我這麼一個面試題目:

現在有一個非常龐大的數據,假設全是 int 類型。現在我給你一個數,你需要告訴我它是否存在其中(儘量高效)。

需求其實很清晰,只是要判斷一個數據是否存在即可。

但這裡有一個比較重要的前提:非常龐大的數據

常規實現

先不考慮這個條件,我們腦海中出現的第一種方案是什麼?

我想大多數想到的都是用 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 也是同理計算後將 4、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. 同時不能清除其中的數據。

第一點應該都能理解,重點解釋下 2、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

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1、圖片圓角顯示 例如(非常簡單): HTML: CSS: 如果圖片只為圓角,這種方式確實沒問題,但如果還要加上居中的效果,這種方式就有問題,下麵會說明。 2、圖片居中顯示(鋪滿父容器且不變形) 效果圖如下: PS:為了實現上圖居中的效果,單靠CSS是不行的,還需要JS處理。 例如: HTML: C ...
  • 以前有小伙伴自己做了個小游戲,類似掃雷這種,都覺得簡單好玩,老師還提名錶揚呢,那時沒深究是怎麼開發的,現在想想就是純前端的簡單小游戲啊。 工作後,發現前端才是王道啊,先不說大家說的什麼“整個互聯網都缺前端啊”這種話題,就說要抓取用戶眼球,讓用戶有點擊的欲望,這都是前端工作啊,畢竟用戶才不關心“你咋實 ...
  • 日常在群里討論一些概念性的問題,比如變數提升,作用域和閉包相關問題的時候,經常會聽一些大佬們給別人解釋的時候說執行上下文,調用上下文巴拉巴拉,總有點似懂非懂,不明覺厲的感覺。今天,就對這兩個概念梳理一下,加深對js基礎核心的理解。 1. 執行上下文(execution context)與可執行代碼( ...
  • H5中拖拽屬性: draggable: auto | true | false 拖動事件: - dragstart 在元素開始被拖動時觸發 - dragend 在拖動操作完成時觸發 - drag 在元素被拖動時觸發 釋放區事件: ...
  • 上一篇給大家的三段代碼不知到大家有沒有練習呢?今天再給大家帶來兩段DOM的練習! 4.封裝函數,實現children功能,最好哎原型鏈上編程 ...
  • CPU上電後,會在某個地址開始執行,比如MIPS結構的CPU會從0xBFC00000取第一條指令,而ARM結構的CPU則從0x00000000開始,嵌入式開發板中,需要把存儲器件ROM或Flash等映射到這個地址。而Bootloader就存在這個地址的開始處,這樣一上電後就會從這個地址處執行。Boo ...
  • [TOC] 引言 剛接觸正則表達式,我也曾被它們天書似的符號組合給嚇住,但經過一段時間的深入學習,發現它並沒有想象中那麼可怕,只要多實踐,多理解,也是可以輕鬆搞定的。 而且我發現帶著問題去學習,求知欲會驅使著你往前走,不知不覺就懂了。 下麵就是我在學習中提出的幾個問題,在後面會依次進行討論。由於正則 ...
  • 前言 上篇博客的內容是守護進程,對於操作系統來說可以在後臺執行一些程式.這篇的內容是互斥鎖,在上上篇博客上說到進程記憶體空間互相隔離,所以可以通過共用文件來操作同一個文件,那麼這樣操作的話會發生什麼呢? 鎖 互斥鎖 多個進程需要共用數據時,先將其鎖定,此時資源狀態為'鎖定',其他進程不能更改;知道該進 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...