高级哈希表

【转】前端

这一节涉及数学超级多,各类数论知识,各类不明觉厉! 看了几遍,才勉强看懂一些,因此这算法

篇稍微简单的介绍着两种hash table, 省得瞎说说错了。数组

这一讲的主要知识点是: 1. 全域哈希及构造    2. 完美哈希 数据结构

1. 全域哈希及构造框架

介绍全域哈希以前,要先讨论一下普通哈希的一个缺点。 举个charles举得那个例子:若是你函数

和一个竞争对手同时为一家公司作compiler的symbol table, 公司要求大家代码共享性能

(o(╯□╰)o),大家作好后公司评判的标准就是 你俩互相提供一些测试样例,谁的效率高就买 谁的。学习

而后, 普通哈希的缺点 就出来了:对任意的hash函数h,总存在一组keys,使得测试

, 对某个槽i。即我总能够找到一组键值,让他们都映射到同一个槽里面,这样效率搜索引擎

就跟离链表差很少了

解决的思想就是:独立于键值, 随机 的选择hash 函数。这就跟快排中为避免最差状况时随机化

版本差很少。可是选取hash function的全局域是不能乱定的,不然也打不到理想的性能。

下面就给出全域哈希的定义:

设U是key的全局域, 设\(\mathcal{H}\) 是哈希函数的有限集合,每个都是将U映射到

{0,1,..,m-1},即table的槽内。 若是对全部不等的\(x,y\in U\),有

换句话说,就是对于任意的不相等key的x和y, 从哈希函数集中选择一个哈希函数,这两个key

发生冲突的几率是1/m

  

更形象的,当我随机选一个哈希函数时,就像在上图区域乱扔一个飞镖,落在下面红色区域中

就会发生 冲突,这个几率是1/m

下面给一个定理,说明为何全域函数就是好的:

设h是从哈希函数全域集\(\mathcal{H}\)中随机选出的函数h. h被用做把任意n个键映射到表T的m个

槽中,对给定键值x,咱们有:

定理: E[#collision with x]<n/m

Proof: 设\(C_x\)是表示与key x冲突的键值数量的随机变量,设\(c_{xy}\)是指示变量,即

则,\(E[c_{xy}]=1/m\) 且\(C_x=\sum_{y\in T-\{x\}}c_{xy}\),则

 证毕!

这个定理想要说明的是,这种全域哈希的随机化选择能够达到哈希表理想的效果。注意这里

n/m是之 前定义过的load factor

如今给出一种 构造全域哈希 的方法:

首先选择一个足够大的质数p,使得全部的键值都在0-p-1之间。且设\(Z_p\)表示{0,1,...,p-1},设

\(Z_p^*\)表示{1,2,..,p-1}. 由于槽m的数量少于key的数量,全部m<p.

而后咱们就能够设计哈希函数了,设任意的\(a\in Z_P^*,b\in Z_p\),而后

\(h_a,b(k)=((ak+b)mod p)mod m\)

全部这样的哈希函数族为:

\(\mathcal{H}_{p.m}=\{h_{a,b}:a\in Z_p^*, b\in Z_p\}\)

例如:选定p=17,m=6,\(h_{3,4}(8)=5\). 每一个哈希函数都是将\(Z_p\)映射到\(Z_m\). 咱们还

能够看到这个哈希函数族共有p(p-1)个哈希函数

针对这种构造方法构造出的是全域哈希函数的证实就略过了,涉及数学知识确实比较多,讲很差。

 2. 完美哈希 

当键值是static(即固定不变)的时候,咱们能够涉及方案使得最差状况下的查询性能也很出色,这就是

完美哈希 。实际上,不少地方都会用到静态关键字集合。好比一种语言的保留字集合,一张CD-ROM

里的文件名集合。 而完美哈希能够在最坏状况下以O(1)复杂度查找,性能很是出色的。

完美哈希的思想就是采用两级的框架,每一级上都用全域哈希

完美哈希的结构如上图。具体来讲,第一级和带链表的哈希很是的类似,只是第一级发生冲突后后面接

的不是链表,而是一个新的哈希表。后面那个哈希结构,咱们能够看到前端存储了一些哈希表的基本

性质:m 哈希表槽数;a,b 全域哈希函数要肯定的两个值(通常是随机选而后肯定下来的),后面跟着

哈希表。

为了保证不冲突,每一个二级哈希表的数量是第一级映射到这个槽中元素个数的平方,这样能够保证整个

哈希表很是的稀疏。 下面给出一个定理,能更清楚的看到设置m=n^2的做用

定理: 设\(\mathcal{H}\)是一类全域哈希函数,哈希表的槽数m=n^2. 那么,若是咱们用一个随机

函数\(h\in\mathcal{H}\)把n个keys映射到表中。冲突次数的指望最可能是1/2.

Proof:根据全域哈希的定义,对任意选出的哈希函数h,表中2个给定keys冲突的几率是1/m,即1/n^2

且总共有\(C_n^2\)可能的键值对,那么冲突次数的指望就是

\(C_n^2\cdot 1/n^2=n(n-1)/2\cdot 1\n^2 < 1/2\)   证毕!

为了冲突的理解从指望转换到几率,引入下面这个推论

推论: 完美哈希没有冲突的几率至少是1/2

Proof: 这里主要要用到一个不等式Markov's inequality-对任意非负随机变量X,咱们有

Pr{X≥t}≤E[x]/t

利用这个不等式,让t=1,便可获得冲突次数大于1的几率最多为1/2

由于第二层每一个表槽的个数是这个表中元素n^2,可能会感受到这样存储空间会很大,实际上,能够证

明\(E[\sum_{i=0}^{m-1}\Theta(n_i^2)]=\Theta(n)\), 由于证起来蛮复杂,因此我也略过了%>_<%

 

【转】

先来看一个TopK题目: 搜索引擎会经过日志文件把用户每次检索使用的全部检索串都记录下来,每一个查询串的长度为1-255字节。
    	假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但若是除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。),请你统计最热门的10个查询串,要求使用的内存不能超过1G。
	如何解答?Topk以前已经说过,寻找最小的K个数。
	但是咱们如何处理Query呢?一千万条记录,每条记录是255Byte,很显然要占据2.375G内存,很明显不能用内部的排序,不管是什么内部排序。这个时候能够用外排序,归并排序能够解决。但是题目也说了除去重复最多300W,300W彻底能够放入内存,但是如何把1000W的字符串放入内存呢?这就是咱们接下来要说的了,Hsah Table彻底能够解决。
	不要着急,听我细细道来。
	说哈希以前先来讲一下直接寻址表,这个相似BloomFilter和位向量。若是关键字域比较小,也就是说关键字很少,并且都在必定范围内。那咱们能够彻底把关键字当成数组下标,每个关键字放入哈希表的一个槽。这也便是一一映射,映射结果不变化。

 

	这个看起来蛮不错的,操做也很简单,每一个操做时间代价都是O(1).

 

 

 

	确实很不错。但是这只是关键字分布较小范围的时候才会有做用,并且还要求关键字都不能相等。。。若是有100个整数,都是64位的,有的很小,有的超大。这个时候你定义的数组的大小岂不是2^64-1,你能忍受吗?你还会用这种方法吗?2个数,1和100000000,你定义的数组大小也必须是100000000,这样才符合刚才的直接寻址法。太浪费内存了吧。。
	因此来讲,直接寻址当然不错,但是限制太多。关键字不重复,关键字的范围要小。
	接下来我正式的介绍一下哈希表。
	什么是哈希表?Hash Table也叫散列表。
	哈希表是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它经过把关键码值映射到表中一个位置来访问记录,以加快查找的速度,存放记录的数组叫作哈希表。建好的Hash表的查询速度是常数级O(1),这样就比较nice了。
	Hash映射就是用一个Hash函数将关键字key映射到Hash表的槽里。这个映射不是一一映射了,即使是一个字符串也能映射成一个整数。对于刚才的直接寻址表来讲就是key映射到key槽,而Hash函数将key映射到H(key)槽里。而咱们要定义的表的大小只是关键字的数量,没必要是关键字范围的大小和关键字的重复,这样就不会浪费内存。并且插入一个须要的时间也是O(1),不过咱们用Hash表大部分是为了查询,查询的时间复杂度也是O(1)。

 

 

 

 

	不幸的是这可能会出现问题,什么问题?不一样的关键字映射到相同的槽里,碰撞collision出现了。这咋办啊?关键字总不能舍弃吧?难道咱们指望他们不会碰撞?这是不现实的。不过不要着急,咱们能够在那个槽的地方拉一个链表,将全部映射到这个槽的关键字都放到这个链表里面。这就是所谓的拉链法,也叫连接法

 

 

 

	对于每个槽都给他拉一个链表,到时候凡是Hash值相同的映射到同一个槽的都要放到链表里,链表的大小随时变化

 

 

	查询和插入的时间复杂度一样是常数级O(1)的。解决了?NO。通常状况下还好,若是全部的Hash值都相同了,也就说因此的关键字都映射到一个相同的槽里,这Hash表就变成了链表了,并且内存占用比链表还大。查询的时间复杂度是O(N)。这不是坑吗?那咱们还用它作什么?不用了?唉唉唉,凡事总有例外,这个不能怪Hash表,只能说Hash函数太差了,若是选一个好的Hash函数,这个状况就不会出现(NO,下文会有说明,想一想)。一个理想的Hash函数会将全部的关键字都平均均匀的映射到Hash表的不一样槽里。也就是说这个状况是最坏的状况,拉链法的平均状况仍是很好的。
	咱们来分析一下拉链法的平均状况假设对于每个关键字key都用相同的记录映射到Hash表的任何一个槽里,并且每个关键字key都是相互独立的,这就是简单一致Hash。假设有n个关键字,Hash表有m个槽。那么有两个key被Hash函数映射到同一个槽里的几率有多大?1/m,互相独立,互不影响。定义装载因子α=n/m,即Hash表中每个槽中关键字的平均数量。(α>1  <1  =1)则对于Hash表的下标i的链表中的关键字数ni=α。则此时查询用时O(1+α),包括查找成功和查找失败。
	定理:在简单一致Hash的假设下,对于连接法解决碰撞的Hash表,平均状况下成功查找和查找失败用时都是O(1+α)。
		α是槽里关键字的平均数量。若是n=O(m),α=n/m=O(m)/m=O(1)。那么查询的时间复杂度就是常数级O(1)。通常状况下m和n都是一个数量级。
	为了不连接法的最坏状况,选择一个好的Hash函数是相当重要的
咱们来看一下经常使用的Hash函数。
除法哈希法,也叫除留余数法。经过关键字除以槽数m将关键字映射到槽里的方法。哈希函数是H(k)=k Mod m。
	举个例子,m=12,k=100,H(100)=4。
	而若是m=2k,那么不管k是什么,H(K)的值都是一个0和奇数,也便是说只要奇数槽和0槽被占用,其余的偶数槽都是浪费掉了。若是m=2^r,那么H(k)的值就是k的低r位(化成二进制)。这样形成的后果是某一个槽有不少的关键字。因此来讲通常的m取值尽可能不要接近2的整数幂,并且还要是质数。
	这样虽然很好了,但是除法毕竟在计算机运算是不快的,因此咱们再讲一个乘法Hash。
乘法哈希法:用关键字乘A(0<A<1),取其结果的小数再乘以m取整。 Hash函数是H(k)=[m(kA Mod 1)].其优势是对m没有什么要求,通常选择2的整数幂(呵呵)。
假设计算机字长为w位,把k化为w位的二进制,A=s/2^w(<0s<2^w),m=2^p,则

 

	这样就比较好了,A取值没啥要求。最好的A=(√5-1)/2。还有一些平方取中法,折叠法等等。
	说了这么些Hash函数。再来看一下避免碰撞的方法。除了连接法以外还有别的方法避免collision吗?固然,开放寻址法。
	开放寻址,和连接法不一样的是这没有链表,全部的关键字都放入槽内,若是Hash值相同此槽已有关键字,则再次Hash查询,直到找到一个空槽放入关键字key为止。查询序列也很关键,不过这是和第一次Hash值是有关系的。查询序列不必定是0 1 2 3....m,但其实只是m!中的一个,Hash表有m!种查询序列。对于每个关键字查询序列是h(k,0),h(k,1),h(k,2)...h(k,m)。
查询和插入都很方便,但是删除确实很麻烦

 

	删除麻烦在哪里呢?由于咱们要Hash不少次,好比k=496,第一次Hash(496,0)=586,但是发现槽586处有关键字370,第二次Hash(496,1)=204,发现槽204处有关键字37,第二次Hash(496,2)=304,发现槽304空,放入关键字496.若是删除值370,370在槽586处。而后我再查询496,第一次Hash获得586,发现槽位空,则说明496不存在,但是496明明是刚才插入的。因此来讲删除不是仅仅删除就完事了,要作一个标记DEL,以避免影响Hash,并且再次插入的时候这个标记表示是空槽能够插入,查询的时候看到此标记能够绕过去。
	那么咱们如何构造开放寻址Hash函数呢?
	线性探测:H(k,i)=(H1(k,0)+i)Mod m,H和H1能够相同也能够不一样。这样咱们的查询序列就是从第一次Hash值开始一个接一个的查询空槽直到找到为止,只须要第一次Hash值便可,很简单。但是这个函数会出现问题,群集问题。就是说会形成一个很长的连续序列都不是空槽,而以前以后都有一连串的空槽,这样若是关键字的Hash值在这个序列中的话将会形成无用的遍历,甚至会到m槽,而0开始的序列有不少的空槽。这样无谓的浪费了不少的时间。		
	二次探测:咱们不要这样一个接一个的查询空槽,而是间隔的查询。能够把i换成i^2或者变成H(k,i)=(H(k)+c*i+c*i^2),这样会好不少的,不过这样也会形成群集,二次群集。若是两个关键字的初始查询值相同,那么他们的查询序列也是相同的,二次群集的长度稍微短些,危害小些。不过这两种探测方法的查询序列都只是m种罢了,而Hash表的查询序列但是m!种。不过接下来咱们说一个更好的,有m^2种查询序列。
 双重哈希:H(k,i)=(H1(K)+i*H2(k))Mod m,这种Hash方法能够大幅度的减轻群集现象。H1和H2都有m种查询序列,因此H有m^2种查询序列。这时候取m的值为2的整数幂,并且要H2函数的Hash值要老是产生奇数。

 

 

	不过尽管开放寻址很好,但是最坏的状况依然仍是不好,避免不了最坏的状况。因此咱们来分析一下平均状况,看一看指望值。α=n/m是槽里关键字的平均数量,对于开放寻址来讲α必然是小于等于1的,由于每一槽最多放入一个关键字。
	假设对于每个关键字key都用相同的记录映射到Hash表的任何一个槽里,并且每个关键字key都是相互独立的,这就是简单一致Hash。假设有n个关键字,有m个槽的Hash表。对于失败的查找,第一次查找失败的几率是n/m(由于此时m中有n个数),那么第二次查找失败的几率是多少?(n-1)/(m-1),由于以前那个已经排除,再也不查询。第i次查找失败的几率是(n-i+1)/(m-i+1)(<n/m)。
	那么指望查找的次数E=1+n/m(1+(n-1)/(m-1)(1+...)+))=1+α(1+(1+α(1+...)))<1+α+α^2+α^3+...=1/(1-α)。因此来讲失败查找的次数是1/(1-α),而成功的查找也是同样的,查找失败不是能够插入吗?那但是空槽啊。
	定理:在一致哈希的假设下,对于一个开放寻址的Hash表,平均状况下成功查找和查找失败的次数都是1/(1-α)。
	若是1/(1-α)=0.5,须要查询2次,而1/(1-α)=0.9,须要查询10次,因此通常状况但愿1/(1-α)小一些比较好,这样查询次数才少。千万不要觉得Hash的利用率越高,Hash很稠密才好,那样会使查询速度变的很慢。咱们用Hash是为了什么,不就是为了快速的查找和插入吗?若是速度都没有了,咱们还要它干什么呢?
	好的Hash函数确实很重要啊,但是再好的Hash函数也不可避免碰撞,老是能找到一组关键字能够用你给定的Hash函数映射到同一个槽,查询时间变成O(n)最坏状况。这一点,与Hash函数没有任何关系,难道咱们说了半天,P用没有?是有点坑啊。这个时候咱们不禁得想起了随机性,若是咱们随机给你一个Hash函数,那你就没办法必定给我致使最坏的状况。这就是全域哈希。
	全域哈希的思想就是执行算法开始从一个设计好的Hash函数集中随机选出一个函数,对于给定的关键字集合就没有办法致使最坏的状况。
	若是全域哈希函数集合为H,而关键字集合为U,则对于U中关键字不一样的key碰撞的几率是1/m。那么也就是说有|H|/m个哈希函数知足这个状况。
	定理:若是h选自全域哈希函数集H的哈希函数,那么将n个关键字映射到m个槽中。则查询失败次数的指望查询次数就是α,而成功查询的指望次数是α+1。
	利用全域哈希能够获得常数级的时间复杂度,由于n=O(m),α=O(1);
	咱们之因此使用Hash,看中的就是它的平均时间复杂度能够达到O(1)。大部分地方状况下咱们利用Hash就是为了查询,若是咱们仅仅但愿建立一个静态的查询Hash Table,那么咱们能够获得更好的效果。那就是彻底哈希。
	彻底哈希:在最坏的状况下进行查找的时间复杂度是O(1)的哈希技术。
	实现办法就是利用二级哈希表, 每一级的Hash函数都使用全域哈希函数。第一级的Hash表和以前没有什么区别,将关键字映射到槽里,可是若是发生碰撞了,咱们利用拉链的思想,可是不用链表作,对于每个碰撞的槽i再创建一个小型Hash表hi,而Hash表的大小mi是碰撞关键字ni的平方,即mi=ni^2.

 

 

	定理:对于一个从全域哈希函数集选择的哈希函数h,将n个关键字映射到m=n^2个槽里的哈希表,发生碰撞的几率小于1/2.
	简单证实一下:对于m个槽两个不一样的关键字碰撞的几率是1/m=1/n^2,而从n个关键字选出2个关键字的组合数是n(n-1))/2,则n(n-1))/2*1/n^2<1/2。
	那么对于二级哈希表来讲只要知足m=n^2,那就能够实现低几率碰撞的常量时间的查询。
	但是二级哈希表的空间复杂度会不会太大?固然不会啦,若是一级哈希表作的好,那么二级的空间复杂度确定会好的。
	定理:对于一个从全域哈希函数集选择的哈希函数h,将n个关键字映射到m=n个槽里的哈希表,并且对于二级哈希表的大小为ni=mi^2,则一个彻底哈希的哈希表的指望空间复杂度小于2n,便是O(n).
	在这里要扩展一个哈希算法,便是d-left hashing。d-left hashing中的d是多个的意思,先看一看2-left hashing。2-left hashing指的是将一个哈希表分红长度相等的两半,分别叫作T1和T2,给T1和T2分别配备一个哈希函数,h1和h2。在存储一个新的key时,同时用两个哈希函数进行计算,得出两个地址h1[key]和h2[key]。这时须要检查T1中的h1[key]位置和T2中的h2[key]位置,哪个位置已经存储的(有碰撞的)key比较多,而后将新key存储在负载少的位置。比较的是两个哈希函数映射的位置中已经存储的key(包括碰撞的状况)的个数,而不是两个子表中已有key的个数。若是两边同样多,好比两个位置都为空或者都存储了一个key,就把新key存储在左边的T1子表中,2-left也由此而来。在查找一个key时,必须进行两次hash,同时查找两个位置。 
	了解了2-left hashing,d-left hashing就很好理解,它只是对前者的扩展。2-left hashing固定了子表的个数是2,d-left hashing更加灵活,子表的个数是一个变量d,同时也意味着哈希函数的个数是d。在d-left hashing中,整个哈希表被分红d个从左到右依次相邻的子表,每一个子表对应一个相互独立的哈希函数。在加入新key时,这个key被d个哈希函数同时计算,产生d个相互独立的位置,而后将key加入到负载最轻的位置(bucket)中。若是负载最轻的位置有多个,就把key加入到最左边的负载最轻的子表中。一样地,若是要查找一个key,须要同时查找d个位置。
	OK,Hash表说先到这里,之后学到新的知识仍是update。由于Hash算法博大精深,这只是九牛一毛而已。之后还要多多学习。
明白了这些 刚才的TopK问题就变的很好解决了,本身想一下吧,我就很少说了。
相关文章
相关标签/搜索