diff --git a/thu_dsa/hash.md b/thu_dsa/hash.md new file mode 100644 index 0000000..1d727ff --- /dev/null +++ b/thu_dsa/hash.md @@ -0,0 +1,92 @@ +Conclusions on HashTable +======================== + +## 散列的基本概念 + +> 什么是散列?为什么需要散列? + +据邓公所说,散列是一种思想。与已经学过的其他数据结构相比较,向量是采用循秩访问(call by rank)的访问方式,列表是采用循位置访问(call by position)的访问方式,二叉搜索树是采用循关键码访问(call by key)的访问方式,散列与他们都不一样,是采用循值访问(call by value)的访问方式。 + +举个例子,你现在身处同济大学嘉定校区,四周是一片荒芒,这个时候你想回家了。沿世界上所有的街道一间一间房找过去,这是循秩访问;你记得你家是住在四川省成都市某街道多少号,然后你可以依次先到四川省,再到成都市,再到某条街道,然后找到你家,这是循关键码访问;而循值访问,则是你通常会采用的方法——你根本不用去回想我家的地址是多少,你知道它就在那里,就在家这个词刚刚出现在你的脑海中的时候。想到家乡,你想到的不是地址或者一串数字,而是一个生动的影像,包含它的环境,四周的风物,已经曾经的朋友。这就是循值访问。 + +可以看到,相对于其他的访问方式,循值访问是将被访问对象的数值,与它在容器中的位置之间,直接建立了一个映射关系,从而对于任何对象的基本操作(访问,插入,删除)都只需要常数$O(1)$的时间,达到了最理想的境地。这就是人类需要散列的原因,你无法不被如此的诱惑所吸引。 + +> 完美散列 + +在时间与空间性能上均达到完美的散列,称为完美散列。 + +也就是说,对于完美散列,其中的每一个值,都可以唯一地映射到散列表中的一个位置,既无空余,亦无重复。从映射角度来看,完美散列是一个单射,同时也是一个满射。Bitmap就是完美散列的一个例子。 + +可以看出,完美散列实际中并不常见,在大多数的情形下,关键码的取值是远远大于词条的个数的,设关键码的取值为$[0, R)$, 词条的个数为$N$,则$R >> N$。设散列表的大小为$M$,此时,从定义域$[0, R)$到值域$[0, M)$的映射不可能是单射,即不可避免地会出现不同的关键码映射到散列表中的同一个位置,即所谓冲突。因此就需要合理地选择这一个映射关系,即散列函数,使冲突出现的可能性最小;同时还应该事先约定好一旦出现这种冲突,应该采取的解决访问。这两个问题将在下面重点讨论,即散列函数的设计与冲突解决方案。 + +## 散列函数的设计 + +> 散列函数的设计方案?什么是好的散列函数? + +前面提到,从词条空间到地址空间的映射,即散列函数,绝对不可能是单射,冲突是一定不可能避免的,但是好的散列函数应该保证尽可能地少出现冲突。由此,可以提炼出散列函数的几个设计指标。 + ++ 确定性。散列函数确定的条件下,同一个关键码应该总是映射到同一个地址,这样才满足一个函数的定义。 ++ 快速性。是指散列地址的计算过程要尽可能快,要能在常数时间内完成。 ++ 满射。好的散列函数最好是一个满射,这样可以充分利用散列空间,尽可能地减少冲突的发生。 ++ 均匀性。也是为了减少冲突的发生,关键码被映射到各个散列地址的概率应该接近于$1/M$,这样可以防止汇聚(clustering)现象的发生,即关键码只被映射到少数的几个散列地址,在局部加剧散列冲突,全局的散列空间也没有得到充分地利用。 + +总之,为了保证冲突尽可能地少,散列函数越是随机,越是没有规律越好。 + +### 几个散列函数的实例 + +> 除余法(division method) + +除余法的整体思路非常简单,即用关键码的值对散列表的长度$M$取余,即$hash(key) = key\ mod\ M$,这样可以将关键码映射到整个散列空间上。 + +这里问题的关键在于散列表长度$M$的选择。考虑有一组数据,其中的关键码以固定步长$S$变化(实际中的数据往往就是这种形式的,而不是随机的,例如for循环一般就是固定步长的数据)。设第$i$个数据第一次与第$j$个数据的关键码发生冲突,即 +$$ +S\times i \equiv S\times j\ (mod M) +$$ +即$Si$与$Sj$是同余类,所以 +$$ +S(j - i) \equiv 0 \ (mod M) +$$ +由此可解得 +$$ +j - i = \frac{M}{gcd(M, S)}\ gcd for Greatest Common Divisor +$$ + +根据上面对散列函数设计要求的分析,我们是希望散列函数可以尽可能地减少冲突,即这里的$j - i$尽可能地大,就需要保证$M$和$S$的最大公因数要尽可能小,因此$M$要和$S$互质。但是由于散列表存储的不同数据具有不同的步长$S$值,要使$M$与所有可能的步长$S$互质,只有当$M$本身就是一个素数才可能实现。 + +> MAD法(Multiply-add-divide method) + +上面的除余法还存在着一些问题。 + +首先,除余法得到的散列地址,依然存在一定程度的连续性,即原来相邻的关键码对应的散列地址也仍然是相邻的;其次,在除余法中关键码较小的那些词条,始终被映射到散列表的起始区段,其中关键码为零的元素,其散列地址总是零,即是一个不动点,这显然违背了散列函数应该越随机,越没有规律越好的原则。MAD法正是对除余法上述问题的一个改进。 + +MAD法,顾名思义,就是对于关键码,依次执行乘法、加法和模余运算,即 +$$ +hash(key) = (a \times key + b)\ mod\ M +$$ + +这样,只要适当选择$a, b$,MAD法可以很好的克服除余法原有的连续性缺陷,其中参数$a$的作用是使相邻关键码的散列地址更加分散,$b$的作用是作为一个偏移量,去掉不动点。 + +> 数字分析法 + +遵循散列函数越是随机没有规律,就越好的原则,引入了数字分析法,即对于关键码key的特定进制展开,抽取其中的几位,映射到一个散列地址。 + +比较简单的情形就是取十进制展开中的奇数位,作为散列地址,例如 +$$ +hash(123456789) = 13579 +$$ + +除此以外,还有平方取中法,即对于关键码$key$取平方,然后截取中间的几位来作为散列地址。之所以选择中间的几位,是因为中间的几位是受到了原来的关键码更多数位的影响;相对于取高位数字(只受到原关键码高位数字影响)或者低位数字(只受到原关键码低位数字影响),取中间位数综合了更多位数的影响,因此随机性、均匀性更强,更不易出现冲突。 + +此外,还有折叠法往复折叠法。 + +为了保证经过这些方法得到的值仍然落在散列空间以内,通常还都需要对散列表长度$M$在取余。 + +> 随机数法 + +既然散列函数是随机性越强越好,那一个简明的思想是直接利用生成的伪随机数来构造散列地址。这样的话,任意一个伪随机数发生器本身就是一个好的散列函数了。即 + +$$ +hash(key) = rand(key)\ mod\ M +$$ +其中,$rand(key)$是系统定义的第$key$个伪随机数。 +