Bloom Filter演算法

裁決者發表於2021-10-20

Bloom Filter演算法詳解

什麼是布隆過濾器


布隆過濾器(Bloom Filter)是 1970 年由布隆提出的。它實際上是一個很長的二進位制向量和一系列隨機對映函式 (下面詳細說),實際上你也可以把它簡單理解為一個不怎麼精確的set結構,當你使用它的contains方法判斷某個物件是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要引數設定的合理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。

當布隆過濾器說某個值存在時,這個值可能不存在;但是當它說不存在時,那麼這個值一定不存在。打個比方,當它說不認識你時,那就是真的不認識,但是當它說認識你時,可能是因為你長得像他認識的另一個朋友(臉長得有些相似),所以誤判認識你。

image

布隆過濾器的使用場景


在程式的世界中,布隆過濾器是程式設計師的一把利器,利用它可以快速地解決專案中一些比較棘手的問題。

如網頁URL的去重、垃圾郵件識別、大集合中重複元素的判斷和快取穿透等問題。

布隆過濾器的典型應用有:

  • 大資料判斷是否存在
    如果你的伺服器記憶體足夠大的話,那麼使用HashMap可能是一個不錯的解決方案,理論上時間複雜度可以達到O(1)級別,但是當資料量起來之後還是隻能考慮布隆過濾器。
  • 解決快取穿透
    我們通常會把一些經常訪問的資料放在Redis中當作快取,例如產品詳情。通常一個請求過來之後,我們會先查詢快取,而不用直接讀取資料庫,這是提升效能最簡單,也是最普遍的做法,但是如果一直請求一個不存在的快取,那就會有大量的請求被直接打到資料庫上,造成快取穿透,布隆過濾器也可以用來解決此類問題。
  • 爬蟲|郵箱等系統的過濾
    對爬蟲網址進行過濾,已經存在布隆中的網址,不再爬取。
    對於垃圾郵件進行過濾,對每一個傳送郵件的地址進行判斷是否在布隆的黑名單內,如果在就判斷為垃圾郵件。
  • 業務場景判斷
    判斷使用者是否閱讀過某視訊或文章,比如抖音或頭條,當然會導致一定的誤判,但不會讓使用者看到重複的內容。
  • Web攔截器
    如果是相同的請求則進行攔截,防止被重複攻擊。
    使用者第一次請求,將請求引數放入布隆過濾器中,當第二次請求時,先判斷請求引數是否被布隆過濾器命中。可以提高快取命中率。Squid 網頁代理快取伺服器在 cache digests 中就使用了布隆過濾器。Google Chrome瀏覽器使用了布隆過濾器加速安全瀏覽服務

為什麼使用布隆過濾器


下面舉一個例項說明我們為什麼要學習BloomFilter

image

假設我們要寫一個爬蟲程式。由於網路間的連結錯綜複雜,蜘蛛在網路間爬行很可能會形成“環”,爬蟲就會進入一個無限怪圈,找不到出路,程式出現崩潰。

所以為了避免形成“環”,就需要知道蜘蛛已經訪問過那些URL,也就是如何判重。

給一個URL,怎樣知道蜘蛛是否已經訪問過呢?按照我們的常識,就會有如下幾種方案:
  1. 將訪問過的URL儲存到資料庫,資料庫管理系統可以為你去重。
  2. 用Set將訪問過的URL儲存起來。那隻需接近O(1)的代價就可以查到一個URL是否被訪問過了。
  3. URL經過MD5SHA-1等單向雜湊後再儲存到Set資料庫
  4. Bit-Map方法。建立一個BitSet,將每個URL經過一個雜湊函式對映到某一位。

方法1~3都是將訪問過的URL完整儲存,方法4則只標記URL的一個對映位。

以上方法在資料量較小的情況下都能完美解決問題,但是當資料量變得非常龐大時問題就來了。

方法1的缺點:資料量變得非常龐大後關係型資料庫查詢的效率會變得很低。而且每來一個URL就啟動一次資料庫查詢是不是太小題大做了?

方法2的缺點:太消耗記憶體。隨著URL的增多,佔用的記憶體會越來越多。就算只有1億個URL,每個URL只算50個字元,至少需要5GB記憶體,還不包括Set資料結構中的記憶體浪費。

方法3的缺點:由於字串經過MD5處理後的資訊摘要長度只有128Bit,SHA-1處理後也只有160Bit,因此方法3比方法2節省了好幾倍的記憶體。

方法4的缺點:消耗記憶體是相對較少的,但缺點是單一雜湊函式發生衝突的概率太高。

若要降低衝突發生的概率到1%,有種辦法就是就要將BitSet的長度設定為URL個數的100倍。

假設一億條URL,就得把BitSet長度設為100億,過於稀疏也是很費記憶體的

實質上,上面的演算法都忽略了一個重要的隱含條件:允許小概率的出錯,不一定要100%準確!

也就是說少量URL實際上沒有沒被網路爬蟲訪問,而將它們錯判為已訪問的代價是很小的——大不了少抓幾個網頁唄。

Bloom Filter演算法原理


下面引入本篇的主角——Bloom Filter。其實上面方法4的思想已經很接近Bloom Filter了。

方法四的致命缺點是衝突概率高,為了降低衝突的概念,Bloom Filter使用了多個雜湊函式,而不是一個。

為什麼可以降低呢?我們知道Hash函式有一定機率出現衝突,概率假設為 p1,我們知道p1是一個很小的機率,但是在資料量大之後衝突就會變多,也就是上面第四種方法的問題。

BoomFilter使用 多個Hash函式 分別衝突概率為 p2 p3 p4 p5 … pn ,我們知道不同 Hash函式處理同一個字串彼此獨立,所以衝突概率通過乘法公式得到為: p1p2p3p4p5p6…pn,是相當相當小的了。

Bloom Filter演算法如下:

預操作
建立一個 m 位 BitSet(C++自帶,Python為bitarray),先將所有位初始化為0,然後選擇 k 個不同的雜湊函式。第 i 個雜湊函式對字串 str 雜湊的結果記為h(i, str),且h(i,str)的範圍是 0 到 m-1 。

image

Add操作
下面是每個字串處理的過程,首先是將字串str“記錄”到BitSet中的過程:

對於字串str,分別計算h(1,str),h(2,str)…… h(k,str)。然後將BitSet的第h(1,str)、h(2,str)…… h(k,str)位設為1。

image

很簡單吧?這樣就將字串str對映到BitSet中的k個二進位制位了。

Check操作
根據上圖,我們對每個字串採用同樣的演算法。

下面是檢查字串str是否被BitSet記錄過的過程:

  • 對於字串str,分別計算h(1,str),h(2,str)…… h(k,str)。然後檢查BitSet的第h(1,str)、h(2,str)…… h(k,str)位是否為1,若其中任何一位不為1則可以判定str一定沒有被記錄過。若全部位都是1,則“認為”字串str存在。
  • 若一個字串對應的Bit不全為1,則可以肯定該字串一定沒有被Bloom Filter記錄過。(這是顯然的,因為字串被記錄過,其對應的二進位制位肯定全部被設為1了)
  • 但是若一個字串對應的Bit全為1,實際上是不能100%的肯定該字串被Bloom Filter記錄過的。(因為有可能該字串的所有位都剛好是被其他字串所對應)這種將該字串劃分錯的情況,稱為wrong position。

Delete操作
字串加入了就被不能刪除了,因為刪除會影響到其他字串。實在需要刪除字串的可以使用Counting bloomfilter(CBF),這是一種基本Bloom Filter的變體,CBF將基本Bloom Filter每一個Bit改為一個計數器,這樣就可以實現刪除字串的功能了。

Bloom Filter跟單雜湊函式Bit-Map不同之處在於:Bloom Filter使用了k個雜湊函式,每個字串跟k個bit對應。從而降低了衝突的概率。

Bloom Filter 優化


image

考慮到BoomFilter上面的指標,總結一下有以下幾個

m : BitSet 位數

n : 插入字串個數

k :hash函式個數

當然,雜湊函式也是影響的重要因素

從表格來看 m/n越大越準,k越大越準。

但是具體怎麼設計呢?

雜湊函式選擇

  • 雜湊函式的選擇對效能的影響應該是很大的,一個好的雜湊函式要能近似等概率的將字串對映到各個Bit。
  • 選擇k個不同的雜湊函式比較麻煩,一種簡單的方法是選擇一個雜湊函式,然後送入k個不同的引數。

引數設計
相信大家對於 Bloom Filter 的工作原理都有了一個基本的瞭解,現在我們來看看在Bloom Filter 中涉及到的一些引數指標:

  • 欲插入Bloom Filter中的元素數目: n
  • Bloom Filter誤判率: P(true)
  • BitArray陣列的大小: m
  • Hash Function的數目: k

欲插入Bloom Filter中的元素數目 n 是我們在實際應用中可以提前獲取或預估的;Bloom Filter的誤判率 P(true) 則是我們提前設定的可以接受的容錯率。所以在設計Bloom Filter過程中,最關鍵的引數就是BitArray陣列的大小 m 和 Hash Function的數目 k,下面將給出這兩個關鍵引數的設定依據、方法

誤判率P(true)

向Bloom Filter插入一個元素時,其一個Hash Function會將BitArray中的某Bit置為1,故對於任一Bit而言,其被置為1的概率\(P1=\frac{1}{m}\),那麼其依然是0的概率\(P0=1-P1=1-\frac{1}{m}\);易知插入一個元素時,其k個Hash Function都未將該Bit置為1的概率\(P0^1=(1-\frac{1}{m})^{k}\)。則向Bloom Filter插入全部n個元素後,該Bit依然為0的概率即為\(P0^n=(1-\frac{1}{m})^{kn}\),反之,該Bit為1的概率則為\(P1^{n}=1-P0^{n}=1-(1-\frac{1}{m})^{kn}=1-(1-\frac{1}{m})^{mkn/m}\)

根據基本極限

\[\begin{gather*} \lim_{x \to \infty}{(1-\frac{1}{x})}^{-x}=e \end{gather*} \]

可得

\[\begin{gather*} P1^{n}≈1-e^{-\frac{kn}{m}} \end{gather*} \]

在已有n個元素進行過Hash操作的基礎上,當有新的元素y到來,如果該元素進行k個Hash操作後對應到位向量的位置均已被置1,那麼元素y就會被認為已經存在於集合之中,但是其實其並不存在於集合之中,這種情況下就產生了誤判。我們用\(P(mis\_judge)\)表示誤判率,結合以上公式則:

\[\begin{gather*} P(mis\_judge)\approx (1-e^\frac{-nk}{m})^k \end{gather*} \]

從上式可以看出,當BitArray陣列的大小m增大或欲插入Bloom Filter的元素數目n減小時,均可以使誤判率\(P(mis\_judge)\)下降,如果要保持\(P(mis\_judge)\)不變,則布隆過濾器位向量大小m應該和集合元素數量n保持同步增長。

Hash Function的數目k

前文已經看到Hash Function數目k的增加可以減小誤判率P(true),但是隨著Hash Function數目k的繼續增加,反而會使誤判率P(true)上升,即誤判率是一個關於Hash Function數目k的凸函式。所以當k在極值點時,此時誤判率即為最小值

\[\begin{gather*} f(k)=(1-e^\frac{-nk}{m})^k \end{gather*} \]

\(a=e^\frac{n}{m}\),則有:

\[\begin{gather*} f(k)=(1-a^{-k})^k \end{gather*} \]

分別對上式兩邊,先取對數然後再求導,可有:

\(\frac{1}{f(k)}f'(k)=ln(1-a^{-k})+\frac{ka^{-k}{lna}}{1-a^{-k}}\)

易知,當k取極值點時,有\(f'(k)=0\),故將其帶入上式即可求出k

\[\begin{gather*} ln(1-a^{-k})+\frac{ka^{-k}lna}{1-a^{-k}}=0 \\=>(1-a^{-k})ln(1-a^{-k})=-ka^{-k}lna \\=>(1-a^{-k})ln(1-a^{-k})=a^{-k}lna^{-k} \\=>1-a^{-k}=a^{-k} \\=>a^{-k}=\frac{1}{2} \\=>e^{\frac{-kn}{m}}=\frac{1}{2} \\=>k=\frac{m}{n}ln2\approx0.7\frac{m}{n} \end{gather*} \]

此時,誤判率\(f\)最小,約為:

\[\begin{gather*} f(k)=(\frac{1}{2})^k\approx(0.6185)^{\frac{m}{n}} \end{gather*} \]

由以上討論我們知道,k的值主要取決於布隆過濾器位向量大小m和集合中元素數量n的值。有研究表明,如果想要保持較低的誤判率,布隆過濾器的位向量使用空間應低於50%。

BitArray陣列的大小 m

如何確定BitArray陣列的大小 m 呢?這裡我們固定k為最優的數目\(\frac{m}{n}ln2\)進行公式推導

由上面可知,

\[\begin{gather*} P(mis\_judge)=(\frac{1}{2})^k=(\frac{1}{2})^{\frac{mln2}{n}} \end{gather*} \]

對上式求解,可得:

\[\begin{gather*} =>lnP(mis\_judge)=(\frac{1}{2})^k=\frac{m}{n}ln2ln{\frac{1}{2}} \end{gather*} \\=>m=-\frac{nlnP(mis\_judge)}{(ln2)^{2}} \]

此時,我們即可以利用上式的結果,通過\(P(mis\_judge)\)\(n\)來確定最優的BitArray陣列的大小 \(m\)

如何解決布隆過濾器不支援刪除的問題


(1)Counting Bloom Filter
Counting Bloom Filter將標準 Bloom Filter位陣列的每一位擴充套件為一個小的計數器(counter),在插入元素時給對應的k(k為雜湊函式個數)個Counter的值分別加1,刪除元素時給對應的k個Counter的值分別減1。Counting Bloom Filter通過多佔用幾倍的儲存空間的代價,給Bloom Filter增加了刪除操作。

image

(2)布穀鳥過濾器
對於這種方式有興趣的讀者可以閱讀這篇文章:https://juejin.cn/post/6924636027948630029#heading-1

Python 程式碼簡單實現


主體

from bitarray import bitarray # 產生BitSet

import mmh3 # 產生Hash函式
 
 
class BloomFilter(set):
 
    def __init__(self, size, hash_count):
        super(BloomFilter, self).__init__()
        self.bit_array = bitarray(size)
        self.bit_array.setall(0)
        self.size = size
        self.hash_count = hash_count
 
    def __len__(self):
        return self.size
 
    def __iter__(self):
        return iter(self.bit_array)
 
    def add(self, item):
        for seed in range(self.hash_count):
            index = mmh3.hash(item, seed) % self.size
            self.bit_array[index] = 1
 
        return self
 
    def __contains__(self, item):
        out = True
        for seed in range(self.hash_count):
            index = mmh3.hash(item, seed) % self.size
            if self.bit_array[index] == 0:
                out = False
 
        return out

測試

def main():
    bloom = BloomFilter(10000, 20)
    animals = ['dog', 'cat', 'giraffe', 'fly', 'mosquito', 'horse', 'eagle',
               'bird', 'bison', 'boar', 'butterfly', 'ant', 'anaconda', 'bear',
               'chicken', 'dolphin', 'donkey', 'crow', 'crocodile']
    # First insertion of animals into the bloom filter
    for animal in animals:
        bloom.add(animal)
 
    # Membership existence for already inserted animals
    # There should not be any false negatives
    for animal in animals:
        if animal in bloom:
            print('{} is in bloom filter as expected'.format(animal))
        else:
            print('Something is terribly went wrong for {}'.format(animal))
            print('FALSE NEGATIVE!')
 
    # Membership existence for not inserted animals
    # There could be false positives
    other_animals = ['badger', 'cow', 'pig', 'sheep', 'bee', 'wolf', 'fox',
                     'whale', 'shark', 'fish', 'turkey', 'duck', 'dove',
                     'deer', 'elephant', 'frog', 'falcon', 'goat', 'gorilla',
                     'hawk' ]
    for other_animal in other_animals:
        if other_animal in bloom:
            print('{} is not in the bloom, but a false positive'.format(other_animal))
        else:
            print('{} is not in the bloom filter as expected'.format(other_animal))
 
 
if __name__ == '__main__':
    main()

結果

dog is in bloom filter as expected
cat is in bloom filter as expected
giraffe is in bloom filter as expected
fly is in bloom filter as expected
mosquito is in bloom filter as expected
horse is in bloom filter as expected
eagle is in bloom filter as expected
bird is in bloom filter as expected
bison is in bloom filter as expected
boar is in bloom filter as expected
butterfly is in bloom filter as expected
ant is in bloom filter as expected
anaconda is in bloom filter as expected
bear is in bloom filter as expected
chicken is in bloom filter as expected
dolphin is in bloom filter as expected
donkey is in bloom filter as expected
crow is in bloom filter as expected
crocodile is in bloom filter as expected
badger is not in the bloom filter as expected
cow is not in the bloom filter as expected
pig is not in the bloom filter as expected
sheep is not in the bloom, but a false positive
bee is not in the bloom filter as expected
wolf is not in the bloom filter as expected
fox is not in the bloom filter as expected
whale is not in the bloom filter as expected
shark is not in the bloom, but a false positive
fish is not in the bloom, but a false positive
turkey is not in the bloom filter as expected
duck is not in the bloom filter as expected
dove is not in the bloom filter as expected
deer is not in the bloom filter as expected
elephant is not in the bloom, but a false positive
frog is not in the bloom filter as expected
falcon is not in the bloom filter as expected
goat is not in the bloom filter as expected
gorilla is not in the bloom filter as expected
hawk is not in the bloom filter as expected

從輸出結果可以發現,存在不少誤報樣本,但是並不存在假陰性。

不同於這段布隆過濾器的實現程式碼,其它語言的多個實現版本並不提供雜湊函式的引數。這是因為在實際應用中誤報比例這個指標比雜湊函式更重要,使用者可以根據誤報比例的需求來調整雜湊函式的個數。通常來說,sizeerror_rate是布隆過濾器的真正誤報比例。如果你在初始化階段減小了error_rate,它們會調整雜湊函式的數量。

參考資料


https://cloud.tencent.com/developer/article/1731494 
https://blog.csdn.net/a745233700/article/details/113751718
https://juejin.cn/post/6924636027948630029#heading-1
https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/#%E4%B8%80%E3%80%81%E5%B8%83%E9%9A%86%E8%BF%87%E6%BB%A4%E5%99%A8%E7%AE%80%E4%BB%8B
https://segmentfault.com/a/1190000024566947
https://github.com/jaybaird/python-bloomfilter
https://blog.csdn.net/weixin_42081389/article/details/103137671

相關文章