书上把 hash_tables 翻译为哈希表,个人感觉还是翻译为哈希表更好,故而把哈希表替换为哈希表
假设你在一家杂货店上班。有顾客来买东西时,你得在一个本子中查找价格。
如果本子的内容不是按字母顺序排列的,你可能为查找苹果(apple)的价格而浏览每一行,这需要很长的时间。此时你使用的是第1章介绍的简单查找(《算法图解》学习笔记(一):二分查找(附代码)),需要浏览每一行。还记得这需要多长时间吗?O(n)。如果本子的内容是按字母顺序排列的,可使用第1章介绍的二分查找(《算法图解》学习笔记(一):二分查找(附代码))来找出苹果的价格,这需要的时间更短,为O(log n)。
需要提醒你的是,运行时间O(n)和O(log n)之间有天壤之别!假设你每秒能够看10行,使用简单查找和二分查找所需的时间将如下。
你知道,二分查找的速度非常快。但作为收银员,在本子中查找价格是件很痛苦的事情,哪怕本子的内容是有序的。在查找价格时,你都能感觉到顾客的怒气。看来真的需要一名能够记住所有商品价格的雇员,这样你就不用查找了:问她就能马上知道答案。
不管商品有多少,这位雇员(假设她的名字为Maggie)报出任何商品的价格的时间都为O(1),速度比二分查找都快。
真是太厉害了!如何聘到这样的雇员呢?
下面从数据结构的角度来看看。前面介绍了两种数据结构:数组和链表(其实还有栈,但栈并不能用于查找)(《算法图解》学习笔记(二):选择排序(附代码))。你可使用数组来实现记录商品价格的本子,为什么,这个讨论过,因为数组可以随机访问。
这种数组的每个元素包含两项内容:商品名和价格。如果将这个数组按商品名排序,就可使用二分查找在其中查找商品的价格。这样查找价格的时间将为O(log n)。然而,你希望查找商品价格的时间为O(1),即你希望查找速度像Maggie那么快,这是哈希函数的用武之地。
哈希函数是这样的函数,即无论你给它什么数据,它都还你一个数字。
如果用专业术语来表达的话,我们会说,哈希函数“将输入映射到数字”。你可能认为哈希函数输出的数字没什么规律,但其实哈希函数必须满足一些要求。
它必须是一致的。例如,假设你输入apple时得到的是4,那么每次输入apple时,得到的都必须为4。如果不是这样,哈希表将毫无用处。
它应将不同的输入映射到不同的数字。例如,如果一个哈希函数不管输入是什么都返回1,它就不是好的哈希函数。最理想的情况是,将不同的输入映射到不同的数字。
定义:哈希表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组叫做哈希表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。
哈希函数将输入映射为数字,这有何用途呢?你可使用它来打造你的“Maggie”(你的专属秘书)!
为此,首先创建一个空数组。
你将在这个数组中存储商品的价格。下面来将苹果的价格加入到这个数组中。为此,将apple作为输入交给哈希函数。
哈希函数的输出为3,因此我们将苹果的价格存储到数组的索引3处。
下面将牛奶(milk)的价格存储到数组中。为此,将milk作为哈希函数的输入。
哈希函数的输出为0,因此我们将牛奶的价格存储在索引0处。
不断地重复这个过程,最终整个数组将填满价格。
现在假设需要知道鳄梨(avocado)的价格。你无需在数组中查找,只需将avocado作为输入交给哈希函数。
它将告诉你鳄梨的价格存储在索引4处。果然,你在那里找到了。
哈希函数准确地指出了价格的存储位置,你根本不用查找!之所以能够这样,具体原因如下。
刚才你就打造了一个“Maggie”!你结合使用哈希函数和数组创建了一种被称为 哈希表(hash table) 的数据结构。哈希表是你学习的第一种包含额外逻辑的数据结构。数组和链表都被直接映射到内存,但哈希表更复杂,它使用哈希函数来确定元素的存储位置。
在你将学习的复杂数据结构中,哈希表可能是最有用的,也被称为哈希映射、映射、字典和关联数组。哈希表的速度很快!还记得第2章关于数组和链表的讨论吗(《算法图解》学习笔记(二):选择排序(附代码))?你可以立即获取数组中的元素,而哈希表也使用数组来存储数据,因此其获取元素的速度与数组一样快。
你可能根本不需要自己去实现哈希表,任一优秀的语言都提供了哈希表实现。Python提供的哈希表实现为字典,你可使用函数dict来创建哈希表。
>>> book = dict()
创建哈希表book后,在其中添加一些商品的价格。
非常简单!我们来查询鳄梨的价格。
哈希表由键和值组成。在前面的哈希表book中,键为商品名,值为商品价格,哈希表将键映射到值。
哈希表用途广泛,本节将介绍几个应用案例。
手机都内置了方便的电话簿,其中每个姓名都有对应的电话号码。
假设你要创建一个类似这样的电话簿,将姓名映射到电话号码。该电话簿需要提供如下功能。
这非常适合使用哈希表来实现!在下述情况下,使用哈希表是很不错的选择。
创建电话簿非常容易。首先,新建一个哈希表。
>>> phone_book = dict()
顺便说一句,Python提供了一种创建哈希表的快捷方式——使用一对大括号。
>>> phone_book = {} # 与phone_book = dict()等效
下面在这个电话簿中添加一些联系人的电话号码。
>>> phone_book["jenny"] = 8675309
>>> phone_book["emergency"] = 911
这就成了!现在,假设你要查找Jenny的电话号码,为此只需向哈希表传入相应的键。
>>> print phone_book["jenny"]
8675309 # Jenny的电话号码
如果要求你使用数组来创建电话簿,你将如何做呢?哈希表让你能够轻松地模拟映射关系。
哈希表被用于大海捞针式的查找。例如,你在访问像http://adit.io这样的网站时,计算机必须将adit.io转换为IP地址。
无论你访问哪个网站,其网址都必须转换为IP地址。
这不是将网址映射到IP地址吗?好像非常适合使用哈希表啰!这个过程被称为 DNS解析(DNS resolution),哈希表是提供这种功能的方式之一。
假设你负责管理一个投票站。显然,每人只能投一票,但如何避免重复投票呢?有人来投票时,你询问他的全名,并将其与已投票者名单进行比对。
如果名字在名单中,就说明这个人投过票了,因此将他拒之门外!否则,就将他的姓名加入到名单中,并让他投票。现在假设有很多人来投过了票,因此名单非常长。
每次有人来投票时,你都得浏览这个长长的名单,以确定他是否投过票。但有一种更好的办法,那就是使用哈希表!
为此,首先创建一个哈希表,用于记录已投票的人。
>>> voted = {}
有人来投票时,检查他是否在哈希表中。
>>> value = voted.get("tom")
如果“tom”在哈希表中,函数get将返回它;否则返回None。你可使用这个函数检查来投票的人是否投过票!
python版本代码如下:
voted = {}
def check_voter(name):
if voted.get(name):
print("kick them out!")
else:
voted[name] = True
print("let them vote!")
check_voter("tom")
check_voter("mike")
check_voter("mike")
#include
#include
#include
using std::cout;
using std::endl;
std::unordered_map<std::string, bool> voted;
void check_voter(const std::string& name) {
auto search = voted.find(name);
if (search == voted.end() || search->second == false) {
voted.insert({name, true});
cout << "Let them vote!" << endl;;
} else {
cout << "Kick them out!" << endl;
}
}
int main() {
check_voter("tom");
check_voter("mike");
check_voter("mike");
}
首先来投票的是Tom,上述代码打印let them vote!。接着Mike来投票,打印的也是let them vote!。然后,Mike又来投票,于是打印的就是kick them out!。
别忘了,如果你将已投票者的姓名存储在列表中,这个函数的速度终将变得非常慢,因为它必须使用简单查找搜索整个列表。但这里将它们存储在了哈希表中,而哈希表让你能够迅速知道来投票的人是否投过票。使用哈希表来检查是否重复,速度非常快。
来看最后一个应用案例:缓存。如果你在网站工作,可能听说过进行缓存是一种不错的做法。下面简要地介绍其中的原理。假设你访问网站 facebook.com。
例如,Facebook的服务器可能搜集你朋友的最近活动,以便向你显示这些信息,这需要几秒钟的时间。作为用户的你,可能感觉这几秒钟很久,进而可能认为Facebook怎么这么慢!另一方面,Facebook的服务器必须为数以百万的用户提供服务,每个人的几秒钟累积起来就相当多了。为服务好所有用户,Facebook的服务器实际上在很努力地工作。有没有办法让Facebook的服务器少做些工作,从而提高Facebook网站的访问速度呢?
假设你有个侄女,总是没完没了地问你有关星球的问题。火星离地球多远?月球呢?木星呢?每次你都得在Google搜索,再告诉她答案。这需要几分钟。现在假设她老问你月球离地球多远,很快你就记住了月球离地球238 900英里。因此不必再去Google搜索,你就可以直接告诉她答案。这就是缓存的工作原理:网站将数据记住,而不再重新计算。
如果你登录了Facebook,你看到的所有内容都是为你定制的。你每次访问 facebook.com,其服务器都需考虑你感兴趣的是什么内容。但如果你没有登录,看到的将是登录页面。每个人看到的登录页面都相同。Facebook被反复要求做同样的事情:“当我注销时,请向我显示主页。”有鉴于此,它不让服务器去生成主页,而是将主页存储起来,并在需要时将其直接发送给用户。
这就是缓存,具有如下两个优点。
缓存是一种常用的加速方式,所有大型网站都使用缓存,而缓存的数据则存储在哈希表中!
Facebook不仅缓存主页,还缓存About页面、Contact页面、Terms and Conditions页面等众多其他的页面。因此,它需要将页面URL映射到页面数据。
当你访问Facebook的页面时,它首先检查哈希表中是否存储了该页面。
具体的代码如下。
cache = {}
def get_page(url):
if cache.get(url):
# 返回缓存的数据
return cache[url]
else:
data = get_data_from_server(url)
# 先将数据保存到缓存中
cache[url] = data
return data
仅当URL不在缓存中时,你才让服务器做些处理,并将处理生成的数据存储到缓存中,再返回它。这样,当下次有人请求该URL时,你就可以直接发送缓存中的数据,而不用再让服务器进行处理了。
这里总结一下,哈希表适合用于:
前面说过,大多数语言都提供了哈希表实现,你不用知道如何实现它们。有鉴于此,我就不再过多地讨论哈希表的内部原理,但你依然需要考虑性能!要明白哈希表的性能,你得先搞清楚什么是冲突。
首先,我撒了一个善意的谎。我之前告诉你的是,哈希函数总是将不同的键映射到数组的不同位置。
实际上,几乎不可能编写出这样的哈希函数。我们来看一个简单的示例。假设你有一个数组,它包含26个位置。
而你使用的哈希函数非常简单,它按字母表顺序分配数组的位置。
你可能已经看出了问题。如果你要将苹果的价格存储到哈希表中,分配给你的是第一个位置。
接下来,你要将香蕉的价格存储到哈希表中,分配给你的是第二个位置。
一切顺利!但现在你要将鳄梨的价格存储到哈希表中,分配给你的又是第一个位置。
不好,这个位置已经存储了苹果的价格!怎么办?这种情况被称为 冲突(collision):给两个键分配的位置相同。这是个问题。如果你将鳄梨的价格存储到这个位置,将覆盖苹果的价格,以后再查询苹果的价格时,得到的将是鳄梨的价格!冲突很糟糕,必须要避免。处理冲突的方式很多,最简单的办法如下:如果两个键映射到了同一个位置,就在这个位置存储一个链表。
在这个例子中,apple和avocado映射到了同一个位置,因此在这个位置存储一个链表。在需要查询香蕉的价格时,速度依然很快。但在需要查询苹果的价格时,速度要慢些:你必须在相应的链表中找到apple。如果这个链表很短,也没什么大不了——只需搜索三四个元素。但是,假设你工作的杂货店只销售名称以字母A打头的商品。
等等!除第一个位置外,整个哈希表都是空的,而第一个位置包含一个很长的列表!换言之,这个哈希表中的所有元素都在这个链表中,这与一开始就将所有元素存储到一个链表中一样糟糕:哈希表的速度会很慢。
这里的经验教训有两个。
哈希函数很重要,好的哈希函数很少导致冲突。
开头是假设你在杂货店工作。你想打造一个让你能够迅速获悉商品价格的工具,而哈希表的速度确实很快。
在平均情况下,哈希表执行各种操作的时间都为O(1)。O(1)被称为常量时间。你以前没有见过常量时间,它并不意味着马上,而是说不管哈希表多大,所需的时间都相同。例如,你知道的,简单查找的运行时间为线性时间。
二分查找的速度更快,所需时间为对数时间。
在哈希表中查找所花费的时间为常量时间。
一条水平线,看到了吧?这意味着无论哈希表包含一个元素还是10亿个元素,从其中获取数据所需的时间都相同。实际上,你以前见过常量时间——从数组中获取一个元素所需的时间就是固定的:不管数组多大,从中获取一个元素所需的时间都是相同的。在平均情况下,哈希表的速度确实很快。
在最糟情况下,哈希表所有操作的运行时间都为O(n)——线性时间,这真的很慢。我们来将哈希表同数组和链表比较一下。
在平均情况下,哈希表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但在最糟情况下,哈希表的各种操作的速度都很慢。因此,在使用哈希表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:
哈希表的填装因子很容易计算。
哈希表使用数组来存储数据,因此你需要计算数组中被占用的位置数。例如,下述哈希表的填装因子为2 / 5,即0.4。
下面这个哈希表的填装因子为多少呢?
如果你的答案为1/3,那就对了。填装因子度量的是哈希表中有多少位置是空的。
假设你要在哈希表中存储100种商品的价格,而该哈希表包含100个位置。那么在最佳情况下,每个商品都将有自己的位置。
这个哈希表的填装因子为1。如果这个哈希表只有50个位置呢?填充因子将为2。不可能让每种商品都有自己的位置,因为没有足够的位置!填装因子大于1意味着商品数量超过了数组的位置数。一旦填装因子开始增大,你就需要在哈希表中添加位置,这被称为 调整长度(resizing)。例如,假设有一个像下面这样相当满的哈希表。
你就需要调整它的长度。为此,你首先创建一个更长的新数组:通常将数组增长一倍。
接下来,你需要使用函数hash将所有的元素都插入到这个新的哈希表中。
这个新哈希表的填装因子为3/8,比原来低多了!填装因子越低,发生冲突的可能性越小,哈希表的性能越高。一个不错的经验规则是:一旦填装因子大于0.7,就调整哈希表的长度。
你可能在想,调整哈希表长度的工作需要很长时间!你说得没错,调整长度的开销很大,因此你不会希望频繁地这样做。但平均而言,即便考虑到调整长度所需的时间,哈希表操作所需的时间也为O(1)。
良好的哈希函数让数组中的值呈均匀分布。
糟糕的哈希函数让值扎堆,导致大量的冲突。
什么样的哈希函数是良好的呢?你根本不用操心——天塌下来有高个子顶着。如果你好奇,可研究一下SHA函数(本书最后一章做了简要的介绍)。你可将它用作哈希函数。
安全哈希算法(英语:Secure Hash Algorithm,缩写为SHA)是一个密码哈希函数家族,是FIPS所认证的安全 哈希 算法。能计算出一个数字消息所对应到的,长度固定的字符串(又称消息摘要)的算法。且若输入的消息不同,它们对应到不同字符串的机率很高。
几乎根本不用自己去实现哈希表,因为编程语言提供了哈希表实现。你可使用Python提供的哈希表,并假定能够获得平均情况下的性能:常量时间。
哈希表是一种功能强大的数据结构,其操作速度快,还能让你以不同的方式建立数据模型。你可能很快会发现自己经常在使用它。