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

mghio發表於2020-04-19

前言

在日常工作中,經常要判斷一個元素是否在一個集合中。假設你要向瀏覽器新增一項功能,該功能可以通知使用者輸入的網址是否是惡意網址,此時你手上有大約 1000 萬個惡意 URL 的資料集,你該如何實現該功能。按我之前的思維,要判斷一個元素在不在當前的資料集中,首先想到的就是使用 hash table,通過雜湊函式執行所有的惡意網址以獲取其雜湊值,然後建立出一個雜湊表(陣列)。這個方案有個明顯的缺點,就是需要儲存原始元素本身,記憶體佔用大,而我們其實主要是關注 當前輸入的網址在不在我們的惡意 URL 資料集中,也就是之前的惡意 URL 資料集的具體值是什麼並不重要,通過吳軍老師的《數學之美》瞭解到,對於這種場景大資料領域有個用於在海量資料情況下判斷某個元素是否已經存在的演算法很適合,關鍵的一點是該演算法並不儲存元素本身,這個演算法就是 — 布隆過濾器(Bloom filter)。

原理

布隆過濾器是由巴頓.布隆於一九七零年提出的,在 維基百科 中的描述如下:

A Bloom filter is a space-efficient probabilistic data structure, conceived by Burton Howard Bloom in 1970, that is used to test whether an element is a member of a set.

布隆過濾器是一個資料結構,它可以用來判斷某個元素是否在集合內,具有執行快速,記憶體佔用小的特點,它由一個很長的二進位制向量和一系列隨機對映函式組成。而高效插入和查詢的代價就是,它是一個基於概率的資料結構,只能告訴我們一個元素絕對不在集合內,布隆過濾器的好處在於快速,省空間,但是有一定的誤判率。布隆過濾器的基礎資料結構是一個位元向量,假設有一個長度為 16 的位元向量,下面我們通過一個簡單的示例來看看其工作原理,:

bloom-filter-bit-array.png

上圖位元向量中的每一個空格表示一個位元, 空格下面的數字表示當前位置的索引。只需要簡單的對輸入進行多次雜湊操作,並把對應於其結果的位元置為 1,就完成了向 Bloom filter 新增一個元素的操作。下圖表示向布隆過濾器中新增元素 https://www.mghio.cnhttps://www.abc.com 的過程,它使用了 func1func2 兩個簡單的雜湊函式。

bloom-filter-add-item.png

當我們往集合裡新增一個元素的時候, 可以檢查該元素在應用對應雜湊函式後的雜湊值對位元向量的長度取餘後的位置是否為 1,圖中用 1 表示最新新增的元素對應位置。然後當我們要判斷新增元素是否存在集合中的話,只需要簡單的通過對該元素應用同樣的雜湊函式,然後看位元向量裡對應的位置是否為 1 的方式來判斷一個元素是否在集合裡。如果不是,則該元素一定不再集合中,但是需要注意的是,如果是,你只知道元素可能在裡面, 因為這些對應位置有可能恰巧是由其它元素或者其它元素的組合所引起的。以上就是布隆過濾器的實現原理。

如何自己實現

布隆過濾器的思想比較簡單,首先在構造方法中初始化了一個指定長度的 int 陣列,在新增元素的時候通過雜湊函式 func1func2 計算出對應的雜湊值,對陣列長度取餘後將對應位置置為 1,判斷元素是否存在於集合中時,同樣也是對元素用同樣的雜湊函式進行兩次計算,取到對應位置的雜湊值,只要存在位置的值為 0,則認為元素不存在。下面使用 Java 語言實現了上面示例中簡單版的布隆過濾器:

public class BloomFilter {

  /**
   * 陣列長度
   */
  private int size;

  /**
   * 陣列
   */
  private int[] array;

  public BloomFilter(int size) {
    this.size = size;
    this.array = new int[size];
  }

  /**
   * 新增資料
   */
  public void add(String item) {
    int firstIndex = func1(item);
    int secondIndex = func2(item);
    array[firstIndex % size] = 1;
    array[secondIndex % size] = 1;
  }

  /**
   * 判斷資料 item 是否存在集合中
   */
  public boolean contains(String item) {
    int firstIndex = func1(item);
    int secondIndex = func2(item);
    int firstValue = array[firstIndex % size];
    int secondValue = array[secondIndex % size];
    return firstValue != 0 && secondValue != 0;
  }

  /**
   * hash 演算法 func1
   */
  private int func1(String key) {
    int hash = 7;
    hash += 61 * hash + key.hashCode();
    hash ^= hash >> 15;
    hash += hash << 3;
    hash ^= hash >> 7;
    hash += hash << 11;
    return Math.abs(hash);
  }

  /**
   * hash 演算法 func2
   */
  private int func2(String key) {
    int hash = 7;
    for (int i = 0, len = key.length(); i < len; i++) {
      hash += key.charAt(i);
      hash += (hash << 7);
      hash ^= (hash >> 17);
      hash += (hash << 5);
      hash ^= (hash >> 13);
    }
    return Math.abs(hash);
  }
} 

自己實現雖然簡單但是有一個問題就是檢測的誤判率比較高,通過其原理可以知道,可我們可以提高陣列長度以及 hash 計算次數來降低誤報率,但是相應的 CPU、記憶體的消耗也會相應的提高;這需要我們根據自己的業務需要去權衡選擇。

扎心一問

雜湊函式該如何設計?

布隆過濾器裡的雜湊函式最理想的情況就是需要儘量的彼此獨立且均勻分佈,同時,它們也需要儘可能的快 (雖然 sha1 之類的加密雜湊演算法被廣泛應用,但是在這一點上考慮並不是一個很好的選擇)。

布隆過濾器應該設計為多大?

個人認為布隆過濾器的一個比較好特性就是我們可以修改過濾器的錯誤率。一個大的過濾器會擁有比一個小的過濾器更低的錯誤率。假設在布隆過濾器裡面有 k 個雜湊函式,m 個位元位(也就是位陣列長度),以及 n 個已插入元素,錯誤率會近似於 (1-ekn/m)k,所以你只需要先確定可能插入的資料集的容量大小 n,然後再調整 k 和 m 來為你的應用配置過濾器。

應該使用多少個雜湊函式?

顯然,布隆過濾器使用的雜湊函式越多其執行速度就會越慢,但是如果雜湊函式過少,又會遇到誤判率高的問題。所以這個問題上需要認真考慮,在建立一個布隆過濾器的時候需要確定雜湊函式的個數,也就是說你需要提前預估集合中元素的變動範圍。然而你這樣做了之後,你依然需要確定位元位個數和雜湊函式的個數的值。看起來這似乎這是一個十分困難的優化問題,但幸運的是,對於給定的 m(位元位個數)和 n(集合元素個數),最優的 k(雜湊函式個數)值為: (m/n)ln(2)(PS:需要了解具體的推導過程的朋友可以參考維基百科)。也就是我們可以通過以下步驟來確定布隆過濾器的雜湊函式個數:

  1. 確定 n(集合元素個數)的變動範圍。
  2. 選定 m(位元位個數)的值。
  3. 計算 k(雜湊函式個數)的最優值

對於給定的 n、m 和 k 計算錯誤率,如果這個錯誤率不能接受的話,可以繼續回到第二步。

布隆過濾器的時間複雜度和空間複雜度?

對於一個 m(位元位個數)和 k(雜湊函式個數)值確定的布隆過濾器,新增和判斷操作的時間複雜度都是 O(k),這意味著每次你想要插入一個元素或者查詢一個元素是否在集合中,只需要使用 k 個雜湊函式對該元素求值,然後將對應的位元位標記或者檢查對應的位元位即可。

總結

布隆過濾器的實際應用很廣泛,特別是那些要在大量資料中判斷一個元素是否存在的場景。可以看到,布隆過濾器的演算法原理比較簡單,但要實際做一個生產級別的布隆過濾器還是很複雜的,谷歌的開源庫 GuavaBloomFilter 提供了 Java 版的實現,用法很簡單。最後留給大家一個問題:布隆過濾器支援元素刪除嗎?

相關文章