第五章 哈希表
在本章中
············································································
你会学到哈希表,哈希表是非常有用的基础数据结构之一,哈希表有很多种使用场景,这一章覆盖了大多数的使用场景。
你会深入学习哈希表:内部实现、冲突和哈希功能
这会帮助你学习如何分析哈希表的性能
············································································
假设你在一个杂货店工作,当用户买某一个物品时,你需要对着一本书来查看价格,若这本书不是字母排序的,你需要查看每一行来查找apple(苹果)。你还记得需要花费多长时间吗?O(n).若这本书是按字母排序的,那么你可以使用二分查找法来查苹果的价格。这只需要O(longn)的时间。
图5-1
图5-2
让我们再说一遍,O(n)和O(longn)相比,时间差很多。假设你每秒可以读这本书的10行,这是简单查找和二分查找花费的时间。
图5-3
你现在看到二分查找是非常快的,但是作为一个收银员,每次从书中找东西实在太费劲了,就算它是排序过的,你可以感受一下,当顾客很多时的场景,你需要一个能把名称和价格都记住的朋友。这样你就不需要查找这些项了。你只要去问她,她就会立刻告诉你答案。
图5-4
你的好友Maggie可以在O(1)的时间内告诉你答案。不管这本书有多厚,她甚至比二分查找法还快。
图5-5
这个多么好的人啊!怎么才能找到Maggie呢?
让我们回到数据结构,你早就知道这两种数据结构了:数组和列表(我不讲堆栈是因为它不支持查找),你可以将这本书看成数组。
图5-6
在每一个数组中的每一项都有两个子项:一个是物品,一个是物品的价格。若是你用二分法通过名称来查找价格,这花费O(1)的时间,你想要“Maggie”,这正是哈希方法产生的原因。
哈希方法
哈希功能是你输入一个字符串,你可以得到一个数值。
图5-7
用术语说,哈希功能是将字符串映射成数字,你可能当你放进一个字符串时,没有限定返回哪种类型,但是哈希功能有一些必须条件:
它必须是始终如一的。比如,你输入一个苹果,它返回4.每次输入苹果,它每次都返回4.这是前提。不然哈希表无法工作
不同的名称要映射不同的数字。比如,若是不管你输入什么名称,它都返回“1”,那是没有用的。最好的情况是,不同的单词对应不同的数字。
所以,哈希功能将字符串映射成数字,这有什么用呢?这样,你可以把它当成“Maggie”来使用。
假设有一个空数组:
图5-8
你将价格都存储进来,通过哈希功能。
图5-9
当你输入苹果时,哈希功能返回3.所以我们将苹果的价格放在第3个位置(数组位置从0开始)
图5-10
我们增加“牛奶”,将牛奶输入,我们得到数字0
图5-11
因此,我们将牛奶的价格存入第0个位置。
图5-12
一直进行,直到数组中所有的位置都存储了价格
图5-13
现在,你可能会问“Hey,鳄梨的价格是多少?”你不需要从数组中查找,你直接问哈希功能。
图5-14
它告诉你,价格存储在数组的第4个位置,然后我们找到答案。
图5-15
哈希功能准确的告诉我们价格存在哪里了,因此,我们不需要去寻找它,这样做是因为:
哈希功能将名称和存储位置做了映射。每次我们输入“鳄梨”,我们会得到同样的数字,所以你可以首先利用它获得鳄梨价格的有效位置,然后通过它找到鳄梨的价格;
哈希功能将不同的字符串映射成不同的位置,如“鳄梨”映射到位置4,“牛奶”映射到位置0.每个事物映射到不同的位置。然后在对应的位置上存储正确的价格;
哈希功能知道数组的大小,它只返回有效的位置。比如,你的数组只有5项,哈希功能不会返回100,因为这个在数组中不存在。
你自己做了个“Maggie”,将哈希功能和数组合并起来。你获得了一个数据结构叫哈希表。哈希表是你学习的第一个有额外逻辑的数据结构,数组和列表都存储在内存中,但是哈希表更聪明一些。他们利用哈希功能来智能的分配这些项的位置。
哈希功能可能是你学习到的复杂的数据结构中最复杂的。它也被叫做哈希图(Hash maps)、图(maps)、字典(dictionary)、关联数组(associative arrays)。哈希表很快,还记得我们在第2章中讨论的数据和链表吗?你可以立即取出其中的某一项,哈希表利用数组来存储数据,因此它是非常快的。
你自己可能还从来没有创建过哈希表,每一种好的语言都会有对哈希表的实现。Python也有哈希表,它被称为字典,你可以通过字典创建一个新的哈希表。
图5-16
>>> book = dict()
book是一个新的哈希表,我们来将价格放进来。
>>> book["apple"] = 0.67
>>> book["milk"] = 1.49
>>> book["avocado"] = 1.49
>>> print book
["avocado":1.49, "apple": 0.67, "milk": 1.49]
图 5-17
非常简单,现在我们问一下鳄梨的价格。
>>> print book[“avocado”]
1.49
哈希表有键和值。在book这个哈希中,键是物品的名称,值是他们的价格。哈希表将键映射成值。
在下一部分,我们会举几个例子,来看下为何说它是有用的。
练习
每次都固定的返回同样地值对哈希功能来说,是非常重要的。若是做不到这样,我们就不能将项存储到哈希表中。下面的哈希功能哪个是固定的?
图5-18
5.1 f(x) = 1
5.2 f(x) = rand( )
5.3 f(x) = next_empty_slot( )
5.4 f(x) = len(x)
用例
哈希表被用在任何地方,这部分会介绍一些例子。
哈希表用于查询
你的手机中有通讯录,每个名字对应一个电话号码。
图5-19
BADE MAMA—-> 581 660 9820
ALEX MANNING —-> 484 234 4680
JANE MARIN —-> 415 567 3579
假设你需要创建一个类似的电话本,你需要将名字映射成电话号码,你的通讯录是需要有以下功能:
将名称和电话号码关联起来
输入名字,可以得到关联的电话号码
这是哈希表的一种使用场景,哈希表在以下场景中有用
创建一个事物映射到另一个事物
查找某项事物
创建一个电话本很简单,首先,创建一个哈希表。
>>>phone_book = dict()
顺便说一句,Python中有创建哈希表的更简单的方法。
>>>phone_book = {}
将人们的名字和电话号码加进来。
>>>phone_book[“jenny”] = 8675309
>>>phone_book[“emergency”] = 911
图5-20
这就好了,现在,假设你想查找Jenny的电话号码,你可以这样做
print phone_book[“jenny”]
8675309
想象一下,若是用数组来实现会是怎样的呢?要怎么做?当两项之间有关系时,用哈希表来建模是比较简单的。
哈希表被用在大数量级上的查找。比如,假设你访问网站http://adit.io。你的电脑需要将它翻译成IP地址。
ADIT.IO ———>. 173.255.248.55
不管你登陆哪个网站,都需要将它翻译成IP地址。
GOOGLE.COM —> 74.125.239.133
FACEBOOK.COM —->173.252.120.6
SCRIBD.COM ——-> 23.235.47.175
Wow,将网站url和IP地址映射起来?这听起来非常适合用哈希表。这个过程叫DNS解码。哈希表是提供该项功能的唯一方式。
防止重复输入
图5-21
假设你运行一个投票网站,每人只能投一次票,你怎么来确定他们之前投过票?当有人来投票时,你问他们的名字,然后去投过票的人员名单(列表)中去检查。
图5-22
若是名字在上面,证明他投过票,就不能让他再投票了。但是若是有很多人需要投票,那么这个记录投过票的列表名单会非常长。
图5-23
每当一个人进来时,你都需要去这个大的列表中去搜索他们是否投过票,这有一个更好的方法,用哈希表!
voted = {}
当一个人进来时,检查这个人在哈希表吗?
value = voted.get(“tom”)
若能得到值,证明投过票。若返回None,证明没有投过票。我们可以利用哈希表来检查一个人是否投过票。
图5-24
这是代码:
voted = {}
def check_voter(name):
if voted.get(name):
print "kick them out"
else:
voted[name] = True
print "let them vote!”
让我们来测试一下
>>> check_voter("tom")
let them vote!
>>> check_voter("mike")
let them vote!
>>> check_voter("mike")
kick them out!
Tom第一次来时,会打印“让他投票!”,当Mike第一次来时,打印“让他投票!”,当Mike第二次来时,打印“让他走!”(因为他已经投过票了)
记住,若是你将名字都存储在列表中,查询速度会变慢。因为简单搜索需要遍历整个列表,但是我们可以将名字存储在哈希表中,哈希表可以立刻告诉我们名字是否在里面,查找是否存储在表中,对哈希表来说非常快!
用哈希表作为缓存
图5-25
最后一个例子:缓存。当你上网时,你可能听说过缓存,它非常好。这是实现。假设你在访问facebook.com:
1.你对facebook的服务器发起请求;
2.服务器思考一秒钟,然后将结果发送给你。
3.你看到网页。
图5-26
例如,facebook可能收集你的朋友们的活动然后发送给你。这需要花费好几秒钟。对于用户来说,会感觉时间很长。你可能会想,“为什么facebook变慢了?”另一方面,facebook要服务数亿人,然后每个人都花费几秒钟,facebook服务器已经很辛苦的服务那么多网页了。。有没有一种方式,让服务器在相同的时间内减少工作量从而让速度更快呢?
假设你的侄女老是问你星球的问题,“从火星到地球有多远?”,“从月球呢?”、“从木星呢?”,每次,你都需要搜索Google,然后告诉她,这需要几分钟。
现在,假设她经常问“从月球呢?”,时间很短的话,你脑海中还记得答案是238,900米,你不需要重新去问谷歌—你记得答案,这也就是缓存是怎么工作的:网站记住答案而不是再去计算。
假设你登陆Facebook,所有的内容立刻呈现给你。每次你登陆facebook,它都需要思考你对什么感兴趣,但是若是你没有登陆过Facebook,你看到登陆页,每个人看到的登陆页面都相同,Facebook经常问这个问题:“当你登出的时候给我主页”,这样它的服务器就不用计算主页是什么样子的。相反,它记住了你的主页然后发送给你。
图5-27
这就叫缓存存储。它有两个优点:
获取页面速度快。这就像你记住月球到地球的距离,当你的侄女下次问你时,你不需要去问谷歌,你可以立刻得到答案。
facebook可以节省工作量。
缓存存储可以让工作变快,大型网站都会使用它,这些数据被存储在哈希表中。
facebook不止在缓存中存储主页,它也存储帮助页面、联系方式页面、条款和条件页等等,所以,我们需要将网址映射成页面名称。
facebook.com/about ——> DATA FOR THE ABOUAT PAGE
facebook.com ———-> DATA FOR THE HOME PAGE
当你在facebook上浏览页面时,它先检查这个页面是否在缓存中有存储。
图5-28
这是代码:
cache = {}
def get_page(url):
if cache.get(url):
return cache[url]
else:
data = get_data_from_server(url)
cache[url] = data
return data
当缓存中找不到时,服务器才会执行查询,当查到后,会先将它存储在缓存中,然后将结果返回。下次有另一个人请求这个网址时,就可以直接从缓存中查询而不用服务器去执行查询了。
回顾
哈希对以下场景是有用的
一种事物和另一种事物关系的模型
过滤重复项
将数据存储在缓存中,而不是让服务器去工作
冲突
就像我在前面说的。大多数语言都有哈希表,你不需要自己写。所以我不会讲哈希表的内部实现,但是我们关心性能!在看哈希表的性能前,我们先理解下冲突,下面的两部分是有关冲突和性能的。
首先,我说了个善意的谎言。我说过在数组的不同位置,不同的key对应不同的value。
图5-29
在现实中,哈希功能是不太可能的这么做的。比如,一个简单的例子,假设数组有26个槽位。
图5-30
你的哈希功能很简单,按照字母顺序放在指定的位置。
图5-31
现在你看到问题了,你要将苹果的价格存储在哈希中,你选择第一个槽位,
图5-32
然后,你想将香蕉的价格放入哈希中,你选择第二个槽位。
图5-33
事情进展的很顺利,但是现在你想将鳄梨的价格存储在哈希表中。你放在第一个槽位中。
图5-34
不!苹果的价格已经放在槽位中了,怎么办?这就叫做冲突:两个key对应的value值在同一个槽位中,这是问题。你若是存储鳄梨的价格,你会将苹果的价格给覆盖掉。下次别人问你苹果的价格时,你会将鳄梨的价格给他!冲突是不好的,你需要解决它。最简单的方式是:若是不同的key对应同一个位置,那么这个位置上要放个链表。
图5-35
在这个例子中,苹果和鳄梨存储在同一个位置,因此在这个位置存储一个链表。若是你查找香蕉,会依然很快。但是若是你想知道苹果的价格,就会慢一些。你需要查找链表来找到“苹果”。如果链表很小,这没有什么大不了的—你只需要查找3项或4项。但假设你在杂货店工作,你只卖字母A打头的物品。
图5-36
等等!整个哈希表除了第一个槽位,其他都是空的。然后第一个槽位中有一个巨大的链表,每一个在这个表中的项都在链表中。这和将所有的事物存储在链表中一样糟,这会将哈希表拖慢。
有两个教训:
哈希功能非常重要。你的哈希功能将所有的key存储在了同一个槽位中了,理想状态下,你的哈希表要将key均匀的分布在哈希表中。
哈希功能是非常重要的,一个好的哈希功能这种冲突会非常少。所以怎样才能选择一个好的哈希功能呢?下一部分会将到性能!
性能
图5-37
我们这章从杂货店开始,你需要构建一个可以自动将物品价格给你的系统,哈希表非常快!
平均情况下,哈希表的时间复杂度位O(1). O(1)被称为常数时间,你以前还没有见过常数时间,这并不意味着瞬时。这只是说,不管哈希表多大,时间是固定的。例如,我们知道简单搜索花费线性时间。
图5-38
二分查找快一些—它花费对数时间
图5-39
在哈希表中查找某一项花费常数时间
图5-40
为什么是水平的直线呢?这意味着不管哈希表含有一项还是数亿项,查找任意项都花费相同的时间。我们以前是没有遇到过的。将某一项从表中取出来也是常数时间。不管数组有多大,从里面取出某项都花费常数时间。平均情况下,哈希表是非常快的。
最差情况下,哈希表需要O(n)—线性时间。对任一项,这是非常慢的,让我们比较大的哈希表—数组和列表。
图5-41
看下哈希表在平均的情况下,查找时,哈希表和数组一样快(找到值所在的位置),当插入和删除时,它和链表一样快。在这两项,哈希表都有优势。在最差情况下,哈希表不管是查找、插入和删除都很慢,所以,在用哈希表时,我们要避免遇到最差情形。要想避免它,我们需要
低负荷系数
好的哈希功能
注意
在开始下一部分前,我们知道这不是必须要读的了。我会来讲述下哈希表是如何实现的。但是你自己是不需要自己来实现哈希功能的。因为不管你用什么语言,哈希功能都是内置的,你可以用它而且假定它有好的性能。下面我会讲一下底层实现。
负荷系数
图5-42
负荷系数=哈希表中的项/槽位总数
哈希表利用数组存储数据,你计算被使用的槽位数量。比如,负荷系数是2/5或0.4.
图5-43
哈希表的负荷系数是啥?
图5-44
若是说1/3,那么你答对了。负荷系数评估在哈希表中有多少未使用的槽位。
假设哈希表中你需要寻找100个物品,最好的情形时,每一项都有它自己的槽位。
图5-45
这个哈希表的负荷系数是1,若是你的表只有50个槽位,那么负荷系数是2,不可能每一项都有自己的槽位,因为没有足够的槽位!当负载系数大于1时,意味着在数组中没有足够的位置。一旦负载系数变大时,你需要来为哈希表增加槽位,这叫做调整大小。假设你的哈希表差不多满了,负载系数位3/4.
图5-46
你需要调整这个哈希表的大小了。首先,你需要一个更大的新表。拇指法则是指将数组扩大1倍。
图5-47
现在你需要将原来的项插入到新的表中
图5-48
新表的负载系数时3/8,更好了,低负载系数意味着少的冲突,你的表性能会好一些。好的拇指法则是,当负载系数大于0.7时,调整大小。你可能会想,“调整大小会花费时间!”,对的,调整大小是有代价的。你不能经常来调整大小。但是通常情况下,就算在调整大小时,它的时间复杂度也是O(1).
好的哈希功能
好的哈希功能将数据平均的分配在列表中
图5-49
坏的哈希功能将值都放在了一起,有很多冲突。
图5-50
什么是好的哈希功能?这个事情你不需要担心—后台有很多有经验的“大胡子”来维护它。但若是你好奇,可以看SHA功能(这个在后面的章节中有讲述),你可以利用它来学习哈希功能。
练习
哈希功能有好的分发功能是非常重要的,最好是将key值分散开来,最差的情况是所有的项都放在哈希表的同样的槽位上。
假设你有以下四个哈希功能,需要处理与字符串相关的工作:
A 所有的输入输出都是1;
B 将字符串的长度作为索引;
C 将字符串的第一个字母作为索引,这样,所有以字母A开头的字符串都会放在同一个位置;
D 将所有的字母映射成不一个素数:a=2, b=3, c=5,d=7,e=11等等。对于一个字符串,哈希功能是将所有的字符的值的和,与哈希表的大小取余数。比如,你的哈希表大小是10,字符串是“bag”,索引(位置)是(3+2+17)%10=22%10=2
对于下面的每个例子,哪一个哈希功能有好的分发效果?假设一个哈希表有10个槽位(位置)。
5.5 一个电话本,键是人名,值是电话号码,这些名字分别是:Esther,Ben,Bob,Dan
5.6 电池大小和能量的映射关系,大小分别是A,AA,AAA,AAAA
5.7 书名和作者的映射关系。书名分别是《Maus》、《Fun Home》和《WatchMen》
回顾:
你不需要自己实现哈希功能,你使用的编程语言会提供这项功能。你可以使用Python的哈希功能,也可以假设你会得到平均性能:常数时间。
哈希表是一个非常有效的数据结构,因为它很快而且将数据用不同的方式抽象成了数据模型,你随后会发现你一直在使用他们:
你可以用哈希功能和数组来实现哈希表
冲突是不好的,你的哈希功能要最小化冲突
哈希表在查找、插入和删除都很快
哈希表很适合对一种事物和另一种事物的关系进行建模
当负载系数大于0.7时,需要重新调整大小;
哈希表被用来缓存数据(比如,通过浏览器服务器)
哈希表可以避免重复项。
图5-51