算法导论(七):哈希表

麻省理工学院公开课:算法导论。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+α+α23+...
... = ∑αi ---------- 从0到∞
... = 1/(1-α) ------------ 几何数列上限

如果α<1,并且α为一个常数,那么预期探查数为常数,即O(1)
看一下哈希表,如果只存放50%的数据,即α=0.5,那么预期探查数为1/(1-0.5) = 2。如果存放90%的数据,即α=0.9,那么预期探查数为1/(1-0.9) = 10。随着哈希表的密度增大,探查的时间花费会急剧地增加,所以一般不要让哈希表太过稠密。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • - 哈希表- 哈希函数选择- 哈希碰撞 由“符号表问题”引入什么是哈希有一个表S有n条记录,每个记录(通常认为是指...
    Alex90阅读 1,618评论 0 2
  • 哈希表定义 散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结...
    n油炸小朋友阅读 4,844评论 0 22
  • - 全域哈希- 完全哈希 普通哈希的一个缺点:对任意的hash函数h,总存在一组keys,让他们都映射到同一个槽里...
    Alex90阅读 4,915评论 0 3
  • 哈希表:即散列存储结构。散列法存储的基本思想:建立记录关键码字与其存储位置的对应关系,或者说,由关键码的值决定数据...
    linbj阅读 6,288评论 1 5
  • 在广州,抬头寻找星星的希望很缥缈。霓虹的灯火侵占了夜晚的天空,已经很难见到一片漆黑的天空。当我今晚偶遇到...
    平淡無奇阅读 452评论 0 1