麻省理工学院公开课:算法导论。B站地址,网易公开课也有对应的资源。
https://www.bilibili.com/video/av1149902/?p=7
哈希表
维基百科的解释:
https://zh.wikipedia.org/wiki/%E5%93%88%E5%B8%8C%E8%A1%A8
参考:https://hit-alibaba.github.io/interview/basic/algo/Hash-Table.html
哈希表的内容比较多,这里花两个课时来讲,包括下一节课。
这节课的主要内容是:常用的两种哈希算法,除法哈希法和乘法哈希法,以及解决碰撞的两种方法,链接法和开放寻址法。
引入——符号表问题
哈希是一项强大的技术,在很多地方都有用到。这里用一个问题来引入,这个问题经常出现在编译器里,即符号表问题。
有一个表S,里面放着n条记录,对于每条记录,有个指针x,x通常是一个指向实际数据的指针,x.key或x->key。接下来要对这张表上的数据进行一系列操作:
- 插入:在表内插入一条数据x,insert(S, x),即S <- S ∪ {x}
- 删除:从表里删除一条记录,delete(S, x),即S <- S - {x}。注:删除不是删除键值,是删除整条记录。如果想要删除有特定键值的记录,即只知道k,不知道x,则需要先进行搜索。
- 搜索:用给定的键k进行搜索,search(S, k),当x的键值为k时,这个函数返回x。即key[x] = k,如果没有相应的记录,返回nil(空值)。
因为这个集合可以进行动态的插入和删除,所以也称为动态集。即插入和删除的操作,将集合动态化了。
直接映射表
直接映射表是一个比较简单的结构,但不是一直能用,这里会列出它能发挥作用的情况。
当键值的分布比较小的时候,就能发挥作用。假设这些键是来自一个有m个元素的集合U,即U = {0,1,2,...,m-1},并假设这些键相互独立。
直接映射表是如何工作的?
建立一个数组T = [0,1,2,...m-1]表示动态集合S。
T[k] = x,if x∈S 以及 key[x] = k
T[k] = nil,else
首先,插入是把相应位置的值设置成要插入的值,删除是直接移除该位置的值,搜索是直接通过索引来查看槽的内容。所有的这些操作,在最坏情况下的时间复杂度为Θ(1)。
但在实际中,能用到直接映射表的情况非常少。主要局限性在于:
if (m-1)是一个非常大的值。假设这些键是取自字符串,比如人名,那么表内就会有大量的空槽,远超出我们需要保存的数据量。我们所希望的是在保存东西之余,还能让表的规模尽可能地小,同时还能保留某些特性。这就需要哈希表了。
哈希表
定义:所谓的哈希法,就是用一个哈希函数H来“随机”映射那些键(不是完全的随机)。这种情况下,数组的索引称为槽(slots)。
我们可能有一个很大的键的全域,称之为U。有一个由我们建立的有m个槽的表。U内有一个集合S,是U内很小的一块。我们要做的就是从S中取出一个键,映射到哈希表的某个位置,然后再取另外一个键(把这个键代入哈希函数,函数会返回给我们一个特定的槽),映射到哈希表的某个槽里。最后我们将键分布到了整个表里。
但是映射的过程也不是那么顺利,总会碰到一些问题。比如S中两个不同的键映射到同一个槽里,即碰撞。
碰撞:当一个记录要插入到映射表时,却被映射到一个早已有记录的槽时,碰撞发生了。
该如何解决碰撞的问题呢?
不能丢弃任何数据,也不能把它当成缓存,虽然缓存用的也是哈希结构。
链接法解决碰撞
为每个槽创建一个链表,把所有映射到这个槽的元素,都存到这个槽的链表里去。这就是用来解决碰撞的链接法。即把相同的哈希值的记录放到一个链表里储存。
例子:
h(49) = h(86) = h(52) = i
最坏情况分析:
所有键哈希映射到同一个槽,一直在进行碰撞,最后变成了链表。在里面查找某个键值的元素时,花费的时间为Θ(n),这里假设S的大小为n。
分析平均情况:
一个理想的哈希函数要做什么呢?把键基本随机映射到一个槽上,应该真正地随机分布。我们把这种假设情况称为“简单均匀哈希”。意思是:每个属于集合S的键值k,即k∈S,都有相同的几率被哈希映射到表T的任何一个槽里。
这里还另外需要一个独立性的假设:每个键与其它被哈希的记录或键之间,相互独立。
基于以上两个假设,两个键被映射到同一个槽的概率为1/m。
载荷因子
定义一个哈希表,存放了n个键,一共有m个槽。其载荷因子α = n/m,其实就是每个槽里键的平均数量。
先看下失败搜索(即搜索的键不存在哈希表中)的预计用时,为Θ(1+α),这里的1是指把键值哈希映射到槽所需要的时间,α是指搜索槽对应的链表,所花费的时间。如果α>1,那么时间接近于α;否则时间近似为一个常数量。
那么什么情况下预计搜索时间等于Θ(1) ?
α为一个常数,即α = O(1),即n=O(m)。
事实上,一个成功搜索所需要的时间也是Θ(1+α),但证明这个需要做一点数学运算。
如何选一个哈希函数
因为哈希的插入、删除和查找都只需要常数的运行时间,这是为什么哈希这么受欢迎的一个原因。只要你的哈希表的大小,不要远小于你要放在里面的保存的记录个数,那么所有操作都只需要一个常数量的运行时间。
但在很大程度上,这种情况是建立在“简单均匀哈希”的假设上,不论你用的是什么哈希函数,都能找到一些元素,使得哈希映射出的结果非常糟糕。比如可以制造出一堆元素,代入到哈希函数里,看看它们会被映射到哪个槽,但是最后都被映射到了同一个槽里。所以需要反过来想想什么才是好的哈希函数。
在实际应用中,大多数程序用到的并不是真正由逆向工程得到的哈希函数。有些非常简单的哈希函数,在现实中也能起到很好的作用。
那如何选择哈希函数呢?我们希望的结果是:
- 把键均匀分布到槽里
- 而键本身的一些分布的特性,不会影响到它在哈希表中分布的均匀性。(比如,经常见的一个分布特性是,所有插入的键值都是偶数。)
除法哈希法
用于快速哈希函数里,最受欢迎的方法是除法哈希法。
具体的实现为
h(k) = k mod m (mod表示取余),m是哈希表里槽的数量
需要注意:不要选太小的值来做除数,这里把除数写成d,方便下面看
例如:d=2,即m是一个偶数。如果刚好碰到了上面提到的情况:所有的键都是偶数。因为偶数对偶数取余,得到的也是偶数,所以永远不可能映射到一个奇数位的槽,即奇数槽都不会被用到。
另一个极端的例子,如m=2r,也就是说,它所有的因子等于我们的小除数d。在这种情况下,如果进行k mod m,它的哈希过程中就不会考虑到k所有的位(二进制)。
比如定义了二进制数如下,r=6,那么m = 26。
k=1011000111011010,这串二进制数对26取余的哈希结果如何?
对2的幂取余,如果是21,结果为0;如果是22,结果为10;如果是23,结果为010;所以对26取余,结果为k的后六位,即011010。所以h(k)的结果为011010。
对2的幂取余的时候,其实是在取它(二进制)后几位的数字。对2r取余,就是取它的后r位数字。
这样的话,哈希函数就与简直的其它几位(前面的位数)无关了。所以好的方法是取质数来作为m的值,不要太接近2的幂或者10的幂这些数。因为2和10是全世界最常见的底数,也是最有规则性的底数。
后面会看一个比除法哈希法更好的算法,但是大家比较喜欢用除法哈希法是因为,它能方便地嵌入代码里。它不是最好的算法的原因之一是:相对于加法和乘法而言,很多计算机在运算时,除法往往有更多的循环运算。而实际上,通常用几步乘法就能解决问题了。
乘法哈希法
乘法哈希法会更好用一些,但其实今天提到的哈希算法,在某种意义上,都不能说是好的哈希函数。乘法哈希法的优点是,基本上只需要用到乘法运算。同样,也需要先定一些假设条件:
槽的数量为m,m是以2为底的幂(这对后面的运算有利)。同时还要假定计算机的一个字的长度是w位(比如比较合适的计算机的字长有32位的,或者64位的)。哈希函数如下:
h(k) = (Ak mod 2w) rsh(w-r) ,其中,mod为取余,rsh为二进制位运算的右偏移。
这里A是一个奇数,并且2w-r < A < 2w,它是一个等长于计算机字长的奇数。
无论k的值为多少,和A相乘,然后对2w取余,最后再右移w-r位数。
下面看看A要如何取值,以及不能取哪些值:
首先,A的取值不要太接近于以2为底的数。这是个快速的方法,因为乘法和右位移运算都很快,尤其是已经知道了偏移量是多少的情况下。
这里看下这个哈希函数是如何运作的?
假设这里m = 23 = 8,即r=3,取一个特殊的字长为7位,即w=7。A是一个用于哈希函数的定值,假定A = 1011001,设定k = 1101011,通过乘法运算计算Ak,会得到一个2w位的值。这里的乘积结果为Ak = 10010100110011。对2w取余,结果为0110011。接着进行右偏移w-r位,将0011右移掉了,所以结果为h(k) = 011。
在大部分的计算机里,当用两个32位数相乘时,计算机会有一个指令会直接得出32个低位的值,所以使用这种指令,通常会比先算64位结果要快得多。
怎么理解这个运算过程呢?
把A看作一个二进制的分数,二进制小数点在前面,即A = .1011001,当A和某个数相乘的时候,小数点会出现在中间的位置,比如上面的Ak = 1001010.0110011。把A看成一个数的小数部分即可,不需要纠结具体的计算。
可以用车轮法来理解这个哈希函数:
先画一个车轮,将其8等分。每一份为1/8,A为0.1011001,乘以8即23,为101.1001份,即约等于5.5份,即每个A占车轮的5.5/8份的大小。
所以k=1时,Ak = A·1,为5.5,从下标0的位置开始顺时针转到5.5的位置;
k=1时,Ak = A·2 = 11,大概绕到11%8=3,即略微超过3的地方;
k=3时,Ak = A·3 = 16.5,大概绕道16.5/8=0.5,即约0.5的位置。
每次加多一个A,会加多一段A的弧长,如果A是奇数,并且不是近似以2为底的幂值。那么哈希的过程,可以看成是把键扔入不同的槽里。类似绕车轮,如果k的值非常大,那么Ak相当于绕k个圈。
这是个不错的哈希函数,但这只是探索性的哈希方法。因为对于任何哈希函数而言,你总能找到一些键,使得哈希出来的结果非常糟糕。所以问题是,在实际应用中选择哪个。
开放寻址法解决碰撞
前面讨论过用链接法解决碰撞问题,还有另外一个解决碰撞的方法,也是非常有用的。即“开放寻址法”。
这里不会用到链表。如果用链接法,在每个记录里,都需要一个额外的关联地址。对于某些应用来说,没必要为此付出巨大的开支,同时也不想对记录做任何的改动。这种情况下,开放寻址法就是很有效的解决碰撞的方法。
在用开放寻址法时,如果哈希到了一个已经存有记录的槽,那么用另一个函数重新进行哈希。第二个哈希函数也哈希到了一个已经存有记录的槽,那就下一个哈希函数。形成一个探查的序列,然后变成一种算术排列。已经探查过的槽就不再进行探查,直到找到一个可以放置记录的槽。如果有一个比较好的探查序列,那么就能很快地找到一个放置的地方。
对于查找键值而言,可以用同样的探查序列来查找。开放寻址的思想就是:系统地探查哈希表,直到找到一个空槽为止。
扩展开来看,实际上,一个哈希函数有两个参数:键和探查顺序。所以哈希函数h会把全域里的键,通过一步步的探查映射到槽里。
- h:Ux{0,1,...,m-1} -> {0,1,...,m-1},U表示键的全域,{}内的表示探查号,后面的一段{}表示槽。
- 探查序列应该是一个算数排列,也就是说,它必须是数字0到m-1的某种完全随机的排列,也就是把0到m-1的数字全部打乱重排。
开放寻址法不需要担心n链接的问题,因为哈希表最终会被填满。所以哈希表里的元素必须小于哈希表中槽的数量,即n<=m,这样才不会溢出。
这种情况下,删除比较困难(原因后面有说明),但不是不可能,有专门的删除方案。删除操作执行起来比较困难,因为从表中删除某个键之后,有人按探查序列来查找另一个键,他本应先发现这里不是他要的键,继而再向下查找,然而现在却发现这个槽是空的。那么他会认为,想要找的键很可能不在这个表里。
解决方案可以是:把元素删除之后,在这里做个标记。也有其它的方案,但都比较复杂。链接法要删除就比较简单了,直接从链表里删除就可以了。
下面举个例子:
要在下面的哈希表中插入一个键k=496
先是第0号探查,探查h(496, 0),假设它映射到了204的槽,槽中已经存放有记录了,所以继续探查。
第1号探查,探查h(496, 1),假设映射到了586的槽,槽中已经存放有记录,所以继续探查。
第2号探查,h(496, 2),这个比较幸运,终于找到了一个空槽。
对于search操作,则返回nil,表示空值。如果执行的是插入操作,那就把k=496放在这个槽里。
如果是查找一个已经存在哈希表中的值,那么也是从h(496, 0)开始执行,跟插入执行的序列一样。所以删除操作才比较难。。。
开放寻址法 如何构造探查序列,如何有效进行探查?
线性探查
其中最简单的方法是“线性探查”:h(k, i) = (h(k, 0) + i) mod m,i表示当前进行的是第几轮探查。
i=1时,即是探查h(k, 0)的下一个;i=2,即是再下一个。这个方法是简单地向下探查。mod m表示:到达了表的底下之后,回到顶端从头开始。
这个方法比较简单,不需要每一轮计算都重新算一次哈希函数,只需要每次探查都+1。但有个问题是,存在“群集现象”:如果一串连续区域同时被占用了,那么接下来的所有操作都必须不停探查,直到这串区域的底部。
二次哈希
二次哈希是个比较受欢迎的方法。
h(k, i) = ( h1(k) + i·h2(k) ) mod m
这是两个关于m的哈希函数
第0次探查只使用h1(k)函数进行探查,1号探查则加上一个h2(k)函数,2号探查则继续加上一个h2(k)。
计算起来也比较简单,先算出h1(k)和h2(k)两个函数值,然后后面的计算是不断加上一个h2(k)。
通常会把m取为以2为底的幂值,即m=2r。以及h2(k)的值最好为奇数。
平均情况分析
这里的分析需要假设的情况比链接法要多一些。
假设“均匀哈希”:每个键都均等地有m!种探查序列,并且每个键都是相互独立的。
这里要证明的理论是:预期的探查次数最多不超过1/(1-α),如果α<1,那么哈希表里键的数量小于槽的数量,即n<m。α是前面提到的载荷因子。
这里要求α<1是因为,如果键的数量大于槽的数量,那么开放寻址法就不起作用了,分分钟死循环。。。
先看搜索失败的情况
首先,一次探查是必不可少的。接着,如果m个槽内已经有n个元素,那么探查发生碰撞的概率为n/m。第二次探查碰撞的概率为(n-1)/(m-1)。第三次探查碰撞的概率为(n-2)/(m-2)。第i次探查碰撞的概率为(n-i)/(m-i) < n/m = α。
那么预期探查数为:
E = 1 + n/m · (1+(n-1)/(m-1)·(1+(n-2)/(m-2·)(...(1+1/(m-n)...)))
... ≤ 1+α(1+α(1+α(...(1+α)...)))
... = 1+α+α2+α3+...
... = ∑αi ---------- 从0到∞
... = 1/(1-α) ------------ 几何数列上限
如果α<1,并且α为一个常数,那么预期探查数为常数,即O(1)
看一下哈希表,如果只存放50%的数据,即α=0.5,那么预期探查数为1/(1-0.5) = 2。如果存放90%的数据,即α=0.9,那么预期探查数为1/(1-0.9) = 10。随着哈希表的密度增大,探查的时间花费会急剧地增加,所以一般不要让哈希表太过稠密。