1. 冲突难免
好,接下来的这一节我们就来介绍散列策略中的第一项,也是最重要的技术,散列函数的设计与定制。
在上一节我们已经看到,散列策略具有诸多的优势和巨大的潜力。然而,尽管如此,冲突却是这一策略的致命缺陷。然而,更糟糕的是,从理论上讲,这一缺陷是无法根本消除的。
依然回到我们电话键盘的实例,借助字符来助记电话号码的策略尽管十分精巧,但同样不难举出冲突的实例。比如有时你需要找某个人,但对应的号码却有可能属于某个学院。你要找的或许是欧几里得,但这个电话却有可能属于小黄鸭。你可能需要联系太阳鸟,但接听这个电话的却可能是只臭虫,凡此种种,皆是冲突。
~
我们知道散列函数无非是一个映射,其功能无非是将词条空间中的元素映射到散列表地址空间。 而在通常的情况下,前者要远远地大于后者,因此绝不可能是一个单射。
当然,面对这种现实,我们并非无可作为。这方面的一条好消息是,在一般的实际应用中,我们还是可以在一定的程度上得到一个近似的单射。
比如黑白打印机经常要做的一件事就是,将原本是彩色的图像转换为黑白灰度的。从映射的角度来看,这样一种转换也是散列,对于这个散例而言,数据项的取值范围应该是图像中各像素可能取的颜色种类数,也就是2的24次方,而散列表的长度也就是灰度的级别2的8次方。二者之间的差异也高达10的4至5次方。
然而,尽管经过了如此之大幅度的压缩,对我们辨识灰度图像中的物体并没有多大的影响。当然,这主要得益于图像中各像素在空间上是有一个分布的。然而,即便离开这个条件,一般意义上的散列也是有可能做得足够实用的。为此我们需要做两件事,这也是实现散列的两项基本任务。
- 首先我们需要精心的设计散列表及其对应的散列函数,消除掉一些能够导致冲突的先天性因素,从而尽可能地压低日后发生冲突的概率。这也是本节讨论的主题。
- 当然,既然无论如何都不可能从根本上消除冲突。所以我们也应该在事先准备好充分的预案,日后一旦发生冲突,我们就可到此预案及时予以排解。关于冲突的排解技巧,往下看。
2. 何为优劣
什么样的散列函数更好?好的散列函数又必须具备哪些要素呢?
- 首先它必须具有确定性,也就是说词条空间中的任何一个元素都应该能被唯一的映射到散列地址空间中的某一元素。这种映射关系必须是明确的。
- 其次,从计算的角度来看,这种映射应该能够快速地兑现。
- 第三条,相对于更大的取值空间,散列的地址空间要小得多,也需要更加充分的加以利用。为此,散列函数必须是一个满射。
- 同样是为了充分地利用有限的散列空间并降低冲突发生的概率,我们应该使得各关键码被映射到散列表各位置的概率尽可能地接近。也就是要将所有可能的关键码,尽可能均匀地压缩到散列空间中,最大程度的避免很多元素在局部汇聚的现象,也就是所谓的 clustering。
以下我们就按照这4条基本的标准介绍若干种常用的散列函数,并分析起各自的优劣以及适用场合。
3. 整除留余
我们所设计的第一个散列函数,使用的是所谓的除余法。实际上我们对这种方法应该并不陌生。是的,在此前清华大学校园电话簿那个应用实例中,我们所采用的正是这种散列方法。当然,这里有一个问题,你应该还记得当时的表长,也就是这里取模余的基底,是取做90001,为什么取这样一个有点古怪的数字呢?
是的,这个数字的确有些古怪。因为照常理,我们或许会选用一个更为规整的数字,比如熟知二进制的你,很有可能会选取2的若干次方。你甚至会发现,如此可以更加高效地来计算散列地址。
因为此时 m 的二进制展开应该是 1 后面接 1 个 2 个 3 个直到第 k 个 0。而此时,相对于 M 的模余计算,也就等同于相对于 M-1 的位与运算,是的,就计算效率而言,的确如此。
然而就我们刚才所确立的第四条原则,也就是均匀性而言,这种方法却是非常糟糕的。因为在整个关键码的取值空间中存在某个特定的子集,该子集中的每一个元素都会统一地映射到散列表中的某一个特定单元,而不是均匀地分布于整个散列表中。
实际上,M 的每一个同余类,都是这样的一个实例。放弃这些形式极其规整的表长,映射的均匀性的确会有所改进,但这还不是根本的办法。
一种最为简明的策略,莫过于将表长取作为素数。 此时,不仅数据对于散列表的覆盖程度能够达到最充分,而且在散列表中的分布,也将达到最均匀。我不凡从一个角度来理解这一点。
4. 以禅为师
知道在实际应用中,我们所处理的数据通常都具有一定的局部性。其中一种典型的现象是数据序列中的数据项大多是按某一步长单调变换。想想你在程序中常写的 while 或者 for 之类的循环,就应该不难理解这一点。
如果数据序列的步长为 S,我们来考察 S 与 M 的最大公因子,将其记作 G。
假设这就是散列表的地址空间,其长度为 M,而你的数据序列呢?将从某一个位置开始以 S 为间隔,逐次的转入下一项以及再下一项。当然,如果你的数据序列足够长,它就有可能会从另一端回到这个空间,并且继续以 S
为间隔在散列地址空间中逐次访问下去。
现在考察这个数据序列在散列地址空间中留下的足迹。这些足迹能够遍历整个散列表空间吗?如果能,那么这种散列方法就具有均匀性,反之就不具有。
借助数论的知识,不难知道数据序列的足迹能够遍布整个散列表,当且仅当刚才这个最大公因子 G 等于1。
请注意,因为可能有不同的程序,而每一程序的每一次运行所对应的这个步长未必相等。也就是说这里的 M 相对于几乎任何 S 最大公因子都只能是 1。这意味着什么呢?意味着 M 应该是一个素数。
很有意思是很动物包括一些昆虫都懂得这个道理。在这里我们再次向禅学习,学习它的哲学。没错,禅的哲学。
昆虫学告诉我们禅有很多变种,每一个变种都有其固定的生命周期。比如有些蚕是3年,而有些却是17年。那么蝉是否有某一个子类寿命是14年、15年甚至16年呢?据我所知没有。为什么没有呢?不妨回到我们的散列表。实际上,每一只禅在生命周期都可以对应为一个散列表,蝉的寿命有多长,散列表也就有多长。
~
所以有些种类的蝉所对应的散列表长度为13,有些等于17,当然也可能是11等等这类的数字。我们知道,在自然界蝉是弱势群体,它有很多天敌,无论是螳螂还是螳螂之后的那只黄雀。每一种天敌大致也有一个自己的生命周期,这就相当于我们这里的步长 S。每经过 S 年,蝉的天敌都会更新一代。当然蝉不能去改变弱肉强食的法则,但唯一能期望的只能是在同一年不要遇到更多的天敌。
~
相应的,反过来所有的天敌都应该尽可能在每一年分布得更加均匀。这更有利于蝉作为一个物种,得以在自然界延续下去。用数学的语言来说,如果蝉能够选择自己的生命周期,那么自然的就应该选择与天敌的生命周期保持最大公约数为1。
而为了以更多的天敌在生命周期上保持这种关系,尽管禅有不同的变种,但是在经过长时间的进化之后,每一个变种都会聪明地将其生命周期设定为素数,就像我们所看到的那样,取做17、13等等。
5. M + A +D
我们接下来将要介绍的是 MAD法,名字听出来多少有点恐怖,这种方法可以认为是除余法的改进或推广。
是的,如果更为严格地考察均匀性,除余法的确还存在一定的缺陷,体现在两个方面问题。
- 问题首先出在零点,无论表长 M 取值如何,零点总是会被映射到零点,也就是说存在不动点,这与任何元素都拥有均等的概率被映射到任何位置的原则是完全相悖的。
- 其次,尽管其他的元素都大体拥有均等的概率,被分配到各个桶中,但不同关键码之间的这种映射却存在着某种简明的关联关系。具体来说,经过散列映射之后,相邻的关键码也必然依然的相邻。
就此意义而言,除余法所拥有的均匀性只是低阶。当然,我们更希望实现更高阶的均匀性,比如至少临近的关键码在经过散列之后,不要继续地彼此临近。那么如何实现这种更高阶的均匀性呢?
为此我们需要对除余法略做改进。除了表长继续取做素数,我们还需要另外两个整数,整个散列的计算过程包括三步,首先做一次乘法,再做一次加法,最后再做整除模余。这里的计算步骤增加了,但如此却可以针对性地修复此前的两个缺陷。
你看出来了吗?是的,新引入的整数 b 可以视作是偏移量,如此即可有效的消除不动点。而另一个引入的整数 a
则扮演着步长的角色,也就是说,在经过散例变换之后,原本相邻的关键码将变成间隔为 a,从而不再继续相邻。
当然,实际应用的需求多种多样的,我们这里暂且只考虑最普遍的应用。实际上在不同的场合,散列的原则都有可能发生变化,甚至翻转,比如在某些特殊的场合,未必需要高阶的乃至通常的均匀性,比如在一些几何计算的场合,我们需要处理的往往是来自于高维空间中的一系列点。为了将他们压缩到更加低维的空间,我们往往也需要借助散列。此时我们对散列的要求可能恰恰相反,也就是要尽可能使得临近的关键码被映射到临近的位置,这也就是所谓的 locality—Sensitive hashing, 散列技术在当今的信息处理中之所以能够无处不在,恰恰在于它的这些准则是灵活的。
再比如在我们这个课程中所讨论的主要技术多是指在将一个相对而言更大的空间通过散列映射压缩至一个相对而言更小的空间。而实际上反过来也是大有用处的,这也就是所谓的密码学。
6. 平方取中
散列函数是一个庞大的家族,其中的成员形形色色,各有所长,这也是散列技术的趣味性和魅力所在。
接下来我们不妨就来看看其中的几个典型代表。
- 首先是所谓的数字分析法,这里的原则是无论此前的关键码有多少位,我们只从其中挑选出若干位构成散列地址。
比如我们可以将关键码的数位间隔的分为两组,并选择其一组成最终的散列地址。当然,尽管这里需要分组,但分组的方式必须是事先确定的,否则也就违背了确定性的原则。
~
另外,这种方法的缺陷也十分明显,因为没有被选用的那一组数位,对最终的散列地址没有任何的影响和贡献,没有足够的体现均匀性的要求。
就此可以有很多种改进方法。
- 比如平方取中法。按照这种策略我们首先要计算出关键码的平方,并截取中间的若干数位作为散列地址。以123为例,如果没有算错,它的平方应该是15129。因此我们可以取居中的三位,也就是512作为对应的散列地址。更复杂的情况也是按同样的方法处理。至此,你或许会有一个疑问,这里为什么我们会倾向于保留居中的数位呢?实际上,这正是为了使得构成原关键码的各数位能够对最终的散列地址有尽可能接近的影响。
来看这样一幅图,它将一个数的平方运算分解为一系列的左移操作以及若干次加法。如果最终的结果是这个,从图中不难看出,如果忽略进位,每一个数位都是由原关键码中的若干数位经求和得到的。因此两侧的数位是由更少的原数位累计而得。而越是居中的数位,则是由更多的原数位累积而得。因此截取居中的若干位,可以使得原关键码的各数位对最终地址的影响彼此更为接近。
7. 折叠汇总
为了使得原关键码中的各数位对最终地址拥有更为平均的影响力,从而实现更好的均匀性,还有一些非常有趣的散列函数值得借鉴。
- 比如所谓的折叠法,可以按照最终散列地址的宽度将原关键码中的个数位依次分为若干组,并将每一组独立的视作为一个整数,而是他们的总和就是对应的散列码。
- 这种方法还有一些有趣的变种,比如原关键码中的各数位将按照交替的方向转换为对应的整数,123 456 789,诸如此类。
形象地说,这个好比我们需要做一张千层饼。为此我们首先需要将和好的面擀成一张薄片,前一方法会将面继续地切成均等的一段,并按次序将他们落起来,最后通过挤压将他们合成一张薄饼。而相对于真实的千层品制作工艺后一种方法更为接近,也就是以一种往复折返的方式,同样将面折叠起来,并最终压实。
尽管同样是经过分段和对齐,最终压实的那一步,却未必只能通过求和。比如对于二进制,很多种位运算都具有同样的功效,比如最常用的就是按位做异或。就不妨就此来验证这里所给的实例。
当然,此类方法零零种,就此,你应该对反例函数的特征有了更具体的理解。是的,总之大致说来,散列函数越是随机,越是没有规律就越好。
8. 伪随机数
谈到随机,你应该很自然会想到系统所提供的随机数发生器。
比如这就是一种实现的方式,可以看到这里每一个所谓的随机数,实际上都是在前一个所谓随机数基础上,按照确定的计算规则递推而得。因此更为准确的应该称之为伪随机数发生器。
就逻辑效果而言,这等同于将取值范围以内的所有整数按照这种规则重新编排为一个貌似随机实则确定的序列。而这个发生器所有返回的只不过是在这个序列中对应于某个特定秩的那个元素。比如一种最常见的方法就是将这个秩取作系统当前的时间。
如果就接口参数的形式,对散列函数以及伪随机数发生器函数做一对比,我们就会发现二者惊人的相似。 难道不是吗?只不过前者是经统一散列转换之后所得的关键码,而后者只是尾随基数序列中的某个值。 因此我们不妨直接借助后者来实现前者。事实上很有意思的是,如果反过来考察,此前我们已经确立的那四条准则,无论是确定性、高效性、满射性还是均匀性,它们恰好同时也是评判随机数发生器的重要标准。
既然每一个伪随机数发生器都可视作为一个散列函数,我们也可以将散列函数的设计难题转交给伪随机数发生器的设计者,我们可以直接套用他们的工作成果。当然,事情还不是这么简单。
如果采用伪随机数法,有一点是非常重要的。事实上,在不同的平台和环境中所提供的伪随机数发生器所采用的算法不尽相同。即便在同一个平台环境中,不同的历史版本也可能对应于不同的随机数发生算法。 因此你在特定时间、特定平台上所生成的散列表未必可以直接移植到其他的平台。对这一点你应该保持足够的谨慎。
9. 多项式
当然,在实际应用中,原始数据的关键码未必天生都是整数形式,因此往往需要先做一个预处理,将其转化为整数,称作散列码 hashcode,然后才可以将其进一步的转化为桶数组中的地址,浮点数以及字符的 hashcode 转换并不困难。
因此接下来我们重点讨论一下字符串型关键码应该如何更好地完成这种转换。比如一种行之有效的方法就是所谓的多项式法。
比如这就是一个长度为 n 的字符串。
- 我们首先将其中的每一个字符分别转换为对应的整数。
- 接下来,再将这 n 个整数,分别视作为一个 n 次多项式的 n 个系数,并采用事先确定的某一个常数 a 计算出这个多项式的具体数值,并将其作为散列地址。
请注意这样一个一元 n 次多项式可以在 O(n) 而不是 n 平方的时间内计算得出。因为时间关系,我们忽略掉具体的算法。如果你这句算法还不甚了解,这个插图应该会对你有所帮助。当然这里的 O(n)毕竟涉及较为复杂的乘法运算,能否加以避免呢?答案也是肯定的。
比如这就是一种可行而有效的方法。实验数据也表明,这种散列码转换算法非常适用于英文字符串。可以看到,这里也是通过一个 for 循环依次的处理串中的各个字符。对于每一个字符,我们首先将其转换为整数,并对其做累计,而在每次这样的累计之前,原有的累计值都要按这种方式做一个数位变换。
这图可以帮助我们理解整个变换的过程。如果这是此前累计值的二进制展开,一般的取做32位,那么调整的实际效果就是将前端的5个比特与此后的27个比特互换位置。这一不断调整、不断累加的过程,实际上可以视作为是对以上多项式计算的近似,只不过这里消除了相对费时的乘法计算。
至于如何来具体理解和解释这种近似的效果。当然,无论是原始的多项式法,还是变通之后的近似方法,其计算过程都不明显的相对复杂。
你或许会质疑,有这个必要吗?答案是的确有。
10. Vorldmort
针对字符串关键码的hashcode转化,最自然的方法莫过于此。具体的与多项式法一样,这里也将每一个字符事先与某一个数值对应起来。而所有字符所对应数值的总和也自然就是所对应的 hashcode。
这个方法与此前所介绍的折叠法类似。但这里的情况却有所不同,以至于这种方法将会导致频繁地冲突。
来看这样一个字符串—— Tom Marvel Riddle。
如果你熟悉 Harry Potter 的故事,那么对 Tom Marvel Riddle 就不会陌生。是的,它是伏地魔的一个化身,而这个名字也是伏地魔的一个化名。
你也应该会记得这样一句话——I am Load voldemort。
是的,这是 Tom Marvel 在自认为已经掌控了Harry Potter 的生死时,颇为得意地写出的一句话。而谜底恰好就藏在这两个字符串中。即便你不知道这个故事,现在也不难看出这两个字符串实际上是由同一组字符构成的,只不过排列次序不同而已。
如果按照我们直观的想法,将该字符串中的所有字符所对应的整数累积起来,就会得到一个 hashcode 196。那么 I am Load voldemort,不难理解,尽管次序有所调整,但因为加法满足交换率,所以同样应该得到196。
当然,既然这两个串都是由同一组字符构成的,所以 hashcode 相同也毫不奇怪。然而事实是,即便是由不同的一组数字所构成的英文字符串,按照这种映射方法也同样会有很高的概率发生冲突。
继续刚才的故事,应该还记得第七集。是的,在那集中我们终于得知 Harry Potter 居然也是 voldemort 化身或者死亡魂器之一。那么在冥冥之中,Harry Potter 是否真的与伏地魔有某种因果联系呢?我们不妨来做一次散列,如果还不是占卜的话,来看这样字符串。
既然 Tom Marvel与 voldemort 的联系是源自其名字的巧合,那么 Harry Potter 与 voldemort也应该是如此。嗯,必须的。