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

最近看到一篇有關 Perfect Hash 生成算法的文章,感受頗有必要寫篇文章推薦下:
http://ilan.schnell-web.net/p...web

先解釋下什麼是 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,取其中最小者
app

如下就是算法的步驟:函數

假設你有 K 個 keys,好比 appleboycatdog測試

  1. 給每一個 keys 分配一個從零開始遞增的 ID,好比
apple 0
boy 1
cat 2
dog 3
  1. 選擇一個稍微比 K 大一點的數 N。好比 N = 6。
  2. 隨機選擇兩個 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 不同,好找到一個最小的。.net

  1. 以 f1(x) 和 f2(x) 的結果做爲節點,鏈接每一個 f1(key) 和 f2(key) 節點,咱們能夠獲得一個圖 G。這個圖最
    多有 N 個節點,有 K 條邊。

好比前面咱們挑的函數裏,f1(x) 和 f2(x) 的結果以下表:設計

key    f1(x) f2(x)
apple     2    0
boy       0    0
cat       0    0
dog       2    0

生成的圖是這樣的:code

2 --- apple ------
|                |
--- dog ---------0 -- boy -
                 |        |
                 --- cat -
  1. 判斷圖 G 是否無環。咱們能夠隨機選擇一個節點進行塗色,而後遍歷其相鄰節點。若是某個節點被塗過色,說
    明當前的圖是有環的。顯然上圖就是有環的。
  2. 若是有環,增長 N,回到步驟 3。好比增長 N 爲 7。
  3. 若是無環,則對每一個節點賦值,確保同一條的兩個節點的值的和爲該邊的 ID。
    (別忘了有多少個 key 就有多少條邊,而每一個 key 都在步驟 1 裏面分配了個 ID)

沿用前面的例子,當 N 爲 7 時,f1(x) 和 f2(x) 的結果以下表:字符串

key    f1(x) f2(x)
apple     5    0
boy       1    0
cat       4    3
dog       6    4

生成的圖是這樣的:

0 --- apple --- 5
|
---- boy --- 1

4 --- cat --- 3
|
---- dog --- 6

顯然上圖是無環的。接下來的工做,就是給各個節點賦值,確保同一條邊兩個節點的值的和爲該邊的 ID。
即 0 號節點的值 + 5 號節點的值爲 apple 的 ID 0。

咱們能夠每次選擇一個沒被賦值的節點,賦值爲 0,而後遍歷其相鄰節點,確保這些節點和隨機選擇的節點的值的
和爲該邊的 ID,直到全部節點都被賦值。這裏咱們假設隨機選取了 5 號節點和 3 號節點,賦值後的圖是這樣的:

0(0) --- apple --- 5(0)
|
---- boy --- 1(1)

4(2) --- cat --- 3(0)
|
---- dog --- 6(1)

如今圖 G 能夠這麼表示:

int G[7] = {
    0, // 0 號節點值爲 0
    1,
    0, // 2 號節點沒有用到,能夠取任意值
    0,
    2,
    0,
    1  // 6 號節點值爲 1
}

最終獲得的最小 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)
apple     5    0    0       0         0
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,有兩點須要解釋下:

  1. 若是步驟 3 中隨機選取的 f1(x),f2(x) 不一樣,則最終生成的 G 亦不一樣。實踐代表,最終生成的 G 大小爲 K
    的 1.5 ~ 2 倍。你應該屢次運行這個最小 Perfect Hash 生成算法,取其中生成的 G 最小的一次。
  2. 因爲 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),因此這麼改應該沒有問題。

相關文章
相關標籤/搜索