分享一種最小 Perfect Hash 生成演算法

2021-09-12 15:36:00 字數 4207 閱讀 9953

最近看到一篇有關 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)的結果恰好等於keykeys裡面的 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 建立了乙個開源工程...