哈希表如何工作?


494

我正在寻找有关哈希表如何工作的解释-用朴素的英语代表像我这样的简单人!

例如,我知道它需要键,计算哈希值(我正在寻找一种解释方式),然后执行某种模运算以求出它在存储值的数组中的位置,但是这就是我的知识所在。

谁能澄清这个过程?

编辑:我不是专门询问哈希码的计算方式,而是关于哈希表工作原理的一般概述。


4
最近,我写了这篇(en.algoritmy.net/article/50101/Hash-table)文章,描述了几种方法,如何存储和查找数据,重点是哈希表及其策略(分离链接,线性探测,双重哈希) )
malejpavouk

1
您可以将哈希表视为数组的扩展版本,而不仅限于连续的整数键。
user253751 '16

Answers:


913

这是外行的解释。

假设您想在图书馆中装满书籍,而不仅仅是将它们装在其中,而且还希望能够在需要时轻松地再次找到它们。

因此,您决定,如果想读书的人知道这本书的书名和要启动的确切书名,那么这便是全部。有了书名,这个人在图书馆员的帮助下应该能够轻松,快速地找到这本书。

那么,你该怎么做呢?好吧,很明显,您可以保留每本书放置位置的某种列表,但是然后您遇到了与搜索图书馆相同的问题,您需要搜索列表。当然,列表会更小并且更易于搜索,但是您仍然不想从库(或列表)的一端到另一端依次搜索。

您想要的是带有书名的内容,可以一次为您提供正确的位置,因此您所要做的就是漫步到正确的书架上,然后拿起书。

但是那怎么办呢?好吧,在填充库时需要一些周全的考虑,而在填充库时需要做很多工作。

您可以设计一个聪明的小方法,而不仅仅是开始从一端到另一端填充库。您拿起这本书的书名,通过一个小的计算机程序运行它,这会吐出书架号和该书架上的插槽号。这是放置书的地方。

该程序的优点在于,以后,当有人返回阅读本书时,您可以再次通过该程序输入书名,并获得与最初给定的相同的架子号和插槽号。这本书所在的位置。

正如其他人已经提到的那样,该程序称为哈希算法或哈希计算,通常通过获取输入的数据(在本例中为书名)并从中计算出一个数字来工作。

为了简单起见,假设它只是将每个字母和符号转换为数字并将它们加起来。实际上,它要比这复杂得多,但是现在让我们保留它。

这种算法的优点在于,如果您一次又一次地向相同的输入输入相同的输入,则每次都会不断吐出相同的数字。

好的,基本上就是哈希表的工作方式。

技术内容如下。

首先,是数字的大小。通常,此类哈希算法的输出在一定范围内,通常大于表中的空间。例如,假设我们在图书馆中可以容纳一百万本书。哈希计算的输出可能在0到10亿的范围内,这要高得多。

那么我们该怎么办?我们使用一种称为模数计算的方法,它基本上是说,如果您计算到想要的数字(即十亿个数字),但又希望保持在一个较小的范围内,那么每次您达到该较小范围的极限时,您都将从0,但是您必须跟踪您所走的最大顺序。

假设哈希算法的输出在0到20的范围内,并且从特定标题中获得的值为17。如果图书馆的大小只有7本书,则您需要计数1、2、3、4、5、6,而当您获得7时,您将从0开始。由于我们需要计数17次,所以我们有1, 2、3、4、5、6、0、1、2、3、4、5、6、0、1、2、3,最后的数字是3。

当然,模数计算不是那样完成的,它是通过除法和余数来完成的。将17除以7的余数为3(7在14处变为17的2倍,而17与14之间的差为3)。

因此,您将这本书放在第3号插槽中。

这导致下一个问题。碰撞。由于该算法无法将书隔开,因此它们无法准确地填满库(如果需要的话,也可以填入哈希表),因此它总是会计算出以前使用过的数字。从图书馆的角度来说,当您到达要放入书架的书架和插槽编号时,那里已经有一本书。

存在各种冲突处理方法,包括将数据运行到另一个计算中以获取表中的另一个位置(双哈希),或者只是找到与您所给定的空间接近的空间(即紧挨着上一本书的位置,假定有插槽)可用,也称为线性探测)。这意味着您稍后尝试查找该书时,需要做一些挖掘工作,但这仍然比仅从库的一端开始要好。

最后,在某个时候,您可能想将超出图书馆允许数量的书籍放入图书馆。换句话说,您需要构建一个更大的库。由于库中的确切位置是使用库的当前大小来计算的,因此,如果您调整库的大小,则可能最终不得不为所有书籍找到新的位置,因为计算完成后才找到它们的位置已经改变。

我希望这个解释比水桶和功能更扎实:)


感谢您的精彩解释。您知道在哪里可以找到有关在4.x .Net框架中如何实现的更多技术细节吗?
Johnny_D 2015年

不,这只是一个数字。您只需对每个机架和插槽编号,从0或1开始,然后对该机架上的每个插槽加1,然后在下一个机架上继续编号。
Lasse V. Karlsen 2015年

2
“存在各种各样的冲突处理方法,包括将数据运行到另一个计算中以获得表中的另一个位置”-另一个计算是什么意思?这只是另一种算法?好的,假设我们使用另一种算法,该算法根据书名输出不同的数字。然后,如果我找到那本书,我怎么知道要使用哪种算法?我会使用第一种算法,第二种算法,依此类推,直到找到书名是我要寻找的书?
user107986 2015年

1
@KyleDelaney:否适用于封闭式散列(冲突是通过查找备用存储桶来解决的,这意味着内存使用情况是固定的,但是您需要花费更多时间在存储桶中进行搜索)。对于病理情况下的开放式哈希(又名“哈希”函数),可能会导致大多数哈希存储桶为空,但总的内存使用情况并不差,只有更多的指针为NULL而不是索引到数据很有用。
托尼·德罗伊

3
@KyleDelaney:需要“ @Tony”来通知您的评论。似乎您对链接感到好奇:假设我们有三个值节点A{ptrA, valueA}, B{ptrB, valueB}, C{ptrC, valueC},以及一个带有三个存储桶的哈希表[ptr1, ptr2, ptr3]。无论插入时是否有冲突,内存使用量都是固定的。您可能没有碰撞:A{NULL, valueA} B{NULL, valueB} C{NULL, valueC}[&A, &B, &C],或者所有碰撞A{&B, valueA} B{&C, valueB}, C{NULL, valueC}[NULL, &A, NULL]:NULL桶是否被“浪费”?有点吧 使用的总内存相同。
托尼·德罗伊

104

用法和术语:

  1. 哈希表用于快速存储和检索数据(或记录)。
  2. 记录使用哈希键存储在存储桶中
  3. 哈希键是通过对记录中包含的选定值(键值)应用哈希算法来计算的。此选择的值必须是所有记录的公共值。
  4. 每个存储桶可以具有以特定顺序组织的多个记录。

真实示例:

Hash&Co .成立于1803年,缺乏任何计算机技术,共有300个文件柜,可为大约30,000个客户保留详细的信息(记录)。每个文件夹都清楚地标明了其客户编号,客户编号为0到29,999之间的唯一编号。

当时的归档文员必须快速获取并存储工作人员的客户记录。工作人员已决定,使用哈希方法存储和检索他们的记录会更有效。

要提交客户记录,提交文员将使用写在文件夹上的唯一客户编号。他们使用此客户号将哈希密钥调制300,以识别其中包含的文件柜。当他们打开文件柜时,他们会发现其中包含许多按客户号排序的文件夹。确定正确的位置后,他们只需将其滑入。

要检索客户记录,将在纸条上为归档文员提供客户编号。他们将使用此唯一的客户编号(哈希键)将其调制300,以确定哪个文件柜具有客户文件夹。当他们打开文件柜时,他们会发现它包含许多按客户编号排序的文件夹。通过搜索记录,他们将快速找到客户端文件夹并进行检索。

在我们的实际示例中,我们的存储桶文件柜,我们的记录文件夹


要记住的重要一点是,计算机(及其算法)处理数字要比处理字符串更好。因此,使用索引访问大型数组比顺序访问要快得多。

正如Simon提到的那样,我认为非常重要的是,哈希部分是转换大空间(具有任意长度,通常是字符串等)并将其映射到小空间(具有已知大小,通常是数字)以进行索引。要记住这一点非常重要!

因此,在上面的示例中,大约30,000个可能的客户端被映射到较小的空间。


其主要思想是将整个数据集划分为多个段,以加快实际搜索的速度,这通常很耗时。在上面的示例中,每个300个文件柜(统计上)将包含大约100条记录。搜索100条记录(无论顺序如何)比处理30,000条记录要快得多。

您可能已经注意到一些实际已经这样做了。但是,在大多数情况下,他们没有设计一种哈希方法来生成哈希密钥,而是仅使用姓氏的第一个字母。因此,如果您有26个文件柜,每个文件柜都包含一个从A到Z的字母,那么从理论上讲,您已经对数据进行了分段,并增强了归档和检索过程。

希望这可以帮助,


2
您描述了一种特定类型的哈希表冲突避免策略,称为可变的“开放式寻址”或“封闭式寻址”(是的,可悲但真实)或“链接”。还有另一种类型,它不使用列表存储桶,而是“内联”存储项目。
康拉德·鲁道夫

2
出色的描述。除了每个文件柜平均包含大约100记录(3万条记录/ 300柜= 100)。可能值得编辑。
瑞安·塔克

@TonyD,在线访问此站点sha-1,并为TonyD您在文本字段中键入的内容生成SHA-1哈希。您最终将获得类似于的生成值e5dc41578f88877b333c8b31634cf77e4911ed8c。这只不过是十六进制的160位(20个字节)而已。然后,您可以使用它来确定将使用哪个存储桶(数量有限)来存储您的记录。
Jeach

@TonyD,我不确定在冲突问题中“哈希密钥”一词在何处提及?如果是这样,请指出两个或多个位置。还是您是说“我们”使用术语“哈希键”,而其他站点(如Wikipedia)则使用“哈希值,哈希码,哈希和或只是哈希”?如果是这样,谁在乎,只要使用的术语在组或组织中是一致的即可。程序员经常使用“关键”一词。我个人认为,另一个不错的选择是“哈希值”。但是我会排除使用“哈希码,哈希和或只是哈希”。专注于算法而不是文字!
Jeach

2
@TonyD,我已将文本更改为“它们将把哈希键模块化300”,希望它对每个人来说都更加清晰。谢谢!
Jeach

64

事实证明这是一个相当深的理论领域,但基本概述很简单。

本质上,哈希函数只是一个函数,它从一个空间(例如任意长度的字符串)中获取内容,并将其映射到对索引有用的空间(例如,无符号整数)。

如果您只有一小部分要哈希的内容,则可以将这些内容解释为整数就可以了(例如4个字节的字符串)

不过,通常情况下,您会有更大的空间。如果您允许用作键的事物的空间大于用于索引的事物(您的uint32或其他东西)的空间,则每个对象都不可能具有唯一的值。当两个或两个以上的事物散列到相同的结果时,您将不得不以适当的方式处理冗余(这通常称为冲突,而您如何处理它或不这样做将在某种程度上取决于您的身份)使用哈希值)。

这意味着您希望它不太可能具有相同的结果,并且您也可能真的希望哈希函数更快。

平衡这两个属性(以及其他几个属性)使很多人忙!

在实践中,通常应该能够找到一个对您的应用程序有效的函数,然后使用它。

现在将其用作哈希表:想象一下,您并不关心内存使用情况。然后,只要创建索引集,就可以创建一个数组(例如所有uint32的索引集)。在向表中添加内容时,您将其哈希,然后查看该索引处的数组。如果那里什么都没有,那就把价值放在那。如果那里已经有东西,则可以将此新条目添加到该地址的事物列表中,并添加足够的信息(您的原始密钥或一些巧妙的信息)以查找哪个条目实际上属于哪个密钥。

因此,随着时间的流逝,哈希表(数组)中的每个条目都为空,或者包含一个条目或条目列表。检索很简单,就像索引数组一样,要么返回值,要么遍历值列表并返回正确的值。

当然,实际上您通常无法做到这一点,因为它浪费了太多内存。因此,您将基于稀疏数组执行所有操作(其中唯一的条目是您实际使用的条目,其他所有条目隐式为null)。

有很多方案和技巧可以使它更好地工作,但这是基础。


1
抱歉,我知道这是一个古老的问题/答案,但是我一直在努力理解您提出的最后一点。哈希表的时间复杂度为O(1)。但是,一旦使用了稀疏数组,就不需要进行二进制搜索来找到您的值了吗?那时时间复杂度不是O(log n)吗?
herbrandson '16

@herbrandson:否...稀疏数组只是意味着已经用值填充了相对较少的索引-您仍然可以直接为从键中计算出的哈希值索引到特定的数组元素;尽管如此,Simon所描述的稀疏数组实现仅在非常有限的情况下才是理智的:当存储桶大小处于内存页面大小的量级时(相对于int键为1的1000稀疏度和4k页面=触摸的大多数页面),以及何时当地址空间充足时,操作系统会有效地处理全0页(因此,所有未使用的存储桶页都不需要后备内存)..
Tony Delroy 2016年

@TonyDelroy-是的,这确实是过分简单化,但其想法是概述它们的含义和原因,而不是实际的实现。正如您在扩展中所说的那样,后者的细节更加细微。
simon

48

答案很多,但都不是很直观,并且哈希表在可视化时可以轻松地“单击”。

哈希表通常实现为链接列表的数组。如果我们想象一个存储人名的表,则在插入几行后可能会如下所示将其布置在内存中,其中- ()括起来的数字是文本/名称的哈希值。

bucket#  bucket content / linked list

[0]      --> "sue"(780) --> null
[1]      null
[2]      --> "fred"(42) --> "bill"(9282) --> "jane"(42) --> null
[3]      --> "mary"(73) --> null
[4]      null
[5]      --> "masayuki"(75) --> "sarwar"(105) --> null
[6]      --> "margaret"(2626) --> null
[7]      null
[8]      --> "bob"(308) --> null
[9]      null

几点:

  • 每个数组条目(索引[0][1]...)被称为存储桶,并启动一个-可能为空链接的列表(在此示例中为aka 元素 -人们的名字
  • 各值(例如"fred"与散列42)从桶连接[hash % number_of_buckets]例如42 % 10 == [2]; %模运算符 -余数除以桶数
  • 多个数据值可能在同一存储桶处发生冲突并从同一存储桶进行链接,通常是因为它们的哈希值在模运算之后发生冲突(例如42 % 10 == [2]9282 % 10 == [2]),但有时是因为哈希值是相同的(例如,"fred"并且"jane"两者都在42上面用哈希显示)
    • 大多数哈希表通过将要查找或插入的值的完整值(此处为文本)与哈希表存储桶中链表中已存在的每个值进行比较来处理冲突(性能略有降低,但不会造成功能混乱)

链接列表的长度与负载系数有关,与值的数量无关

如果表大小增加,按上述方法实现的哈希表会自行调整大小(即创建更大的存储桶数组,从中创建新的/更新的链表,删除旧的数组)以保持值与存储桶的比率(即负载)系数)在0.5到1.0范围内。

汉斯(Hans)在下面的注释中给出了其他负载因子的实际公式,但提供了指示性值:使用负载因子1和加密强度哈希函数时,1 / e(〜36.8%)的存储桶将趋于空,而另外1 / e (〜36.8%)具有一个元素1 /(2e)或〜18.4%两个元素,1 /(3!e)约6.1%三个元素,1 /(4!e)或〜1.5%四个元素,1 / (5!e)〜.3%有五个,等等。-无论表中有多少个元素(即,是否有100个元素和100个桶,或1亿个),非空桶的平均链长为〜1.58元素和1亿个存储桶),这就是为什么我们说查找/插入/擦除是O(1)恒定时间操作。

哈希表如何将键与值关联

给定如上所述的哈希表实现,我们可以想象创建一个值类型(例如)struct Value { string name; int age; };,相等比较和哈希函数,这些值类型仅查看name字段(忽略年龄),然后发生一些奇妙的事情:我们可以将Value记录存储{"sue", 63}在表中,然后在不知道她年龄的情况下搜索“ sue”,找到存储的值并恢复甚至更新她的年龄
-Sue生日快乐-有趣的是,它不会更改哈希值,因此不需要我们将Sue的记录移至另一个桶。

执行此操作时,我们将哈希表用作关联容器(也称为map),并且可以将其存储的值视为由(名称)和一个或多个其他字段组成,这些字段仍称为-令人困惑- (在我的示例中,只是年龄)。用作映射的哈希表实现称为哈希映射

这与该答案前面的示例相反,在该示例中,我们存储了离散值(如“ sue”),您可以将其视为自己的键:这种用法称为哈希集

还有其他方法可以实现哈希表

并非所有哈希表都使用链表(称为独立链表),但大多数通用表都使用链表,因为主要的替代哈希(也称为开放寻址(尤其是支持擦除操作)具有不稳定的性能属性,且容易发生碰撞键/哈希函数。


关于哈希函数的几句话

强大的哈希...

通用的,在最坏情况下尽量减少冲突的哈希函数的工作是在哈希表存储桶周围随机有效地喷洒密钥,同时始终为同一密钥生成相同的哈希值。理想情况下,即使在密钥中的任何位置更改一位,也将随机地翻转生成的哈希值中大约一半的位。

通常情况下,这是由数学安排得太复杂,以至于我无法理解。我将介绍一种易于理解的方法-不是最可扩展的或对缓存友好的,但本质上是优雅的(例如一次性加密!),因为我认为它有助于带动上述理想的质量。假设您要对64位doubles 进行哈希处理-您可以创建8个表,每个表包含256个随机数(下面的代码),然后使用double的内存表示形式的每个8位/ 1字节切片来索引到不同的表中,您查找随机数。通过这种方法,很容易看到在二进制数位中的任何地方发生了变化,double结果是在一张表中查找了一个不同的随机数,以及一个完全不相关的最终值。

// note caveats above: cache unfriendly (SLOW) but strong hashing...
size_t random[8][256] = { ...random data... };
const char* p = (const char*)&my_double;
size_t hash = random[0][p[0]] ^ random[1][p[1]] ^ ... ^ random[7][p[7]];

弱但经常快速散列...

许多库的哈希函数通过不变的整数(称为琐碎身份哈希函数)传递整数。这是上述强哈希处理的另一个极端。身份哈希非常在最坏的情况下容易发生冲突,但是希望是在整数键趋于增加(可能有一些间隙)的相当普遍的情况下,它们将映射到连续的存储桶中,而留下的空余空间比随机散列叶少(我们的〜36.8在前面提到的负载因子1时为%),因此与随机映射相比,碰撞次数更少,碰撞元素的链接列表更长。节省生成强哈希值所花费的时间也很棒,而且如果对键进行查找以便在内存附近的存储桶中找到它们,则可以改善缓存命中率。当密钥不能很好地递增时,希望它们会足够随机,它们将不需要强大的哈希函数来完全随机化它们在存储桶中的位置。


6
请允许我说:很棒的答案。
CRThaze

@Tony Delroy感谢您的精彩回答。我仍然有一个开放的想法。您说即使有1亿个存储桶,查找时间也将是O(1)(负载因子为1)和加密强度哈希函数。但是如何在1亿中找到合适的存储区呢?即使我们将所有存储桶都排序了,也不是O(log100.000.000)吗?如何找到存储桶为O(1)?
selman

@selman:您的问题并未提供很多细节来解释为什么您认为它可能是O(log100,000,000),但您确实会说“即使我们对所有存储桶都进行了排序”,请记住,哈希表存储桶中的值通常,它们从不 “排序”:通过将哈希函数应用于键,可以确定哪个值出现在哪个存储桶中。认为复杂度为O(log100,000,000)意味着您想象通过排序的存储桶进行二进制搜索,但这不是散列的工作原理。也许阅读其他一些答案,看看它是否变得更有意义了。
托尼·德罗伊

@TonyDelroy实际上,“排序的存储桶”是我想象中的最佳情况。因此O(log100,000,000)。但是,如果不是这种情况,应用程序如何在数百万个中找到相关的存储桶?哈希函数是否以某种方式生成内存位置?
selman

1
@selman:因为计算机内存允许恒定的时间“随机访问”:如果您可以计算内存地址,则可以检索内存内容,而不必访问数组其他部分的内存。因此,无论您访问第一个存储桶,最后一个存储桶,还是访问它们之间的任何存储桶,它都将具有相同的性能特征(松散地,花费相同的时间,尽管会受到CPU L1 / L2 / L3内存缓存影响,但是它们只会帮助您快速重新访问最近访问过的存储桶或与之同时发生的附近存储桶,对于big-O分析可以忽略)。
托尼·德罗伊

24

你们非常接近于完整地解释这一点,但是缺少一些东西。哈希表只是一个数组。数组本身将在每个插槽中包含一些内容。至少您将在此插槽中存储哈希值或值本身。除此之外,您还可以存储在此插槽上发生冲突的值的链接/链表,或者可以使用开放寻址方法。您也可以存储一个指针或指向要从此插槽中检索的其他数据的指针。

重要的是要注意,哈希值本身通常并不指示将值放入的插槽。例如,哈希值可能是负整数值。显然,负数不能指向数组位置。另外,哈希值往往会比可用插槽大很多倍。因此,哈希表本身需要执行另一次计算,以找出该值应放入哪个插槽。这是通过模数数学运算完成的,例如:

uint slotIndex = hashValue % hashTableSize;

该值是该值将进入的插槽。在开放式寻址中,如果该插槽已被另一个哈希值和/或其他数据填充,则将再次运行模数运算以查找下一个插槽:

slotIndex = (remainder + 1) % hashTableSize;

我想可能还有其他更高级的方法来确定插槽索引,但这是我见过的常见方法……对其他性能更好的方法感兴趣。

使用模数方法,如果您有一个大小为1000的表,则介于1到1000之间的任何哈希值都将进入相应的插槽。任何负值以及任何大于1000的值都可能是冲突的插槽值。发生这种情况的可能性既取决于您的哈希方法,又取决于您添加到哈希表中的项目总数。通常,最佳实践是使散列表的大小使得添加到其上的值的总数仅等于其大小的大约70%。如果您的散列函数在均匀分布方面做得很好,那么通常您将遇到很少甚至没有桶/槽冲突,并且对于查找和写入操作,它将非常快速地执行。如果预先不知道要添加的值的总数,请使用任何手段进行合理估计,

希望对您有所帮助。

PS-在C#中,该GetHashCode()方法相当慢,并且在我测试过的许多条件下都会导致实际值冲突。为了获得一些真正的乐趣,请构建自己的哈希函数,并尝试使其永远不会与正在哈希的特定数据发生冲突,比GetHashCode运行得更快,并且分布相当均匀。我使用long而不是int大小的哈希码值来完成此操作,并且它在哈希表中最多0冲突的情况下,最多可处理3200万个整个哈希值。不幸的是,我无法共享代码,因为它属于我的雇主……但是我可以透露某些数据域是可能的。当您可以实现此目标时,哈希表非常快。:)


我知道该帖子很旧,但是有人可以在这里解释(余数+ 1)的意思
Hari 2014年

3
@Hari remainder指的是原始模运算的结果,我们将其加1以便找到下一个可用插槽。
x4nd3r 2015年

“数组本身将在每个插槽中包含某些内容。至少,您将在此插槽中存储哈希值或值本身。” -“插槽”(存储桶)根本不存储任何值是很常见的;开放式寻址实现通常在链接列表中存储NULL或指向第一个节点的指针-在插槽/存储桶中没有直接值。 “对其他任何事物都会感兴趣” -您说明的“ +1”被称为线性探测,通常表现更好:二次探测“通常遇到很少甚至没有铲斗/插槽碰撞” -@ 70%容量,〜12%插槽,带有2个值,
〜3

“我已经使用long而不是int大小的哈希码值来完成此操作,并且在哈希表中最多有3200万个散列值且冲突为0的情况下,效果很好。” -在通常情况下,键值实际上是在比存储桶数大得多的范围内随机的情况下,这根本是不可能的。请注意,具有不同的哈希值通常很容易(并且您对long哈希值的论述暗示您已经实现了这一点),但是要确保在不执行mod /%操作之后它们不会在哈希表中发生冲突(通常情况下) )。
托尼·德罗伊

(避免所有冲突被称为完美哈希。通常,它对于预先知道的数百或数千个键是可行的-gperf是计算此类哈希函数的工具的一个示例。您也可以编写非常有限的密钥情况-例如,如果您的键是指向您自己的内存池中的对象的指针,该对象保持相当满,并且每个指针之间保持固定距离,则可以将指针除以该距离,并有效地将索引分配到一个稍微稀疏的数组中,从而避免了碰撞。)
Tony Delroy

17

在我的理解中,这是这样的:

这是一个示例:将整个表格想象成一系列的存储桶。假设您有一个使用字母数字哈希码的实现,并且每个字母对应一个存储桶。此实现将哈希码以特定字母开头的每个项目放入相应的存储桶中。

假设您有200个对象,但是只有15个对象的哈希码以字母“ B”开头。哈希表仅需要查找和搜索“ B”存储桶中的15个对象,而不是全部200个对象。

至于计算哈希码,没有什么神奇的。目的只是让不同的对象返回不同的代码,并使相等的对象返回相同的代码。您可以编写一个类,该类对于所有实例始终返回与哈希码相同的整数,但是由于它将变成一个巨大的存储桶,因此本质上破坏了哈希表的用处。


13

简短而甜美:

哈希表包装了一个数组,让其称为internalArray。将项目以这种方式插入到数组中:

let insert key value =
    internalArray[hash(key) % internalArray.Length] <- (key, value)
    //oversimplified for educational purposes

有时,两个键将散列到数组中的同一索引,并且您希望保留两个值。我喜欢将两个值存储在同一个索引中,这很容易通过创建internalArray链接列表数组来进行编码:

let insert key value =
    internalArray[hash(key) % internalArray.Length].AddLast(key, value)

因此,如果我想从哈希表中检索项目,则可以编写:

let get key =
    let linkedList = internalArray[hash(key) % internalArray.Length]
    for (testKey, value) in linkedList
        if (testKey = key) then return value
    return null

删除操作很容易编写。如您所知,从我们的链接列表数组中进行插入,查找和删除几乎是 O(1)。

当我们的internalArray变得太满时,也许大约有85%的容量,我们可以调整内部数组的大小并将所有项目从旧数组移到新数组中。


11

比这更简单。

哈希表只不过是包含键/值对的向量的数组(通常是稀疏的)。该数组的最大大小通常小于哈希表中存储的数据类型的可能值集中的项目数。

哈希算法用于根据将存储在数组中的项目的值生成该数组的索引。

这是在数组中存储键/值对的向量的地方。因为可以作为数组索引的值的集合通常小于该类型可以拥有的所有可能值的数量,所以散列很可能算法将为两个单独的键生成相同的值。一个好的哈希算法将尽可能避免这种情况(这就是为什么将它归为类型通常是因为它具有一般哈希算法无法知道的特定信息)的原因,但无法避免。

因此,您可以具有多个将生成相同哈希码的键。发生这种情况时,将遍历向量中的项,并在向量中的键和正在查找的键之间进行直接比较。如果找到,则返回great并返回与键关联的值,否则不返回任何值。


10

您需要一堆东西和一个数组。

对于每件事,您都为它组成一个索引,称为哈希。哈希的重要之处在于它“分散”了很多。您不希望两个相似的事物具有相似的哈希值。

您将您的东西放到哈希指示的位置的数组中。在给定的哈希值中,可能会发生不止一件事情,因此您将它们存储在数组或其他适当的东西中,我们通常将其称为存储桶。

当您在哈希中查找内容时,您将执行相同的步骤,找出哈希值,然后查看该位置存储桶中的内容,并检查其是否为所需内容。

当您的哈希工作良好且数组足够大时,数组中的任何特定索引最多只会有几件事,因此您不必花太多时间。

为了获得加分,请确保它在访问哈希表时将找到的内容(如果有)移动到存储桶的开头,因此下次它是第一个检查的内容。


1
感谢其他人都没有提到的最后一点
Sandeep Raju Prabhakar

4

到目前为止,所有答案都是好的,它们涉及哈希表的工作原理的不同方面。这是一个简单的示例,可能会有所帮助。假设我们要存储一些带有小写字母字符串的项作为键。

如simon所述,哈希函数用于从大空间映射到小空间。在我们的示例中,哈希函数的一个简单,简单的实现可以采用字符串的第一个字母,然后将其映射为整数,因此“ alligator”的哈希码为0,“ bee”的哈希码为1,斑马”将为25,依此类推。

接下来,我们有一个26个存储桶的数组(在Java中可以是ArrayLists),然后将该项放入与键的哈希码匹配的存储桶中。如果我们有多个项目的键以相同字母开头,那么它们将具有相同的哈希码,因此所有哈希码都将放入存储桶中,因此必须在存储桶中进行线性搜索才能查找特定项目。

在我们的示例中,如果我们只有几十个项目的键跨越整个字母,那么它将很好地工作。但是,如果我们有一百万个项目,或者所有键都以“ a”或“ b”开头,那么我们的哈希表将不是理想的。为了获得更好的性能,我们需要一个不同的哈希函数和/或更多的存储桶。


3

这是另一种查看方式。

我假设您了解数组A的概念。这支持索引操作,无论A有多大,您都可以一步一步到达Ith元素A [I]。

因此,例如,如果您要存储有关一群年龄恰好不同的人的信息,一种简单的方法是拥有一个足够大的数组,并将每个人的年龄用作该数组的索引。这样,您可以单步访问任何人的信息。

但是当然可以有一个以上相同年龄的人,因此在每个条目中输入的数组是所有具有该年龄的人的列表。因此,您可以一步一步地获取个人信息,然后在该列表中进行一些搜索(称为“存储桶”)。只有在有很多人以至于桶变大的情况下,它才会放慢速度。然后,您需要一个更大的数组,以及一些其他方式来获取有关此人的更多标识信息,例如其姓氏的前几个字母,而不是使用年龄。

这是基本思想。代替使用年龄,可以使用产生良好价值观传播的人的任何功能。那就是哈希函数。就像您可以使用该人的姓名的ASCII表示形式的每三位一位,以某种顺序进行加扰。重要的是,您不希望有太多的人哈希到同一个存储桶,因为速度取决于存储桶保持很小的速度。


2

哈希的计算方式通常不取决于哈希表,而取决于添加到哈希表中的项。在诸如.net和Java的框架/基类库中,每个对象都有一个GetHashCode()(或类似方法)返回该对象的哈希码的方法。理想的哈希码算法和确切的实现取决于对象中表示的数据。


2

哈希表完全适用于以下事实:实际计算遵循随机访问机器模型,即可以在O(1)时间或恒定时间内访问内存中任何地址的值。

因此,如果我有一个键的Universe(我可以在应用程序中使用的所有可能的键的集合,例如学生的卷号,如果它是4位数字,则此Universe是一组从1到9999的数字),并且将它们映射到一组有限大小的方法我可以在系统中分配内存,理论上我的哈希表已经准备好了。

通常,在应用程序中,键的Universe的大小比我要添加到哈希表中的元素数大得多(我不想浪费1 GB的内存来进行哈希,例如,10000或100000整数值,因为它们是32在二进制表示形式中位长)。因此,我们使用此哈希。这是一种混合的“数学”运算,它将我的大宇宙映射到我可以在内存中容纳的一小部分值。在实际情况下,哈希表的空间通常与(元素数*每个元素的大小)具有相同的“顺序”(big-O),因此,我们不会浪费太多内存。

现在,一个大集合映射到一个小集合,映射必须是多对一的。因此,不同的键将分配给相同的空间(??不公平)。有几种方法可以解决此问题,我只知道其中两种流行的方法:

  • 使用将要分配给该值的空间作为对链表的引用。此链表将存储一个或多个值,这些值将以多对一映射的方式位于同一插槽中。链接列表还包含帮助搜索者的键。就像很多人在同一个公寓里一样,当送货员来时,他去了房间,专门问那个家伙。
  • 在数组中使用双哈希函数,该函数每次都给出相同的值序列,而不是单个值。当我去存储一个值时,我看到所需的内存位置是空闲还是已占用。如果是免费的,则可以在其中存储我的值,如果已使用,则从序列中获取下一个值,依此类推,直到找到空闲位置,然后在其中存储我的值。在搜索或获取值时,我按序列给出的相同路径返回,并在每个位置询问值是否存在,直到找到它或搜索数组中所有可能的位置。

CLRS的算法介绍为该主题提供了很好的见解。


0

对于所有寻求编程的人来说,这是它的工作原理。高级哈希表的内部实现在存储分配/解除分配和搜索方面有许多复杂性和优化方法,但是顶级思想将非常相似。

(void) addValue : (object) value
{
   int bucket = calculate_bucket_from_val(value);
   if (bucket) 
   {
       //do nothing, just overwrite
   }
   else   //create bucket
   {
      create_extra_space_for_bucket();
   }
   put_value_into_bucket(bucket,value);
}

(bool) exists : (object) value
{
   int bucket = calculate_bucket_from_val(value);
   return bucket;
}

这里calculate_bucket_from_val()是散列函数,所有的魔法唯一必须发生。

经验法则是: 对于要插入的给定值,存储桶必须是唯一且可从应存储的值中得出。

存储桶是存储值的任何空间-在这里,我将其保留为数组索引,但它也可能是内存位置。


1
“经验法则是:对于要插入的给定值,存储桶必须是唯一且可从应存储的值中导出的。” -这描述了一个完美的哈希函数,通常仅对于编译时已知的数百或数千个值才可用。大多数哈希表必须处理冲突。同样,哈希表倾向于为所有存储桶分配空间,无论它们是否为空,而您的伪代码记录了create_extra_space_for_bucket()插入新键期间的步骤。桶可能是指针。
托尼·德罗伊
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.