最近看到一篇有關 perfect hash 生成演算法的文章,感覺很有必要寫篇文章推薦下:
先解釋下什麼是 perfect hash:perfect hash 是這樣一種演算法,可以對映給定 n 個 keys 到 n 個不同的的數字
裡。由於沒有 hash collision,這種 hash 在查詢時時間複雜度是真正的o(1)
。外加乙個「最小」字首,則是要
求生成的 perfect hash 對映結果的上界盡可能小。舉個例子,假設我有 100 個字串,如果存在這樣的最小
perfect hash 演算法,可以把 100 個字串一一對映到 0 ~99 個數里,我就能用乙個陣列儲存全部的字串,然
後在查詢時先 hash 一下,取 hash 結果作為下標便可知道給定字串是否在這 100 個字串裡。總時間複雜度
為o(n)
的 hash 過程 +o(1)
的查詢,而所占用的空間只是乙個陣列(外加乙個圖 g,後面會講到)。
聽到前面的描述,你可能想到 trie (字首樹)和類似的 ac 自動機演算法。不過討論它們之間的優劣和應用場景不
是本文的主題(也許以後我有機會可以寫一下)。本文的主題在於介紹一種生成最小 perfect hash 演算法。
這種演算法出自於一篇 1992 年的**《an optimal algorithm for generating minimal perfect hash functions》。
演算法的關鍵在於把判斷某個 hash 演算法是否為 perfect hash 演算法的問題變成乙個判斷圖是否無環的問題。
注意該演算法最終生成的圖 g 在不同的執行次數裡大小可能不一樣,你可能需要多跑幾次結果生成多個 g,取其中最小者
。以下就是演算法的步驟:
給每個 keys 分配乙個從零開始遞增的 id,比如
boy 1
cat 2
dog 3
選擇乙個稍微比 k 大一點的數 n。比如 n = 6。
隨機選擇兩個 hash 函式 f1(x) 和 f2(x)。這兩個函式接收 key,返回 0 ~ n-1 中的乙個數。比如
f1(x) = (x[0] + x[1] + x[2] + ...) % n
f2(x) = (x[0] * x[1] * x[2] * ...) % n
之所以隨機選擇 hash 函式,是為了讓每次生成的圖 g 不一樣,好找到乙個最小的。
以 f1(x) 和 f2(x) 的結果作為節點,連線每個 f1(key) 和 f2(key) 節點,我們可以得到乙個圖 g。這個圖最
多有 n 個節點,有 k 條邊。
比如前面我們挑的函式裡,f1(x) 和 f2(x) 的結果如下表:
key f1(x) f2(x)
boy 0 0
cat 0 0
dog 2 0
生成的圖是這樣的:
| |
--- dog ---------0 -- boy -
| |
--- cat -
判斷圖 g 是否無環。我們可以隨機選擇乙個節點進行塗色,然後遍歷其相鄰節點。如果某個節點被塗過色,說
明當前的圖是有環的。顯然上圖就是有環的。
如果有環,增加 n,回到步驟 3。比如增加 n 為 7。
如果無環,則對每個節點賦值,確保同一條的兩個節點的值的和為該邊的 id。
(別忘了有多少個 key 就有多少條邊,而每個 key 都在步驟 1 裡面分配了個 id)
沿用前面的例子,當 n 為 7 時,f1(x) 和 f2(x) 的結果如下表:
key f1(x) f2(x)
boy 1 0
cat 4 3
dog 6 4
生成的圖是這樣的:
|---- boy --- 1
4 --- cat --- 3
|---- dog --- 6
我們可以每次選擇乙個沒被賦值的節點,賦值為 0,然後遍歷其相鄰節點,確保這些節點和隨機選擇的節點的值的
和為該邊的 id,直到所有節點都被賦值。這裡我們假設隨機選取了 5 號節點和 3 號節點,賦值後的圖是這樣的:
|---- boy --- 1(1)
4(2) --- cat --- 3(0)
|---- dog --- 6(1)
現在圖 g 可以這麼表示:
int g[7] =
最終得到的最小 perfect hash 演算法如下:
p(x) = (g[f1(x)] + g[f2(x)]) % n
# n = 7
key f1(x) f2(x) g[f1(x)] g[f2(x)] p(x)
boy 1 0 1 0 1
cat 4 3 2 0 2
dog 6 4 1 2 3
p(x)
返回的值正好是 key 的 id,所以拿這個 id 作為 keys 的 offset 就能取出對應的 key 了。
注意,如果輸入 x 不一定是 keys 中的乙個 key,則p(x)
的算出來的 offset 取出來的 key 不一定匹配輸入
的 x。你需要匹配下x 和 key 兩個字串。
關於圖 g,有兩點需要解釋下:
如果步驟 3 中隨機選取的 f1(x),f2(x) 不同,則最終生成的 g 亦不同。實踐表明,最終生成的 g 大小為 k
的 1.5 ~ 2 倍。你應該多次執行這個最小 perfect hash 生成演算法,取其中生成的 g 最小的一次。
由於 g 是無環的,所以其用到的節點數至少為 k + 1 個。而 g 裡面用到的節點數最多為 1.5k 到 2k。所以
有一半以上的節點是有值的。這也是為什麼可以用乙個 g 陣列來表示圖 g 裡面每個點對應的值。
這個演算法背後的數學原理並不深奧。
如果你能找到這樣的p(key)
,令p(key)
的結果恰好等於key
在keys
裡面的 offset,則p(key)
必然是最小 perfect hash 演算法。因為keys[p(key)]
只能是key
,不可能會有兩個結果;而且也找不到比
比 keys 的個數更小的 perfect hash 了,再小下去必然會有 hash collision。
如果我們設計出這樣的乙個圖 g,它有 k 條邊,每條邊對應乙個 key,邊的兩端節點的和為該邊(key)的 offset
,則 p(x) 就是先算得兩端節點的值,然後求和。兩端節點的值可以通過隨機選取乙個節點為 0,然後給每個相鄰
節點賦值的方式決定,前提是這個圖必須是無環的,否則乙個節點就可能被賦予兩個值。所以我們首先要檢查生成
出來的圖 g 是否是無環的。
你可能會問,為什麼生成出來的 p(x) 是(g[f1(x)] + g[f2(x)]) % n
,而不是g[f1(x)] + g[f2(x)]
?我看
了原文裡面的**實現(就在本文開頭所給的鏈結裡),他在計算每個節點值時,不允許值為負數。比如節點 a 為 5,
邊的 id 為 3,n 為 7,則另一端的節點 b 為 9(而不是 -2)。之所以這麼做,是因為**裡面說g(x)
是乙個對映
x
到[0,k]
的函式,然後 p(x) 裡面需要% k
。而**裡則把g(x)
實現成對映 x 到[0,n]
的函式,順理
成章地後面就要% n
了。
但其實如果我們允許值為負數,則g[f1(x)] + g[f2(x)]
就能滿足該演算法背後的數學原理了。這麼改的好處在
於計算時可以省掉乙個相對昂貴的取餘操作。
我改動了下**實現,改動後的結果也能通過所有的測試(我另外還添了個 fuzzy test),所以這麼改應該沒有
問題。
分享一種最小 Perfect Hash 生成演算法
最近看到一篇有關 perfect hash 生成演算法的文章,感覺很有必要寫篇文章推薦下 先解釋下什麼是 perfect hash perfect hash 是這樣一種演算法,可以對映給定 n 個 keys 到 n 個不同的的數字 裡。由於沒有 hash collision,這種 hash 在查詢時...
分享是一種品質
相信很多人都有這樣的經歷 在乙個問題上和別人競爭,當自己掌握一些有用的資訊時總是嚴加保護,覺得自己手中握著屠龍寶刀並絲毫不洩漏半點有關資訊,生怕別人會奪了自己的風頭。即使在我們大學中,這種現象也有,臨近考試的時候,有的所謂高手花了很大的精力,把老師圈起來的重點整理了出來作為自己的複習資料,並且不告訴...
Godtear,分享是一種境界,開源是一種信仰
轉眼間,接觸.net技術已經6年了。作為乙個非科班的軟體工程師,自學之路離不開那許許多多在技術社群中熱衷於分享和幫助的人,我敬重他們。一直以來,都想參與技術的分享和交流之其中,卻因為自身的淺薄而無勇氣,今天特別在寫下第一段文字,希望給自己和大家拋磚引玉。同步,我在codeplex 建立了乙個開源工程...