散列表查找失败平均查找长度_我要自学生信之数据结构与算法:散列表

引用自算法图解,作者[美] Aditya Bhargava 译袁国忠 特别备注:本书非原创,但部分内容自己会再进行解释,以便更容易理解,重点部分会加粗

假设你在一家杂货店上班。有顾客来买东西时,你得在一个本子中查找价格。如果本子的内容不是按字母顺序排列的,你可能为查找苹果(apple)的价格而浏览每一行,这需要很长的时间。此时你使用的是第1章介绍的简单查找,需要浏览每一行。还记得这需要多长时间吗?O(n)。如果本子的内容是按字母顺序排列的,可使用二分查找来找出苹果的价格,这需要的时间更短,为O(log n)。

a348e3c3b8c82a921015482b32a9bc6f.png
图片来源:算法图解

需要提醒你的是,运行时间O(n)和O(log n)之间有天壤之别!假设你每秒能够看10行,使用 简单查找和二分查找所需的时间将如下。

083fb8d80c6a2816389e8c021990011f.png
图片来源:算法图解

你知道,二分查找的速度非常快。但作为收银员,在本子中查找价格是件很痛苦的事情,哪 怕本子的内容是有序的。在查找价格时,你都能感觉到顾客的怒气。看来真的需要一名能够记住 所有商品价格的雇员,这样你就不用查找了:问她就能马上知道答案。

9b2c1b57a703bf0ab95fed8951b04584.png
图片来源:算法图解

不管商品有多少,这位雇员(假设她的名字为Maggie)报出任何商品的价格的时间都为O(1), 速度比二分查找都快。

ef87042edd8609a5f48be111fe84300f.png
图片来源:算法图解

真是太厉害了!如何聘到这样的雇员呢?

下面从数据结构的角度来看看。前面介绍了两种数据结构:数组和链表(其实还有栈,但栈 并不能用于查找)。你可使用数组来实现记录商品价格的本子。

d4fdb8a2ed02cc27a6cfb53313f1de4d.png
图片来源:算法图解

这种数组的每个元素包含两项内容:商品名和价格。如果将这个数组按商品名排序,就可使 用二分查找在其中查找商品的价格。这样查找价格的时间将为O(log n)。然而,你希望查找商品 价格的时间为O(1),即你希望查找速度像Maggie那么快,这是散列函数的用武之地。

5.1 散列函数

散列函数是这样的函数,即无论你给它什么数据,它都还你一个数字。

ecdd77f4decec38217dca35b69279139.png
图片来源:算法图解

如果用专业术语来表达的话,我们会说,散列函数“将输入映射到数字”。你可能认为散列 函数输出的数字没什么规律,但其实散列函数必须满足一些要求。

  • 它必须是一致的。例如,假设你输入apple时得到的是4,那么每次输入apple时,得到的都 必须为4。如果不是这样,散列表将毫无用处。、
  • 它应将不同的输入映射到不同的数字。例如,如果一个散列函数不管输入是什么都返回1, 它就不是好的散列函数。最理想的情况是,将不同的输入映射到不同的数字。

散列函数将输入映射为数字,这有何用途呢?你可使用它来打造你的“Maggie”!

为此,首先创建一个空数组。

fa53ef03692d560735ecfbf6ed5451d6.png
图片来源:算法图解

你将在这个数组中存储商品的价格。下面来将苹果的价格加入到这个数组中。为此,将apple 作为输入交给散列函数。

c33ce0e69e53ecd16ebb97cf31578f96.png
图片来源:算法图解

它将告诉你鳄梨的价格存储在索引4处。果然,你在那里找到了。

06d7490d8494825281f84aa4393ccc84.png
图片来源:算法图解
  • 散列函数准确地指出了价格的存储位置,你根本不用查找!之所以能够这样,具体原因如下。 散列函数总是将同样的输入映射到相同的索引。每次你输入avocado,得到的都是同一个 数字。因此,你可首先使用它来确定将鳄梨的价格存储在什么地方,并在以后使用它来 确定鳄梨的价格存储在什么地方。
  • 散列函数将不同的输入映射到不同的索引。avocado映射到索引4,milk映射到索引0。每 种商品都映射到数组的不同位置,让你能够将其价格存储到这里。
  • 散列函数知道数组有多大,只返回有效的索引。如果数组包含5个元素,散列函数就不会 返回无效索引100。

刚才你就打造了一个“Maggie”!你结合使用散列函数和数组创建了一种被称为散列表(hash table)的数据结构。散列表是你学习的第一种包含额外逻辑的数据结构。数组和链表都被直接映 射到内存,但散列表更复杂,它使用散列函数来确定元素的存储位置。

在你将学习的复杂数据结构中,散列表可能是最有用的,也被称为散列映射、映射、字典和 关联数组。散列表的速度很快!还记得第2章关于数组和链表的讨论吗?你可以立即获取数组中 的元素,而散列表也使用数组来存储数据,因此其获取元素的速度与数组一样快。

你可能根本不需要自己去实现散列表,任一优秀的语言都提供了散列表实现。Python提供的 散列表实现为字典,你可使用函数dict来创建散列表。

 book = dict() 

创建散列表book后,在其中添加一些商品的价格。

>>> book["apple"] = 0.67   ##一个苹果的价格为67美分
>>> book["milk"] = 1.49    ##牛奶的价格为1.49美元
>>> book["avocado"] = 1.49
>>> print(book)
{'avocado': 1.49, 'apple': 0.67, 'milk': 1.49}

非常简单!我们来查询鳄梨的价格。

 print(book["avocado"])
1.49

散列表由键和值组成。在前面的散列表book中,键为商品名,值为商品价格。散列表将键 映射到值。

在下一节中,你将看到一些散列表使用示例。

5.2 应用案例

散列表用途广泛,本节将介绍几个应用案例。

5.2.1 将散列表用于查找

手机都内置了方便的电话簿,其中每个姓名都有对应的电话号码。

aa201c625c969362a81f88cdd24b9a97.png
图片来源:算法图解

假设你要创建一个类似这样的电话簿,将姓名映射到电话号码。该 电话簿需要提供如下功能。

  • 添加联系人及其电话号码。
  • 通过输入联系人来获悉其电话号码。 这非常适合使用散列表来实现!在下述情况下,使用散列表是很不错的选择。
  • 创建映射。
  • 查找。

创建电话簿非常容易。首先,新建一个散列表。

>>> phone_book = dict() 

顺便说一句,Python提供了一种创建散列表的快捷方式——使用一对大括号。

>>> phone_book = {}   ##与phone_book = dict()等效

下面在这个电话簿中添加一些联系人的电话号码。

>>> phone_book["jenny"] = 8675309
>>> phone_book["emergency"] = 911 

这就成了!现在,假设你要查找Jenny的电话号码,为此只需向散列表传入相应的键。

>>> print(phone_book["jenny"])
8675309

e557710ddf024d4e255d27237d0ca6e5.png
图片来源:算法图解

如果要求你使用数组来创建电话簿,你将如何做呢?散列表让你能够轻松地模拟映射关系。 散列表被用于大海捞针式的查找。

2896bb7506e81047fbc5a2109160b3ee.png
图片来源:算法图解

例如,你在访问像http://adit.io这样的网站时,计算机必须 将http://adit.io转换为IP地址。

ac80d835d51d5958f645b073db521df7.png
图片来源:算法图解

这不是将网址映射到IP地址吗?好像非常适合使用散列表啰!这个过程被称为DNS解析 (DNS resolution),散列表是提供这种功能的方式之一。

5.2.2 防止重复

假设你负责管理一个投票站。显然,每人只能投一票,但如何避免重复投 票呢?有人来投票时,你询问他的全名,并将其与已投票者名单进行比对。

2b2e3997c67c45d679e764d2200ccf95.png
图片来源:算法图解

如果名字在名单中,就说明这个人投过票了,因此将他拒之门外!否则,就将他的姓名加入 到名单中,并让他投票。现在假设有很多人来投过了票,因此名单非常长。

a5eca2d9b8861d99eadd130a9ac45794.png

每次有人来投票时,你都得浏览这个长长的名单,以确定他是否投过票。但有一种更好的办 法,那就是使用散列表!

为此,首先创建一个散列表,用于记录已投票的人。

>>> voted = {} 

有人来投票时,检查他是否在散列表中。

>>> value = voted.get("tom") 

如果“tom”在散列表中,函数get将返回它;否则返回None。你可使用这个函数检查来投 票的人是否投过票!

1d4bf920ed051b4fa713d44252eb4c66.png
图片来源:算法图解
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,上述代码打印let them vote!。接着Mike来投票,打印的也是let them vote!。然后,Mike又来投票,于是打印的就是kick them out!。

别忘了,如果你将已投票者的姓名存储在列表中,这个函数的速度终将变得非常慢,因为它 必须使用简单查找搜索整个列表。但这里将它们存储在了散列表中,而散列表让你能够迅速知道 来投票的人是否投过票。使用散列表来检查是否重复,速度非常快。

5.2.3 将散列表用作缓存

来看最后一个应用案例:缓存。如果你在网站工作,可能听说过进 行缓存是一种不错的做法。下面简要地介绍其中的原理。假设你访问网 站http://facebook.com。

353505e0f8316a744fee3f1245add800.png
图片来源:算法图解

例如,Facebook的服务器可能搜集你朋友的最近活动,以便向你显示这些信息,这需要几秒 钟的时间。作为用户的你,可能感觉这几秒钟很久,进而可能认为Facebook怎么这么慢!另一方 面,Facebook的服务器必须为数以百万的用户提供服务,每个人的几秒钟累积起来就相当多了。 为服务好所有用户,Facebook的服务器实际上在很努力地工作。有没有办法让Facebook的服务器少做些工作,从而提高Facebook网站的访问速度呢?

假设你有个侄女,总是没完没了地问你有关星球的问题。火星离地球多远?月球呢?木星 呢?每次你都得在Google搜索,再告诉她答案。这需要几分钟。现在假设她老问你月球离地球多远,很快你就记住了月球离地球238 900英里。因此不必再去Google搜索,你就可以直接告诉她答案。这就是缓存的工作原理:网站将数据记住,而不再重新计算。

如果你登录了Facebook,你看到的所有内容都是为你定制的。你每次访问http://facebook.com,其 服务器都需考虑你感兴趣的是什么内容。但如果你没有登录,看到的将是登录页面。每个人看到 的登录页面都相同。Facebook被反复要求做同样的事情:“当我注销时,请向我显示主页。”有鉴于此,它不让服务器去生成主页,而是将主页存储起来,并在需要时将其直接发送给用户。

c91cc86409bc72b24464c13ee49b1276.png
图片来源:算法图解

这就是缓存,具有如下两个优点。

  • 用户能够更快地看到网页,就像你记住了月球与地球之间的距离时一样。下次你侄女再 问你时,你就不用再使用Google搜索,立刻就可以告诉她答案。
  • Facebook需要做的工作更少。

缓存是一种常用的加速方式,所有大型网站都使用缓存,而缓存的数据则存储在散列表中!

Facebook不仅缓存主页,还缓存About页面、Contact页面、Terms and Conditions页面等众多 其他的页面。因此,它需要将页面URL映射到页面数据。

72648e97b0304653c332b33b01d7c1cf.png
图片来源:算法图解

具体的代码如下。

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时,你就可以直接发送缓存中的数据,而不用再让服务器进 行处理了。

5.2.4 小结

这里总结一下,散列表适合用于:

  • 模拟映射关系;
  • 防止重复;
  • 缓存/记住数据,以免服务器再通过处理来生成它们。

5.3 冲突

前面说过,大多数语言都提供了散列表实现,你不用知道如何实现它们。有鉴于此,我就不 再过多地讨论散列表的内部原理,但你依然需要考虑性能!要明白散列表的性能,你得先搞清楚 什么是冲突。本节和下一节将分别介绍冲突和性能。

首先,我撒了一个善意的谎。我之前告诉你的是,散列函数总是将不同的键映射到数组的不 同位置。

2071f6ca01df2e80547667d91b57dd86.png
图片来源:算法图解

接下来,你要将香蕉的价格存储到散列表中,分配给你的是第二个位置。

99c46ddbc4cc4b1c11c459bfd1e7bf0a.png
图片来源:算法图解

不好,这个位置已经存储了苹果的价格!怎么办?这种情况被称为冲突(collision):给两 个键分配的位置相同。这是个问题。如果你将鳄梨的价格存储到这个位置,将覆盖苹果的价格, 以后再查询苹果的价格时,得到的将是鳄梨的价格!冲突很糟糕,必须要避免。处理冲突的方式很多,最简单的办法如下:如果两个键映射到了同一个位置,就在这个位置存储一个链表。

4a7b41947ed19a45e3e514b431cf82f6.png
图片来源:算法图解

在这个例子中,apple和avocado映射到了同一个位置,因此在这个位置存储一个链表。在需 要查询香蕉的价格时,速度依然很快。但在需要查询苹果的价格时,速度要慢些:你必须在相应 的链表中找到apple。如果这个链表很短,也没什么大不了——只需搜索三四个元素。但是,假设你工作的杂货店只销售名称以字母A打头的商品。

26c2c5eb06b4fb6fbf58a0f99f0036c2.png
图片来源:算法图解

等等!除第一个位置外,整个散列表都是空的,而第一个位置包含一个很长的列表!换言之, 这个散列表中的所有元素都在这个链表中,这与一开始就将所有元素存储到一个链表中一样糟 糕:散列表的速度会很慢。

这里的经验教训有两个。

  • 散列函数很重要。前面的散列函数将所有的键都映射到一个位置,而最理想的情况是, 散列函数将键均匀地映射到散列表的不同位置。
  • 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很 好,这些链表就不会很长!

散列函数很重要,好的散列函数很少导致冲突。那么,如何选择好的散列函数呢?这将在下 一节介绍!

5.4 性能

本章开头是假设你在杂货店工作。你想打造一个让你能够迅速获悉商品价格的工具,而散列表的速度确实很快。

在平均情况下,散列表执行各种操作的时间都为O(1)。O(1)被称为常量时间。你以前没有见过常量时间,它并不意味着马上,而是说不管散列表多大,所需的时间都相同。例如,你知道的,简单查找的运行时间为线性时间。

7bdbbed1f3f01c47c2920e7eaf8b6df0.png
图片来源:算法图解

一条水平线,看到了吧?这意味着无论散列表包含一个元素还是10亿个元素,从其中获取数 据所需的时间都相同。实际上,你以前见过常量时间——从数组中获取一个元素所需的时间就是 固定的:不管数组多大,从中获取一个元素所需的时间都是相同的。在平均情况下,散列表的速 度确实很快。

在最糟情况下,散列表所有操作的运行时间都为O(n)——线性时间,这真的很慢。我们来将 散列表同数组和链表比较一下。

e7d2735687d9f12b1d058972542ec3cf.png
图片来源:算法图解

在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速 度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。 因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:

  • 较低的填装因子;
  • 良好的散列函数。

5.5小结

你几乎根本不用自己去实现散列表,因为你使用的编程语言提供了散列表实现。你可使用 Python提供的散列表,并假定能够获得平均情况下的性能:常量时间。

散列表是一种功能强大的数据结构,其操作速度快,还能让你以不同的方式建立数据模型。 你可能很快会发现自己经常在使用它。

  • 你可以结合散列函数和数组来创建散列表。
  • 冲突很糟糕,你应使用可以最大限度减少冲突的散列函数。
  • 散列表的查找、插入和删除速度都非常快。
  • 散列表适合用于模拟映射关系。
  • 一旦填装因子超过0.7,就该调整散列表的长度。
  • 散列表可用于缓存数据(例如,在Web服务器上)。
  • 散列表非常适合用于防止重复。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值