1. 程式人生 > >布隆過濾器與 Swift 4.2

布隆過濾器與 Swift 4.2

作者:Soroush Khanlou,原文連結,原文日期:2018-09-19 譯者:WAMaker;校對:numbbbbb小鐵匠Linus;定稿:Forelax

Swift 4.2 為雜湊的實現帶來了一些新的變化。在此之前,雜湊交由物件本身全權代理。當你向物件索取 雜湊值(hashValue)時,它會把處理好的整型值作為雜湊值返回。而現在,實現了 Hashable 協議的物件則描述了它的引數是如何組合,並傳遞給作為入參的 Hasher 物件。這樣做有以下幾點好處:

  • 寫出好的雜湊演算法很難。Swift 的使用者不需要知道如何組合引數來獲得更好的雜湊值。
  • 出於不提倡使用者以任何形式儲存雜湊值,以及
    一些安全方面因素
    的考慮,雜湊值在程式每次執行的時候都應該有所不同。描述性的雜湊允許雜湊函式的種子在每次程式執行的時候發生改變。
  • 能實現更多有意思的資料結構,這也是我們這篇文章接下來會聚焦的。

我之前寫過一篇關於 如何使用 Swift 的 Hashable 協議從零實現 Dictionary 的文章(先閱讀它會幫助你閱讀本文,但這不是必須的)。今天,我想談論一種不同型別的,基於概率性而非明確性的資料結構:布隆過濾器(Bloom Filters)。我們會使用 Swift 4.2 的新特性,包括新的雜湊模型來構建它。

布隆過濾器很怪異。想象這樣一種資料結構:

  • 你能夠往裡插入資料
  • 你能夠查詢一個值是否存在
  • 只需要少量儲存資源就能儲存大量物件

但是:

  • 你不能列舉其中的物件
  • 它有時會出現誤報(但不會出現漏報)
  • 你不能從中移除資料

什麼時候會想要這種資料結構呢?Medium 使用它們來 跟蹤博文的閱讀狀態。必應使用它們做 搜尋索引。你可以使用它們來構建一個快取,在無需訪問資料庫的情況下就能判斷使用者名稱是否有效(例如在 @-mention 中)。像伺服器這樣可能擁有巨大的規模,卻不一定有巨大資源的場景中,它們會非常有用。

(如果你之前做過圖形方面的工作,可能好奇它是如何與 高光過濾器 產生聯絡的。答案是沒有聯絡。高光過濾器(bloom filters)是小寫的 b,而布隆過濾器(Bloom Filters)是由一個叫布隆的人命名的。完全是個巧合。)

那它們是如何運作的呢?

將物件放入布隆過濾器如同將它放入集合或字典:計算物件的雜湊值,並根據儲存陣列的大小對雜湊值求餘。就這點而言,使用布隆過濾器只需要修改該索引處的值:將 false 改為 true,而不用像使用集合或字典那樣,把物件存放到索引位置。

我們先通過一個簡單的例子來理解過濾器是如果運作的,之後再對它進行擴充套件。想象一個擁有 8 個 false 值的布林陣列(或稱之為 位元陣列):

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---------------------------------
|   |   |   |   |   |   |   |   |
複製程式碼

它代表了我們的布隆過濾器。我想要插入字串“soroush”。它的雜湊值是 9192644045266971309,當這個值餘 8 時得到 5。我們修改那一位的值。

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---------------------------------
|   |   |   |   |   | * |   |   |
複製程式碼

接下來我想要插入字串“swift”,它的雜湊值是 7052914221234348788,餘 8 得 4,修改索引 4 的值。

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---------------------------------
|   |   |   |   | * | * |   |   |
複製程式碼

要測試布隆過濾器是否包含“soroush”,我再次計算它的雜湊值並求餘,仍舊得到餘數 5,對應值是 true。“soroush”確實在布隆過濾器中。

然而僅僅測試能夠通過的用例是不夠的,我們需要寫一些會導致失敗的用例。測試字串“khanlou”取餘得到的索引值是 2,因此我們知道它不在布隆過濾器中。到此為止一切都好。接下去一個測試:對“hashable”字串取餘得到的索引值是 5,這就發生了一次衝突!即使這個值從來沒有被加入過,過濾器仍返回了 true。這便是布隆過濾器會發生誤報的例子。

有兩個主要的策略可以儘可能減少誤報。第一個策略,也是兩個策略中相對有趣的:我們可以使用不同的雜湊函式計算兩次或三次雜湊值而非一次。只要它們都是表現良好的雜湊函式(均勻分佈,一致性,最小的碰撞機率),我們就能為每個值生成多個索引改變布林值。這次,我們計算兩次“soroush”的雜湊值,生成 2 個索引並改變布林值。這時,當我們檢查“khanlou”是否在布隆過濾器中,其中一個雜湊值可能會和“soroush”的一個雜湊值衝突,但兩個值同時發生衝突的可能性就會變得很小。你可以把這個數字擴大。在下面的程式碼我會做 3 次雜湊計算,但你可以做更多次。

當然,如果你計算更多次雜湊值,每個元素在布林陣列中會佔據更多的空間。事實上,現在的資料幾乎不佔用空間。8 個布林值的陣列對應 1 位元組。所以第二個減小誤報的策略是擴大陣列的規模。我們能將陣列變得足夠大而不用擔心它的空間消耗。下面的程式碼中我們會預設使用 512 位元大小的陣列。

現在,即使同時使用這些策略,你依然會得到衝突,即誤報,但衝突的機率會減小。這是布隆過濾器的一個缺陷,但在合適的場景用它來節省速度與空間是一筆不錯的交易。

在開始具體的程式碼之前我有另外三點想要談談。首先,你不能改變布隆過濾器的大小。當你對雜湊值取餘時,這是在破壞資訊,在不保留原始雜湊值的情況下你不能回溯之前的資訊 —— 保留原始值相當於否決了這個資料結構節約空間的優勢。

其次,你能看到想要列舉布隆過濾器所有的值是多麼異想天開。你不再擁有這些值,只是它們以雜湊形式存在的替代品。

最後,你同樣能看到想要從布隆過濾器中移除元素是不可能的。如果想將布林值變回 false,你並不知道是哪些值將它變為 true。是準備移除的值還是其它值?這樣做會造成漏報和誤報。(這對你來說可能是值得權衡的)你可以在每個索引上保留計數而非布林值來解決這個問題,雖然保留計數還是會帶來儲存問題,但根據使用場景的不同,這樣做或許是值得的。

廢話不多說,讓我們開始著手編碼。我在這裡做的一些決策和你可能會做的有所不同,第一個不同就是要不要讓物件支援範型。我認為讓物件包含更多關於它需要儲存內容的元資料是有意義的,但如果你發現這樣做限制太多,你可以改變它。

struct BloomFilter<Element: Hashable> {
	// ...
}
複製程式碼

我們需要儲存兩種主要的資料。第一個是 data,用於表示位元陣列。它儲存了所有和雜湊值有關的標記:

private var data: [Bool]
複製程式碼

接下來,我們需要不同的雜湊函式。一些布隆過濾器確實會使用不同的方法計算雜湊值,但我覺得使用相同的演算法,同時混入一個隨機生成的值會更簡單。

private let seeds: [Int]
複製程式碼

當初始化布隆過濾器時,我們需要初始化這兩個例項變數。位元陣列會簡單的重複 false 值來初始化,而種子值則使用 Swift 4.2 的新 API Int.random 來生成我們需要的種子值。

init(size: Int, hashCount: Int) {
	data = Array(repeating: false, count: size)
	seeds = (0..<hashCount).map({ _ in Int.random(in: 0..<Int.max) })
}
複製程式碼

同時,建立一個帶有預設值的便利構造器。

init() {
	self.init(size: 512, hashCount: 3)
}
複製程式碼

我們要實現兩個主要的方法:insertcontains。它們都需要接收元素作為引數併為每一個種子值計算出對應的雜湊值。私有的幫助方法會很有用。由於種子值代表了“不同的”雜湊函式,我們就需要為每一個種子生成對應的雜湊值。

private func hashes(for element: Element) -> [Int] {
	return seeds.map({ seed -> Int in
		// ...
	})
}
複製程式碼

要實現函式主體,我們需要建立一個 Hasher 物件(Swift 4.2 新特性),將想要進行雜湊計算的物件傳給它。帶上種子確保了生成的雜湊值不會衝突。

private func hashes(for element: Element) -> [Int] {
	return seeds.map({ seed -> Int in
		var hasher = Hasher()
		hasher.combine(element)
		hasher.combine(seed)
		let hashValue = abs(hasher.finalize())
		return hashValue
	})
}
複製程式碼

同時,注意雜湊值的絕對值。雜湊計算有可能產生負數,這會導致我們的陣列訪問崩潰。取絕對值操作減少了 1 位元的資訊熵,對我們來說是有益的。

理想的情況是你能夠使用種子來初始化 Hasher 而不是把它混合進去。Swift 的 Hasher 會在每次程式啟動的時候被分配一個不同的種子(除非你 設定固定的環境變數 讓種子在不同啟動間保持一致,而這樣做通常是一些測試目的),意味著你不能把這些值寫到磁碟上。如果我們控制了 Hasher 的種子,我們就能將這些值寫到磁碟上了。而就像這個布隆過濾器展示的那樣,它應該只被用於記憶體快取。

hashes(for:) 方法完成了很多繁重的工作,讓 insert 方法非常簡潔:

mutating func insert(_ element: Element) {
	hashes(for: element)
		.forEach({ hash in
			data[hash % data.count] = true
		})
}
複製程式碼

生成所有的雜湊值,分別餘上 data 陣列的長度,並設定對應索引位的值為 true

contains 方法也同樣簡單,同時也給了我們使用 Swift 4.2 另一個新特性 allSatisfy 的機會。這個新方法可以判斷序列中的所有物件是否都通過了某項用 block 表示的測試:

func contains(_ element: Element) -> Bool {
	return hashes(for: element)
		.allSatisfy({ hash in
			data[hash % data.count]
		})
}
複製程式碼

因為 data[hash % data.count] 的結果已經是布林值了,它與 allSatisfy 十分契合。

你也可以新增 isEmpty 方法用來檢測 data 中的所有值是否都是 false。

布隆過濾器是一種奇怪的資料結構。我們接觸的大多數資料結構都是明確性的。當把一個物件放入字典中時,你知道那個值之後一直在那兒。而布隆過濾器是概率性的,犧牲確定性來換取空間和速度。布隆過濾器不是你會每天用的資料結構,但當你確實需要它時,就會感受到有它真好。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg。 Open Annotation Sidebar