【问题标题】:How does a hash table work?哈希表是如何工作的?
【发布时间】:2010-10-18 08:29:30
【问题描述】:

我正在寻找关于哈希表如何工作的解释 - 对于像我这样的傻瓜来说,用简单的英语解释!

例如,我知道它需要密钥,计算哈希(我正在寻找如何解释),然后执行某种模运算来计算它在存储值的数组中的位置,但这就是我的知识停止了。

谁能说明一下流程?

编辑:我不是专门询问哈希码是如何计算的,而是对哈希表如何工作的一般概述。

【问题讨论】:

标签: data-structures hash hashtable modulo


【解决方案1】:

下面是通俗的解释。

假设您想在图书馆里装满书籍,而不仅仅是把它们塞进去,而是希望在需要时能够轻松地再次找到它们。

因此,您决定如果想要阅读一本书的人知道书名和要启动的确切标题,那么就应该这样做。有了书名,在图书管理员的帮助下,该人应该能够轻松快速地找到这本书。

那么,你怎么能这样做呢?好吧,显然您可以保留某种列表,列出每本书的放置位置,但是您会遇到与搜索图书馆相同的问题,您需要搜索列表。当然,列表会更小更容易搜索,但您仍然不想从库(或列表)的一端到另一端按顺序搜索。

你想要的东西,加上书名,可以立刻给你正确的位置,所以你所要做的就是走到正确的书架上,拿起书。

但是怎么做呢?好吧,当你填满图书馆时,有点深谋远虑,当你填满图书馆时,还有很多工作。

与其只是开始从一端到另一端填满库,不如设计一个聪明的小方法。你取这本书的标题,通过一个小型计算机程序运行它,它会在那个架子上输出一个书架号和一个槽号。这是您放置图书的地方。

这个程序的美妙之处在于,后来当有人回来看书时,你再次通过程序输入书名,并取回原来给你的相同的书架号和槽号,这就是这本书所在的地方。

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

为简单起见,假设它只是将每个字母和符号转换为一个数字并将它们加起来。实际上,它比这要复杂得多,但我们暂时搁置它。

这种算法的美妙之处在于,如果你一次又一次地向它输入相同的输入,它每次都会输出相同的数字。

好的,这就是哈希表的基本工作原理。

接下来是技术内容。

首先是数字的大小。通常,这种散列算法的输出在一个很大的数字范围内,通常比您在表中的空间大得多。例如,假设我们在图书馆里正好有 100 万本书的空间。哈希计算的输出可能在 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 处乘以 2 到 17,17 和 14 之间的差是 3)。

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

这导致了下一个问题。碰撞。由于该算法无法将书籍隔开以使它们准确地填满图书馆(或者如果你愿意的话,也可以使用哈希表),它总是会最终计算出一个以前使用过的数字。在图书馆的意义上,当你到达书架和你想放一本书的槽位时,那里已经有一本书了。

存在各种碰撞处理方法,包括将数据运行到另一个计算中以在表中获得另一个位置 (double hashing),或者只是找到一个接近给定位置的空间(即紧邻上一个位置)假设该插槽可用,也称为linear probing)。这意味着当您稍后尝试查找这本书时,您需要进行一些挖掘工作,但这仍然比简单地从图书馆的一端开始要好。

最后,在某个时候,您可能希望将比图书馆允许的更多的书放入图书馆。换句话说,你需要建立一个更大的库。由于图书馆中的确切位置是使用图书馆的确切和当前大小计算的,因此如果您调整图书馆的大小,您可能最终不得不为所有书籍找到新位置,因为计算完成以找到它们的位置变了。

我希望这个解释比桶和函数更接地气:)

【讨论】:

  • 感谢您的精彩解释。你知道我在哪里可以找到更多关于它是如何在 4.x .Net 框架中实现的技术细节吗?
  • 不,它只是一个数字。您只需从 0 或 1 开始为每个架子和插槽编号,并为该架子上的每个插槽增加 1,然后继续在下一个架子上编号。
  • '存在各种碰撞处理方法,包括将数据运行到另一个计算中以获得表中的另一个位置' - 你所说的另一个计算是什么意思?这只是另一种算法?好的,假设我们使用另一种算法,根据书名输出不同的数字。后来,如果我找到那本书,我怎么知道要使用哪种算法?我会使用第一个算法,第二个算法,依此类推,直到找到我要找的书名?
  • @KyleDelaney:closed hashing 不适用(通过查找替代存储桶来处理冲突,这意味着内存使用量是固定的,但您需要花费更多时间在存储桶中进行搜索)。对于@ 987654324@,在病态情况下(可怕的哈希函数或故意设计的输入被某些对手/黑客碰撞)你可能最终会导致大多数哈希桶为空,但总内存使用情况并没有更糟 - 只是更多的指针 NULL 而不是索引有用的数据。
  • @KyleDelaney:需要“@Tony”来获得您的 cmets 的通知。似乎您对链接感到疑惑:假设我们有三个值节点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 存储桶是否“浪费”?有点,有点不像。使用的总内存相同。
【解决方案2】:

用法和行话:

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

现实世界的例子:

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

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

要归档客户记录,归档文员将使用写在文件夹上的唯一客户编号。使用这个客户编号,他们会将 哈希键 调制 300 以识别它所在的文件柜。当他们打开文件柜时,他们会发现其中包含许多按客户编号排序的文件夹.在确定了正确的位置后,他们就会简单地把它塞进去。

要检索客户记录,归档文员将在纸条上获得客户编号。使用这个唯一的客户编号(哈希键),他们会将其调整 300,以确定哪个文件柜有客户文件夹。当他们打开文件柜时,他们会发现里面有许多按客户编号排序的文件夹。通过搜索记录,他们会很快找到客户文件夹并检索它。

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


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

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

所以在上面的例子中,大约 30,000 个可能的客户端被映射到一个更小的空间。


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

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

希望这会有所帮助,

杰奇!

【讨论】:

  • 您描述了一种特定类型的哈希表冲突避免策略,称为可变的“开放寻址”或“封闭寻址”(是的,可悲但真实)或“链接”。还有另一种类型不使用列表桶,而是“内联”存储项目。
  • 优秀的描述。除了每个文件柜平均包含大约100 条记录(30k 条记录 / 300 个文件柜 = 100)。可能值得编辑。
  • @TonyD,访问此站点 sha-1 online 并为您在文本字段中键入的 TonyD 生成 SHA-1 哈希。您最终会得到一个类似于e5dc41578f88877b333c8b31634cf77e4911ed8c 的生成值。这只不过是一个 160 位(20 字节)的大十六进制数。然后,您可以使用它来确定将使用哪个存储桶(数量有限)来存储您的记录。
  • @TonyD,我不确定“哈希键”这个词在哪里被提及?如果是这样,请指出两个或多个位置。或者您是说“我们”使用术语“哈希键”,而其他网站(例如 Wikipedia)使用“哈希值、哈希码、哈希和或简单的哈希”?如果是这样,只要使用的术语在组或组织内是一致的,谁在乎。程序员经常使用“关键”一词。我个人认为另一个不错的选择是“哈希值”。但我会排除使用“散列码、散列和或简单的散列”。专注于算法而不是文字!
  • @TonyD,我已将文本更改为“他们会将 哈希键 模块化 300”,希望它对每个人来说都更清晰。谢谢!
【解决方案3】:

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

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

如果您只有一小部分要散列的东西,您可能只需将这些东西解释为整数就可以了,然后就完成了(例如 4 字节字符串)

不过,通常情况下,您的空间要大得多。如果您允许作为键的事物的空间大于您用于索引的事物的空间(您的 uint32 或其他),那么您不可能为每个事物都有一个唯一的值。当两个或多个事物哈希到相同的结果时,您必须以适当的方式处理冗余(这通常被称为冲突,您如何处理它或不处理它取决于您是什么使用哈希)。

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

平衡这两个属性(以及其他一些属性)让许多人忙得不可开交!

在实践中,您通常应该能够找到已知对您的应用程序运行良好的函数并使用它。

现在让它作为一个哈希表工作:想象一下你不关心内存使用情况。然后,您可以创建一个数组,只要您的索引集(例如,所有 uint32)。当你向表中添加一些东西时,你对它的键进行散列并查看该索引处的数组。如果那里什么都没有,你就把你的价值放在那里。如果那里已经有东西了,你可以把这个新条目添加到那个地址的东西列表中,连同足够的信息(你的原始密钥,或者一些聪明的东西)来找到哪个条目实际上属于哪个密钥。

所以当你走很长的路时,你的哈希表(数组)中的每个条目要么是空的,要么包含一个条目,或者一个条目列表。检索很简单,例如对数组进行索引,然后返回值,或者遍历值列表并返回正确的值。

当然在实践中你通常不能这样做,它会浪费太多的内存。因此,您基于稀疏数组执行所有操作(其中唯一的条目是您实际使用的条目,其他所有内容都隐含为 null)。

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

【讨论】:

  • 对不起,我知道这是一个老问题/答案,但我一直在努力理解你提出的最后一点。哈希表的时间复杂度为 O(1)。然而,一旦你使用了一个稀疏数组,你就不需要进行二分搜索来找到你的值了吗?到那时时间复杂度不就变成O(log n)了吗?
  • @herbrandson: 不......稀疏数组仅仅意味着相对较少的索引已经填充了值 - 您仍然可以直接索引到特定数组元素,以获得您从键计算的哈希值;尽管如此,Simon 描述的稀疏数组实现仅在 非常 有限的情况下才是理智的:当桶大小与内存页面大小相同时(与说 int 键在 1-in-1000 稀疏和4k 页面 = 接触的大多数页面),当操作系统有效地处理所有 0 页面时(因此所有未使用的存储桶页面不需要后备内存),当地址空间充足时......
  • @TonyDelroy - 确实是过于简单化了,但其想法是概述它们是什么以及为什么,而不是实际的实现。后者的细节更加细致入微,正如您在扩展包中所点头的那样。
【解决方案4】:

很多答案,但都不是非常可视化,而哈希表在可视化时可以轻松“点击”。

哈希表通常以链表数组的形式实现。如果我们想象一个存储人名的表,经过几次插入后,它可能会在内存中布局如下,其中()-enclosed 数字是文本/名称的哈希值。

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]...)被称为bucket,并开始一个 - 可能为空 - 链表(又名元素,在本例中为人们的名称
  • 每个值(例如 "fred" 和哈希 42)都从桶 [hash % number_of_buckets] 链接,例如42 % 10 == [2]; %modulo operator - 除以桶数的余数
  • 多个数据值可能冲突在同一个存储桶上并从同一个存储桶链接起来,最常见的原因是它们的哈希值在模运算后发生冲突(例如 42 % 10 == [2] 和 @ 987654336@),但偶尔因为哈希值相同(例如"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% 有五个等.. - 来自非空桶的平均链长度为 ~1.58 无论表中有多少元素(即是否有 100 个元素和 100 个桶,或 1 亿个元素和 1 亿个桶),这就是为什么我们说查找/插入/擦除是 O(1) 恒定时间操作。

哈希表如何将键与值关联

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

当我们这样做时,我们将哈希表用作associative container aka map,它存储的值可以被视为由一个(名称)和一个或多个其他字段仍然被称为 - 令人困惑 - value(在我的示例中,只是年龄)。用作映射的哈希表实现称为哈希映射

这与本答案前面的示例形成对比,在该示例中我们存储离散值(例如“sue”),您可以将其视为其自己的键:这种用法称为 散列集 .

还有其他实现哈希表的方法

并非所有哈希表都使用链表(称为separate chaining),但大多数通用哈希表都使用链表,作为主要替代方案closed hashing (aka open addressing) - 特别是支持擦除操作 - 与易发生冲突的键/哈希函数。


关于哈希函数的几句话

强哈希...

一个通用的、最坏情况冲突最小化哈希函数的工作是随机有效地将键喷洒在哈希表桶周围,同时始终为相同的键生成相同的哈希值。理想情况下,即使密钥中任何位置的一位发生变化 - 随机 - 翻转结果哈希值中大约一半的位。

这通常与数学太复杂,我无法理解。我将提到一种易于理解的方式——不是最具可扩展性或缓存友好的方式,但本质上是优雅的(比如使用一次性密码器进行加密!)——因为我认为它有助于将上述理想品质带回家。假设您正在散列 64 位 doubles - 您可以创建 8 个表,每个表有 256 个随机数(代码如下),然后使用 double 的内存表示的每个 8 位/1 字节切片来索引另一个表,对您查找的随机数进行异或运算。使用这种方法,很容易看出,double 中任何位置的位(在二进制数字意义上)更改会导致在其中一个表中查找不同的随机数,以及完全不相关的最终值。

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

弱但通常很快的散列...

许多库的散列函数通过不变的整数传递(称为平凡身份散列函数);这是上述强散列的另一个极端。在最坏的情况下,身份哈希非常容易发生冲突,但希望在整数键的相当常见的情况下,往往会增加(可能有一些间隙),它们会映射成连续的与随机散列叶相比,存储桶留下的空余量更少(我们在前面提到的负载因子 1 时约为 36.8%),因此与随机映射相比,具有更少的冲突和更少的更长的冲突元素链表。节省生成强哈希所需的时间也很棒,如果按顺序查找键,它们将在内存中附近的存储桶中找到,从而提高缓存命中率。当键没有很好地增加时,希望它们足够随机,它们不需要强大的哈希函数来完全随机化它们在桶中的位置。

【讨论】:

  • 请允许我说:很棒的答案。
  • @Tony Delroy 感谢您的精彩回答。不过,我仍然有一个开放的观点。你说即使有 1 亿个桶,查找时间也是 O(1),负载因子为 1 和加密强度哈希函数。但是在 1 亿中找到合适的桶呢?即使我们把所有的桶都排序了,不是 O(log100.000.000) 吗?如何找到桶是 O(1) ?
  • @selman:你的问题没有提供很多细节来解释为什么你认为它可能是 O(log100,000,000),但你确实说“即使我们已经把所有的桶都排序了”——保持请记住,哈希表桶中的值从不在通常意义上“排序”:哪个值出现在哪个桶中是通过将哈希函数应用于键来确定的。认为复杂度为 O(log100,000,000) 意味着您可以想象通过排序的存储桶进行二进制搜索,但这不是散列的工作方式。也许阅读其他一些答案,看看它是否开始变得更有意义。
  • @TonyDelroy 确实,“排序桶”是我想象的最佳情况。因此 O(log100,000,000) 。但如果不是这样,应用程序如何在数以百万计的桶中找到相关的桶呢?哈希函数会以某种方式生成内存位置吗?
  • @selman:因为计算机内存允许恒定时间“随机访问”:如果您可以计算内存地址,您就可以检索内存内容,而无需访问数组其他部分的内存。因此,无论您访问第一个存储桶、最后一个存储桶还是介于两者之间的存储桶,它都将具有相同的性能特征(松散地说,花费相同的时间,尽管会受到 CPU L1/L2/L3 内存缓存的影响,但它们仅用于帮助您快速重新访问最近访问过或巧合附近的存储桶,在大 O 分析中可以忽略)。
【解决方案5】:

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

请务必注意,哈希值本身通常不指示放置值的槽。例如,哈希值可能是负整数值。显然负数不能指向数组位置。此外,散列值往往比可用插槽大很多倍。因此,哈希表本身需要执行另一个计算来确定值应该进入哪个槽。这是通过模数数学运算完成的,例如:

uint slotIndex = hashValue % hashTableSize;

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

slotIndex = (remainder + 1) % hashTableSize;

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

使用模数方法,如果您有一个大小为 1000 的表,则任何介于 1 和 1000 之间的哈希值都将进入相应的插槽。任何负值和任何大于 1000 的值都可能是冲突槽值。发生这种情况的机会取决于您的散列方法,以及您添加到散列表中的总项目数。通常,最佳做法是使散列表的大小使得添加到其中的值的总数仅等于其大小的大约 70%。如果您的哈希函数在均匀分布方面做得很好,您通常会遇到很少甚至没有桶/槽冲突,并且它会非常快速地执行查找和写入操作。如果事先不知道要添加的值的总数,请使用任何方法进行良好的猜测,然后在添加到其中的元素数量达到容量的 70% 时调整哈希表的大小。

我希望这会有所帮助。

PS - 在 C# 中,GetHashCode() 方法非常慢,并且在我测试过的很多条件下都会导致实际值冲突。为了获得真正的乐趣,构建自己的哈希函数并尝试让它永远不会与您正在哈希的特定数据发生冲突,运行速度比 GetHashCode 快,并且分布相当均匀。我已经使用 long 而不是 int 大小的哈希码值完成了这项工作,并且它在哈希表中多达 3200 万个整数哈希值上运行良好,并且冲突为 0。不幸的是,我无法共享代码,因为它属于我的雇主……但我可以透露某些数据域是可能的。当你能做到这一点时,哈希表会非常快。 :)

【讨论】:

  • 我知道这篇文章已经很老了,但有人可以解释一下 (remainder + 1) 的含义
  • @Hari remainder 指的是原始模计算的结果,我们将其加1以便找到下一个可用槽。
  • “数组本身将在每个插槽中包含一些内容。至少您将在此插槽中存储哈希值或值本身。” - “插槽”很常见(桶)根本不存储任何价值;开放寻址实现通常存储 NULL 或指向链表中第一个节点的指针 - 直接在槽/桶中没有值。 “会对任何其他人感兴趣” - 您说明的“+1”称为 线性探测,通常表现更好:二次探测 . “通常很少遇到桶/槽冲突” - @ 70% 容量,~12% 带 2 个值的槽,~3% 3....
  • “我已经使用 long 而不是 int 大小的哈希码值完成了这项工作,并且它在哈希表中多达 3200 万个整数哈希值上运行良好,并且发生了 0 个冲突。” -这在一般情况中根本不可能,因为键的值在比桶数大得多的范围内是有效随机的。请注意,拥有不同的哈希值通常很容易(您对long 哈希值的谈论暗示这就是您所取得的成就),但要确保它们在 mod 之后不会在 哈希表中 发生冲突/% 操作不是(一般情况下)。
  • (避免所有冲突被称为完美散列。一般来说,它对于预先知道的几百或几千个键是实用的 - gperf 是一个工具示例计算这样一个哈希函数。您也可以在非常有限的情况下编写自己的 - 例如,如果您的键是指向您自己的内存池中的对象的指针,该内存池保持相当满,每个指针相隔固定距离,您可以将指针除以该距离并有效地在稍微稀疏的数组中建立索引,避免冲突。)
【解决方案6】:

我的理解是这样的:

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

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

就计算哈希码而言,它并没有什么神奇之处。目标只是让不同的对象返回不同的代码,并让相同的对象返回相同的代码。您可以编写一个始终返回与所有实例的哈希码相同的整数的类,但实际上您会破坏哈希表的实用性,因为它只会变成一个巨大的桶。

【讨论】:

    【解决方案7】:

    又短又甜:

    哈希表包含一个数组,我们称之为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% 左右的容量,我们可以调整内部数组的大小并将旧数组中的所有项目移动到新数组中。

    【讨论】:

      【解决方案8】:

      比这更简单。

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

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

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

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

      【讨论】:

        【解决方案9】:

        你拿了一堆东西,还有一个数组。

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

        您将您的东西放入数组中由哈希指示的位置。一个给定的哈希值可能不止一个东西,所以你将这些东西存储在数组或其他适当的东西中,我们通常称之为存储桶。

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

        当您的哈希运行良好并且您的数组足够大时,在数组中的任何特定索引处最多只会有一些东西,所以您不必看太多。

        要获得奖励积分,请确保在访问您的哈希表时,它将找到的东西(如果有)移动到存储桶的开头,以便下次检查它。

        【讨论】:

        • 感谢其他人错过的最后一点
        【解决方案10】:

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

        正如 simon 所解释的,散列函数用于从大空间映射到小空间。对于我们的示例,哈希函数的一个简单、朴素的实现可以获取字符串的第一个字母,并将其映射到一个整数,因此“alligator”的哈希码为 0,“bee”的哈希码为 1,“斑马”将是 25,等等。

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

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

        【讨论】:

          【解决方案11】:

          这是另一种看待它的方式。

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

          因此,例如,如果您想存储一组碰巧有不同年龄的人的信息,一个简单的方法是拥有一个足够大的数组,并使用每个人的年龄作为索引数组。这样一来,您就可以一步访问任何人的信息。

          当然,同龄人可能不止一个,因此您在数组中的每个条目中放入的是所有具有该年龄的人的列表。因此,您可以通过一个步骤以及在该列表(称为“存储桶”)中的一点点搜索来获取个人信息。只有当人太多以至于水桶变大时,它才会放慢速度。然后你需要一个更大的数组,以及其他一些方法来获取更多关于这个人的识别信息,比如他们姓氏的前几个字母,而不是使用年龄。

          这是基本的想法。可以使用产生良好传播价值的人的任何功能,而不是使用年龄。这就是哈希函数。就像您可以将人名的 ASCII 表示的每三分之一位按某种顺序打乱一样。重要的是你不希望太多人哈希到同一个桶,因为速度取决于桶保持小。

          【讨论】:

            【解决方案12】:

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

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

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

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

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

            CLRS 的算法简介提供了关于该主题的非常好的见解。

            【讨论】:

              【解决方案13】:

              基本理念

              为什么人们用梳妆台来存放他们的衣服?除了看起来时尚和时尚,他们的优势在于每件衣服都有它应该在的地方。如果您正在寻找一双袜子,您只需检查袜子抽屉。如果你正在寻找一件衬衫,你可以检查一下里面有你的衬衫的抽屉。当你在寻找袜子时,你有多少件衬衫或你拥有多少条裤子都没关系,因为你不需要看它们。你只要看看袜子抽屉,就会发现里面有袜子。

              从高层次上讲,哈希表是一种存储(有点像)像衣服梳妆台一样的东西的方式。基本思路如下:

              • 您可以获得一些可以存放物品的位置(抽屉)。
              • 您想出了一些规则,告诉您每件物品属于哪个位置(抽屉)。
              • 当您需要查找某些东西时,您可以使用该规则来确定要查看的抽屉。

              这样的系统的优势在于,假设您的规则不太复杂并且您有适当数量的抽屉,您只需在正确的位置查找即可很快找到您要查找的内容。

              当你把衣服收起来时,你使用的“规则”可能是“袜子放在左上方的抽屉里,衬衫放在中间的大抽屉里,等等”。但是,当您存储更多抽象数据时,我们会使用一种称为散列函数的东西来为我们完成这项工作。

              考虑散列函数的合理方法是将其视为黑盒。您将数据放在一侧,然后一个称为 哈希码 的数字从另一侧出来。从示意图上看,它看起来像这样:

                            +---------+
                          |\|   hash  |/| --> hash code
                 data --> |/| function|\|
                            +---------+
              

              所有哈希函数都是确定性的:如果您将相同的数据多次放入函数中,您总是会从另一端得到相同的值。一个好的散列函数应该看起来或多或少是随机的:输入数据的微小变化应该给出截然不同的散列码。例如,字符串"pudu" 和字符串"kudu" 的哈希码可能会彼此大不相同。 (话又说回来,它们可能是相同的。毕竟,如果哈希函数的输出看起来或多或少是随机的,那么我们有可能两次获得相同的哈希码。)

              您究竟是如何构建哈希函数的?现在,让我们继续“体面的人不应该想太多”。数学家已经想出了更好和更差的方法来设计散列函数,但为了我们的目的,我们真的不需要太担心内部结构。把散列函数想象成一个函数就很好了

              • 确定性(相等的输入产生相等的输出),但是
              • 看起来很随机(很难预测一个哈希码给定另一个)。

              一旦我们有了哈希函数,我们就可以构建一个非常简单的哈希表。我们将制作一系列“桶”,您可以将其视为类似于我们梳妆台中的抽屉。要将项目存储在哈希表中,我们将计算对象的哈希码并将其用作表中的索引,这类似于“选择该项目进入哪个抽屉”。然后,我们将该数据项放入该索引处的存储桶中。如果那个桶是空的,那就太好了!我们可以把物品放在那里。如果那个桶是满的,我们可以做一些选择。一种简单的方法(称为chained hashing)是将每个存储桶视为一个项目列表,就像您的袜子抽屉可能存储多个袜子一样,然后只需将项目添加到该索引处的列表中。

              要在哈希表中查找某些内容,我们使用基本相同的过程。我们首先计算要查找的项目的哈希码,它告诉我们要查找哪个桶(抽屉)。如果项目在表中,它必须在那个桶中。然后,我们只需查看存储桶中的所有项目,看看我们的项目是否在其中。

              这样做有什么好处?好吧,假设我们有大量的桶,我们希望大多数桶中不会有太多的东西。毕竟,我们的散列函数看起来有点像随机输出,所以项目在所有桶中均匀分布。事实上,如果我们将“我们的哈希函数看起来有点随机”的概念形式化,我们可以证明每个桶中的预期项目数是项目总数与桶总数的比率。因此,我们无需做太多工作就可以找到我们正在寻找的项目。

              细节

              解释“哈希表”的工作原理有点棘手,因为哈希表有很多种。下一节将讨论所有哈希表共有的一些通用实现细节,以及不同样式的哈希表如何工作的一些细节。

              出现的第一个问题是如何将哈希码转换为表槽索引。在上面的讨论中,我只是说“使用哈希码作为索引”,但这实际上不是一个好主意。在大多数编程语言中,散列码都适用于 32 位或 64 位整数,您不能直接将它们用作存储桶索引。相反,一种常见的策略是创建一个大小为 m 的存储桶数组,为您的项目计算(完整的 32 位或 64 位)哈希码,然后根据表的大小对它们进行修改以获得介于 0 和m-1,包括在内。模数的使用在这里效果很好,因为它的速度相当快,并且可以很好地在较小的范围内传播全范围的哈希码。

              (您有时会看到此处使用的按位运算符。如果您的表的大小是 2 的幂,例如 2k,则计算哈希码的按位与,然后计算数字 2k - 1 相当于计算模数,而且速度明显更快。)

              下一个问题是如何选择正确数量的存储桶。如果你选择了太多的桶,那么大多数桶将是空的或只有很少的元素(有利于速度 - 你只需要检查每个桶的几个项目),但你会使用一堆空间来简单地存储桶(不是这样太好了,虽然也许你买得起)。另一面也适用 - 如果存储桶太少,平均每个存储桶的元素会更多,查找时间会更长,但会使用更少的内存。

              一个好的折衷方案是在哈希表的整个生命周期内动态更改存储桶的数量。哈希表的负载因子,通常表示为 α,是元素数与桶数之比。大多数哈希表选择一些最大负载因子。一旦负载因子超过此限制,哈希表就会增加其槽数(例如,通过加倍),然后将旧表中的元素重新分配到新表中。这称为重新散列。假设表中的最大负载因子是一个常数,这确保了,假设你有一个好的散列函数,进行查找的预期成本仍然是 O(1)。由于定期重建表的成本,插入现在有一个摊销的预期成本,就像删除一样。 (如果负载因子太小,删除同样可以压缩表。)

              散列策略

              到目前为止,我们一直在讨论链式哈希,这是构建哈希表的许多不同策略之一。提醒一下,链式散列有点像衣服梳妆台 - 每个桶(抽屉)可以容纳多个物品,当您进行查找时,您会检查所有这些物品。

              但是,这不是构建哈希表的唯一方法。还有另一个哈希表系列使用称为open addressing 的策略。开放寻址背后的基本思想是存储一个 slots 数组,其中每个 slot 可以是空的,也可以只容纳一个项目。

              在开放寻址中,当您执行插入操作时,和以前一样,您会跳转到某个插槽,其索引取决于计算的哈希码。如果该插槽是免费的,那就太好了!你把物品放在那里,你就完成了。但是如果插槽已经满了怎么办?在这种情况下,您可以使用一些辅助策略来找到一个不同的空闲槽来存储该项目。执行此操作的最常见策略使用称为linear probing 的方法。在线性探测中,如果您想要的插槽已满,您只需转移到表中的下一个插槽。如果那个插槽是空的,太好了!你可以把物品放在那里。但是,如果该槽已满,则您将移至表中的下一个槽,依此类推(如果您到达表的末尾,则返回到开头)。

              线性探测是构建哈希表的一种非常快速的方法。 CPU 缓存针对locality of reference 进行了优化,因此在相邻内存位置的内存查找往往比在分散位置的内存查找要快得多。由于线性探测插入或删除是通过命中某个数组槽然后线性向前移动来工作的,因此它会导致很少的缓存未命中,并且最终会比理论通常预测的要快得多。 (而且理论预测它会非常快!)

              最近流行的另一种策略是cuckoo hashing。我喜欢将杜鹃散列视为散列表的“冻结”。我们有两个哈希表和两个哈希函数,而不是一个哈希表和一个哈希函数。每个项目都可以恰好位于两个位置中的一个 - 它要么位于第一个哈希函数给出的第一个表中的位置,要么位于第二个哈希函数给出的第二个表中的位置。这意味着查找是最坏情况高效的,因为您只需检查两个位置即可查看表中是否有内容。

              杜鹃散列中的插入使用与以前不同的策略。我们首先查看可以容纳该项目的两个插槽中的任何一个是否空闲。如果是这样,太好了!我们只是把物品放在那里。但如果这不起作用,那么我们选择一个插槽,将项目放在那里,然后踢出曾经在那里的项目。该物品必须放在某个地方,因此我们尝试将其放在另一张桌子的适当位置。如果这行得通,那就太好了!如果没有,我们将一个项目踢出 那个 表并尝试将其插入到另一个表中。这个过程一直持续到一切都平静下来,或者我们发现自己陷入了一个循环。 (后一种情况很少见,如果发生这种情况,我们有很多选择,例如“将其放入辅助哈希表”或“选择新的哈希函数并重建表。”)

              cuckoo hashing 有许多改进的可能,例如使用多个表,让每个插槽容纳多个项目,以及制作一个“存储”来存放其他任何地方都无法容纳的项目,这是一个活跃的研究领域!

              还有混合方法。 Hopscotch hashing 是开放寻址和链式哈希之间的混合,可以认为是采用链式哈希表并将每个存储桶中的每个项目存储在项目想要去的位置附近的插槽中。这种策略与多线程配合得很好。 Swiss table 利用了一些处理器可以用一条指令并行执行多个操作的事实来加速线性探测表。 Extendible hashing 专为数据库和文件系统而设计,并使用 trie 和链式哈希表的混合来在加载单个存储桶时动态增加存储桶大小。 Robin Hood hashing 是线性探测的一种变体,其中项目可以在插入后移动,以减少每个元素可以居住的距离的差异。

              进一步阅读

              有关哈希表基础知识的更多信息,请查看these lecture slides on chained hashingthese follow-up slides on linear probing and Robin Hood hashing。您可以了解更多关于cuckoo hashing heretheoretical properties of hash functions here 的信息。

              【讨论】:

                【解决方案14】:

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

                【讨论】:

                  【解决方案15】:

                  直接地址表

                  要理解哈希表,直接地址表是我们首先要理解的概念。

                  直接地址表直接使用键作为数组中槽的索引。 Universe 键的大小等于数组的大小。由于数组支持随机访问操作,因此在 O(1) 时间内访问此密钥非常快。

                  但是,在实现直接地址表之前有四个注意事项:

                  1. 要成为有效的数组索引,键应该是整数
                  2. 键的范围相当小,否则,我们将需要一个巨大的数组。
                  3. 没有两个不同的键映射到数组中的同一个槽
                  4. Universe 键的长度等于数组的长度

                  事实上,现实生活中并不是很多情况都符合上述要求,所以哈希表来救场

                  哈希表

                  哈希表不是直接使用键,而是首先应用数学哈希函数将任意键数据一致地转换为数字,然后使用该哈希结果作为键。

                  Universe 键的长度可以大于数组的长度,这意味着两个不同的键可以散列到同一个索引(称为散列冲突)?

                  实际上,有几种不同的策略来处理它。这是一个常见的解决方案:我们不是将实际值存储在数组中,而是存储一个指向链表的指针,该链表包含哈希到该索引的所有键的值。

                  如果您仍然有兴趣了解如何从头开始实现 hashmap,请阅读following post

                  【讨论】:

                    【解决方案16】:

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

                    (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() 是散列函数,所有唯一性魔法都必须在其中发生。

                    经验法则是: 对于要插入的给定值,存储桶必须是唯一的并且可以从它应该存储的值中派生出来。

                    Bucket 是存储值的任何空间 - 因为在这里我将它保存为 int 作为数组索引,但它也可能是一个内存位置。

                    【讨论】:

                    • “经验法则是:对于要插入的给定值,存储桶必须是唯一的并且可以从它应该存储的值中派生出来。” - 这描述了一个perfect hash function,这通常只适用于编译时已知的几百或几千个值。大多数哈希表必须处理冲突。此外,哈希表倾向于为所有桶分配空间,无论它们是否为空,而您的伪代码在插入新键期间记录了 create_extra_space_for_bucket() 步骤。桶可能是指针。
                    【解决方案17】:

                    里面的哈希表包含了存储密钥集的罐子。哈希表使用哈希码来决定密钥对应该计划到哪个。从 Key 的 hashcode 中获取容器区域的能力称为 hash work。原则上,散列工作是一种容量,当给定一个键时,它会在表中创建一个地址。哈希工作始终返回项目的数字。两个等效的项目将始终具有相似的数字,而两个不一致的对象通常可能不会具有不同的数字。当我们将对象放入哈希表时,可以想象各种对象可能具有相同/相同的哈希码。这被称为碰撞。为了确定冲突,哈希表使用了多种列表。映射到单个数组索引的集合存储在列表中,然后列表引用存储在索引中。

                    【讨论】:

                    • 欢迎来到 Stack Overflow。这里已经有 16 个答案,其中一些非常详细,得到了高度评​​价。这个答案是否改进了已经存在的内容?请阅读How to Answer
                    猜你喜欢
                    • 1970-01-01
                    • 2012-01-03
                    • 1970-01-01
                    • 2010-10-07
                    • 2011-02-07
                    • 2011-05-26
                    • 2018-09-25
                    • 1970-01-01
                    相关资源
                    最近更新 更多