最近看到一篇有关 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,好比 apple
,boy
,cat
,dog
。测试
apple 0 boy 1 cat 2 dog 3
f1(x) = (x[0] + x[1] + x[2] + ...) % N f2(x) = (x[0] * x[1] * x[2] * ...) % N
之因此随机选择 hash 函数,是为了让每次生成的图 G 不同,好找到一个最小的。.net
好比前面咱们挑的函数里,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 -
沿用前面的例子,当 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,有两点须要解释下:
这个算法背后的数学原理并不深奥。
若是你能找到这样的 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),因此这么改应该没有问题。