我正在寻找关于哈希表如何工作的解释 - 用像我这样的傻瓜的简单英语!
例如,我知道它需要密钥,计算哈希(我正在寻找解释如何),然后执行某种模数来计算它存储在存储值的数组中的位置,但那就是我的知识停止了。
有人可以澄清这个过程吗?
编辑:我并没有具体询问哈希码的计算方法,而是概述了哈希表的工作原理。
答案 0 :(得分:879)
答案 1 :(得分:94)
用法和Lingo:
真实世界的例子:
Hash& Co。成立于1803年,缺乏任何计算机技术,总共有300个文件柜,可以为大约30,000个客户保留详细信息(记录)。每个文件夹都清楚地标明了其客户编号,一个从0到29,999的唯一编号。
当时的备案员必须快速获取并存储工作人员的客户记录。工作人员已经决定使用散列方法来存储和检索他们的记录会更有效率。
要提交客户记录,归档文员将使用写在该文件夹上的唯一客户编号。使用此客户端编号,他们会将哈希密钥调制为300,以便识别其所包含的文件柜。当他们打开文件柜时,他们会发现它包含许多按客户编号排序的文件夹。在确定了正确的位置后,他们就会把它放进去。
要检索客户记录,归档文员将在一张纸上给出客户编号。使用这个唯一的客户端编号(哈希密钥),他们会将其调制为300,以确定哪个文件柜具有客户端文件夹。当他们打开文件柜时,他们会发现它包含许多按客户编号排序的文件夹。通过搜索记录,他们可以快速找到客户端文件夹并检索它。
在我们的实际示例中,我们的存储桶是文件柜,我们的记录是文件夹。
要记住的一件重要事情是计算机(及其算法)处理数字比使用字符串更好。因此,使用索引访问大型数组比顺序访问要快得多。
正如西蒙提到的我认为非常重要是散列部分是变换大空间(任意长度,通常是字符串等)和将其映射到一个小空间(已知大小,通常是数字)以进行索引。这非常重要,要记住!
因此,在上面的示例中,30,000个可能的客户端映射到较小的空间。
这样做的主要思想是将整个数据集划分为多个段,以加快实际搜索速度,这通常非常耗时。在上面的示例中,300个文件柜中的每一个(统计上)将包含大约100条记录。通过100条记录搜索(无论顺序)要比处理30,000条记录要快得多。
你可能已经注意到有些人已经这样做了。但是,它们不是设计散列方法来生成散列键,而是在大多数情况下只使用姓氏的第一个字母。因此,如果您有26个文件柜,每个文件柜都包含从A到Z的字母,理论上您只需要对数据进行细分并增强文件归档和检索过程。
希望这有帮助,
Jeach!
答案 2 :(得分:64)
事实证明这是一个非常深刻的理论领域,但基本概要很简单。
本质上,哈希函数只是一个从一个空间(比如任意长度的字符串)中获取东西的函数,并将它们映射到一个对索引有用的空间(比如说无符号整数)。
如果你只有很小的空间可以散列,那么你可能只需要将这些东西解释为整数,然后你就完成了(例如4字节字符串)
但通常,你有更大的空间。如果你允许作为键的东西的空间大于你用来索引的东西的空间(你的uint32或其他什么)那么你不可能为每个东西都有一个唯一的值。当两个或两个以上的东西散列到相同的结果时,你将必须以适当的方式处理冗余(这通常被称为冲突,你如何处理它或不依赖于你是什么使用哈希)。
这意味着你希望它不太可能有相同的结果,你可能也希望哈希函数能够快速。
平衡这两个属性(以及其他一些属性)让很多人忙碌起来!
在实践中,您通常应该能够找到一个已知适合您的应用程序并使用它的函数。
现在让它作为哈希表工作:想象一下你不关心内存使用情况。然后,只要您的索引集(例如,所有uint32),您就可以创建一个数组。当您向表中添加内容时,您将其哈希键并查看该索引处的数组。如果那里什么也没有,你就把价值放在那里。如果已经存在某些内容,则将此新条目添加到该地址的事物列表中,以及足够的信息(您的原始密钥或其他聪明的东西),以查找哪个条目实际属于哪个密钥。
因此,当你走了很长时间时,哈希表(数组)中的每个条目都是空的,或者包含一个条目或条目列表。检索是一个简单的索引到数组,并返回值,或走过值列表并返回正确的值。
当然在练习中你通常不能这样做,浪费了太多的记忆。因此,您可以根据稀疏数组执行所有操作(其中唯一的条目是您实际使用的条目,其他所有条目都隐式为null)。
有很多方案和技巧可以使这项工作更好,但这是基础。
答案 3 :(得分:39)
很多答案,但它们都不是 visual ,哈希表可以很容易地点击"在可视化时。
哈希表通常实现为链表的数组。如果我们想象一个存储人名的表,在几次插入后,它可能会在内存中布局如下,其中()
- 附上的数字是文本/名称的哈希值。
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]
...)被称为 存储桶 ,并启动 - 可能空 - 链接的值列表(又名元素,在此示例中 - 人们名称)"fred"
带有哈希42
)都是从广告符[hash % number_of_buckets]
链接的,例如42 % 10 == [2]
; %
是modulo operator - 余数除以桶数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.58)或1亿个元素和1亿个桶),这就是为什么我们说查找/插入/擦除是O(1)恒定时间操作。
给定如上所述的哈希表实现,我们可以想象创建一个值类型,如struct Value { string name; int age; };
,以及仅查看name
字段(忽略年龄)的相等比较和哈希函数,以及然后会发生一些奇妙的事情:我们可以在表格中存储Value
等{"sue", 63}
条记录,然后搜索" sue"不知道她的年龄,找到储值,恢复甚至更新她的年龄
- 生日快乐苏 - 有趣地没有改变哈希值,因此我们不要求将苏的记录移到另一个桶中。
当我们这样做时,我们将哈希表用作associative container aka map,并且它存储的值可以被视为包含键(名称)和一个或多个其他字段仍被称为 - 令人困惑 - 值(在我的示例中,只是年龄)。用作映射的哈希表实现称为哈希映射。
这与本答案前面的例子形成了鲜明的对比,我们存储了离散值,例如" sue",您可以将其视为自己的密钥:这种用法称为哈希设置。
并非所有哈希表都使用链接列表(称为separate chaining),但大多数通用哈希表都使用链接列表closed hashing (aka open addressing)作为主要替代{{3}} - 特别是支持擦除操作 - 具有较低的稳定性能属性-prone keys / hash functions。
通用的,最坏情况下的冲突最小化哈希函数的工作是有效地随机地在哈希表桶周围喷射密钥,同时总是为相同的密钥生成相同的哈希值。即使在密钥中任何地方改变一位,理想情况下 - 随机 - 在结果哈希值中翻转大约一半的比特。
通常情况下,数学太复杂,我不能理解。我将提到一种易于理解的方式 - 不是最具扩展性或缓存友好性但本质上优雅(如使用一次性密码加密!) - 因为我认为它有助于将上述所需的品质带回家。假设您正在散列64位double
- 您可以创建8个表,每个256个随机数(下面的代码),然后使用double
&#39的每个8位/ 1字节切片; s内存表示索引到不同的表,对您查找的随机数进行异或。通过这种方法,很容易看出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]];
许多图书馆'散列函数通过未更改的整数传递整数(称为普通或 身份 散列函数);它是上述强烈散列的另一个极端。在最糟糕的情况下,身份哈希是非常冲突,但希望是在相当普遍的整数键的情况下,往往会递增(可能有一些间隙),他们会映射进入连续的桶中,留下的空白比随机的散列叶子少(我们在前面提到的载荷因子1下约为36.8%),因此与随机映射相比,碰撞元素的冲突更少,链接列表的链接列表更少。保存生成强哈希所需的时间也很棒,并且如果按顺序查找密钥,则可以在内存中附近的桶中找到密钥,从而提高缓存命中率。当密钥不很好地增加时,希望他们是随机的,他们不需要强大的哈希函数来完全随机化他们的位置到桶中。
答案 4 :(得分:24)
你们非常接近完全解释这一点,但遗漏了一些事情。哈希表只是一个数组。数组本身将在每个插槽中包含一些内容。您至少会将哈希值或值本身存储在此插槽中。除此之外,您还可以存储已在此插槽上发生冲突的链接/链接值列表,或者您可以使用开放寻址方法。您还可以存储指向要从此插槽中检索的其他数据的指针或指针。
重要的是要注意,hashvalue本身通常不指示放置值的槽。例如,hashvalue可能是负整数值。显然负数不能指向数组位置。此外,哈希值往往会比可用的时隙数量大很多倍。因此,需要由散列表本身执行另一个计算,以确定该值应该进入哪个槽。这是通过模数运算完成的,如:
uint slotIndex = hashValue % hashTableSize;
此值是值将进入的插槽。在开放寻址中,如果插槽已经填充了另一个哈希值和/或其他数据,则模数操作将再次运行以查找下一个插槽:
slotIndex = (remainder + 1) % hashTableSize;
我想可能还有其他更先进的方法来确定广告位索引,但这是我见过的常见方法...会对其他表现更好的广告感兴趣。
使用模数方法,如果您有一个大小为1000的表,则任何介于1和1000之间的哈希值将进入相应的槽。任何负值以及任何大于1000的值都可能会碰撞插槽值。发生这种情况的可能性取决于您的散列方法,以及您添加到散列表的总项数。通常,最佳做法是使散列表的大小使得添加到其中的值的总数仅等于其大小的大约70%。如果您的哈希函数在均匀分布方面做得很好,您通常会遇到很少甚至没有桶/槽冲突,并且它将对查找和写入操作执行得非常快。如果要提前知道要添加的值的总数,请使用任何方法进行良好的估计,然后在添加到其中的元素数量达到容量的70%时调整哈希表的大小。
我希望这有所帮助。
PS - 在C#中,GetHashCode()
方法非常慢,并且在我测试的很多条件下导致实际值冲突。为了一些真正的乐趣,构建自己的哈希函数并尝试使其永远不会碰撞您正在散列的特定数据,运行速度比GetHashCode快,并且具有相当均匀的分布。我使用long而不是int size hashcode值完成了这项工作,并且它在哈希表中有多达3200万个哈希值,并且有0次冲突。不幸的是,我无法共享代码,因为它属于我的雇主...但我可以透露它可能是某些数据域。当你可以实现这一点时,哈希表非常快。 :)
答案 5 :(得分:17)
这就是我理解的方式:
以下是一个示例:将整个表格描绘为一系列存储桶。假设您有一个带字母数字哈希码的实现,并且每个字母表的字母都有一个存储桶。此实现将其哈希码以特定字母开头的每个项目放在相应的存储桶中。
假设您有200个对象,但其中只有15个具有以字母“B”开头的哈希码。哈希表只需要查找并搜索“B”桶中的15个对象,而不是所有200个对象。
就计算哈希码而言,没有任何神奇之处。目标只是让不同的对象返回不同的代码,并使相同的对象返回相等的代码。您可以编写一个总是返回与所有实例的哈希代码相同的整数的类,但是您实际上会破坏哈希表的有用性,因为它只会成为一个巨大的桶。
答案 6 :(得分:12)
短而甜蜜:
哈希表包装一个数组,我们称之为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%的容量时,我们可以调整内部数组的大小,并将旧数组中的所有项目移动到新数组中。
答案 7 :(得分:10)
它比那更简单。
哈希表只不过是一个包含键/值对的数组(通常是sparse个)。此数组的最大大小通常小于哈希表中存储的数据类型的可能值集中的项目数。
哈希算法用于根据将存储在数组中的项的值生成该数组的索引。
这是存储数组中键/值对的向量的地方。因为数组中可以作为索引的值集合通常小于该类型可能具有的所有可能值的数量,所以它是可能的您的哈希算法将为两个单独的键生成相同的值。一个良好的哈希算法将尽可能地防止这种情况(这就是为什么它通常因为它具有一般哈希算法不可能知道的特定信息而降级到该类型的原因),但它是不可能的防止。
因此,您可以拥有多个生成相同哈希码的密钥。当发生这种情况时,迭代向量中的项目,并在向量中的键和正在查找的键之间进行直接比较。如果找到了,那么很好,并且返回与该键相关联的值,否则,不返回任何内容。
答案 8 :(得分:9)
你需要一堆东西和一个数组。
对于每一件事,你构成一个索引,称为哈希。哈希的重要之处在于它散布了很多东西;你不希望两个类似的东西有类似的哈希。
你把你的东西放在哈希指示的位置的数组中。在给定的散列中不止一件事可以结束,所以你将这些东西存储在数组或其他合适的东西中,我们通常将其称为存储桶。
当您在哈希中查找内容时,您将执行相同的步骤,找出哈希值,然后查看该位置的存储桶中的内容并检查它是否是您要查找的内容。
当您的散列运行良好并且您的数组足够大时,阵列中的任何特定索引最多只会有一些内容,因此您不必非常关注。
对于奖励积分,请将其设置为当访问哈希表时,它会将找到的东西(如果有的话)移动到桶的开头,因此下次检查时会发生第一件事。
答案 9 :(得分:3)
到目前为止,所有的答案都很好,并且可以了解哈希表如何工作的不同方面。这是一个可能有用的简单示例。假设我们希望将一些带有小写字母字符串的项目存储为键。
正如西蒙所解释的,哈希函数用于从大空间映射到小空间。对于我们的示例,哈希函数的简单,天真的实现可以取字符串的第一个字母,并将其映射到整数,因此“alligator”的哈希码为0,“bee”的哈希码为1,“斑马“将是25,等等。
接下来我们有一个包含26个桶的数组(可能是Java中的ArrayLists),我们将该项放在与我们的密钥的哈希码匹配的存储桶中。如果我们有多个项目的密钥以相同的字母开头,那么它们将具有相同的哈希码,因此所有哈希代码都会在桶中进行,因此必须在桶中进行线性搜索找到一个特定的项目。
在我们的示例中,如果我们只有几十个项目,其中的键位于字母表中,那么它将非常有效。但是,如果我们有一百万个项目或所有键都以'a'或'b'开头,那么我们的哈希表就不太理想了。为了获得更好的性能,我们需要一个不同的散列函数和/或更多的桶。
答案 10 :(得分:3)
这是另一种看待它的方式。
我假设你理解了数组A的概念。这是支持索引操作的东西,你可以在一步中找到Ith元素A [I],无论A有多大。
因此,举例来说,如果你想存储一组人都有不同年龄的信息,一个简单的方法就是拥有一个足够大的数组,并将每个人的年龄作为索引数组。坦率地说,你可以一步访问任何人的信息。
但是当然可能有不止一个年龄相同的人,所以你在每个条目中放入数组的是所有具有该年龄的人的列表。因此,您可以一步到达一个人的信息,再加上该列表中的一点点搜索(称为“桶”)。如果有这么多人让水桶变大,它只会减慢速度。然后你需要一个更大的数组,以及其他一些方法来获得更多关于这个人的识别信息,比如姓氏的前几个字母,而不是使用年龄。
这是基本的想法。可以使用产生良好价值传播的人的任何功能,而不是使用年龄。这是哈希函数。就像你可以采用该人名的ASCII表示的每三位一样,按某种顺序加扰。重要的是你不要让太多人哈希到同一个桶,因为速度取决于剩余的小桶。
答案 11 :(得分:2)
如何计算哈希值通常不依赖于哈希表,而是依赖于添加到哈希表的项。在诸如.net和Java的框架/基类库中,每个对象都有一个GetHashCode()(或类似)方法,返回该对象的哈希码。理想的哈希码算法和确切的实现取决于对象中表示的数据。
答案 12 :(得分:2)
哈希表完全依赖于实际计算遵循随机访问机器模型的事实,即在O(1)时间或恒定时间内可以访问存储器中任何地址的值。
所以,如果我有一个密钥世界(我可以在应用程序中使用的所有可能密钥的集合,例如,对于学生来说是滚动编号,如果它是4位数,则此Universe是从1到9999的一组数字) ,以及将它们映射到有限数量的大小的方法我可以在我的系统中分配内存,理论上我的哈希表已准备就绪。
通常,在应用程序中,密钥的大小非常大于我想要添加到哈希表的元素的数量(我不想浪费1 GB的内存来哈希,比如10000或100000个整数值,因为它们在二进制复制中是32位长。所以,我们使用这个散列。这是一种混合类型的“数学”操作,它将我的大宇宙映射到一小部分值,我可以在记忆中容纳它们。在实际情况中,散列表的空间通常与(元素的数量*每个元素的大小)具有相同的“顺序”(big-O),因此,我们不会浪费太多内存。
现在,一个大型集映射到一个小集,映射必须是多对一的。因此,不同的密钥将被分配到相同的空间(不公平)。有几种方法可以解决这个问题,我只知道其中流行的两种方法:
CLRS算法简介提供了对该主题的非常好的见解。
答案 13 :(得分:1)
为什么人们使用梳妆台来存放衣服?除了看起来时髦和时尚外,它们还具有每件衣服都应有的位置的优点。如果您正在寻找一双袜子,只需检查袜子抽屉即可。如果您正在寻找衬衫,请检查其中装有衬衫的抽屉。没关系,当您寻找袜子时,您拥有多少件衬衫或您拥有多少条裤子,因为您无需查看它们。您只要看一看袜子抽屉,就可以在那里找到袜子。
从高层次上讲,哈希表是一种存储东西的方式(有点像),像梳妆台一样。基本思想如下:
这样的系统的优点是,假设您的规则不是太复杂,并且您有适当数量的抽屉,那么只需在正确的位置查看即可快速找到所需的内容。< / p>
放下衣服时,使用的“规则”可能类似于“袜子放在左上方的抽屉里,衬衫放在大的中间抽屉里,等等”。但是,当您存储更多抽象数据时,我们将使用称为 hash函数 的功能来为我们完成此操作。
考虑哈希函数的一种合理方法是将其作为黑盒。您将数据放在一边,而从另一面冒出一个叫做 hash code 的数字。从示意图上看,它是这样的:
+---------+
|\| hash |/| --> hash code
data --> |/| function|\|
+---------+
所有哈希函数都是 确定性 :如果将相同的数据多次放入该函数中,则总是可以得到相同的值。一个好的哈希函数应该或多或少地看起来是随机的:对输入数据的微小更改应给出完全不同的哈希码。例如,字符串"pudu"和字符串"kudu"的哈希码可能彼此之间大不相同。 (然后,它们可能是相同的。毕竟,如果哈希函数的输出看起来或多或少是随机的,则有可能我们两次获得相同的哈希码。)
您究竟如何构建哈希函数?现在,让我们来谈谈“体面的人不应对此考虑太多”。数学家已经找到了设计哈希函数的好坏方法,但是出于我们的目的,我们实际上并不需要过多地担心内部问题。仅仅将散列函数视为具有以下功能的函数
一旦有了哈希函数,我们就可以构建一个非常简单的哈希表。我们将制作一系列“桶”,您可以认为它们类似于我们梳妆台中的抽屉。要将项目存储在哈希表中,我们将计算对象的哈希码并将其用作表中的索引,类似于“选择该项目进入哪个抽屉”。然后,我们将该数据项放在该索引的存储桶中。如果那个桶是空的,那就太好了!我们可以把物品放在那里。如果该存储桶已满,我们可以选择一些可以做的事情。一种简单的方法(称为chained hashing)是将每个存储桶都视为项目列表,就像袜子抽屉可以存储多只袜子一样,然后将项目添加到该索引处的列表中。
要在哈希表中查找内容,我们使用基本上相同的过程。我们首先计算要查找的项目的哈希码,这告诉我们要查找哪个存储桶(抽屉)。如果该项目在表中,则它必须在该存储桶中。然后,我们只需要查看存储桶中的所有项目,看看我们的项目是否在其中。
以这种方式做事有什么好处?好吧,假设我们有大量的存储桶,我们希望大多数存储桶中不会有太多东西。毕竟,我们的哈希函数有点儿排序看起来像是具有随机输出,因此这些项有点儿排序均匀地分布在所有存储桶中。实际上,如果我们将“我们的散列函数看起来有点随机”的概念形式化,我们可以证明每个存储桶中的预期项目数是项目总数与存储桶总数的比率。因此,我们无需进行太多工作即可找到所需的物品。
解释“哈希表”的工作原理有些棘手,因为哈希表有很多种。下一节将讨论所有哈希表共有的一些常规实现细节,以及有关不同样式的哈希表如何工作的一些细节。
出现的第一个问题是如何将哈希码转换为表槽索引。在上面的讨论中,我只是说过“使用哈希码作为索引”,但这实际上不是一个好主意。在大多数编程语言中,哈希码可计算为32位或64位整数,您将无法直接将其用作存储区索引。相反,一种常见的策略是创建一个大小为m的存储桶数组,为您的项目计算(完整的32位或64位)哈希码,然后根据表的大小对其进行修改,以使索引介于0和m-1(含)。模数的使用在这里效果很好,因为它的速度相当快,并且可以将散列码的整个范围散布在较小的范围内。
((您有时会在这里看到按位运算符。如果表的大小是2的幂,例如2 k ,则计算哈希码的按位与,然后再乘以2 < sup> k -1等于计算模数,并且速度更快。)
下一个问题是如何选择正确数量的桶。如果您选择了太多的存储桶,那么大多数存储桶将是空的或元素很少(有利于提高速度-您只需要检查每个存储桶中的一些物品),但是您将使用大量空间来简单地存储存储桶(不是这样)很好,尽管也许您负担得起)。另一面也适用-如果存储桶太少,则平均每个存储桶中会有更多元素,从而使查找花费的时间更长,但使用的内存更少。
一个不错的折衷方案是在哈希表的生存期内动态更改存储桶数。哈希表的 负载因子 (通常表示为α)是元素数与存储桶数之比。大多数哈希表会选择一些最大负载因子。一旦负载因子超过此限制,哈希表将增加其插槽数量(例如,通过加倍),然后将元素从旧表重新分配到新表中。这称为 hashing 。假设表中的最大负载因子是一个常数,这可以确保在假设您具有良好的哈希函数的情况下,执行查找的预期成本仍为O(1)。现在,插入会产生O(1)的摊销预期成本,因为删除表会定期重建表,因此需要付出一定的代价。 (如果负载系数太小,删除也可以压缩表。)
到目前为止,我们一直在讨论链式哈希,这是构建哈希表的许多不同策略之一。提醒一下,链式哈希排序有点像服装梳妆台-每个存储桶(抽屉)可以容纳多个项目,并且在执行查找时会检查所有这些项目。
但是,这不是构建哈希表的唯一方法。散列表的另一家族使用称为open addressing的策略。开放寻址的基本思想是存储一个 插槽 数组,其中每个插槽可以为空或仅容纳一个项目。
在开放式寻址中,如前所述,当您执行插入操作时,您将跳转到其索引取决于所计算的哈希码的某个插槽。如果该插槽是免费的,那就太好了!您将项目放在这里,就完成了。但是,如果插槽已满怎么办?在这种情况下,您可以使用一些辅助策略来找到另一个用于存储项目的空闲插槽。为此,最常见的策略是使用称为linear probing的方法。在线性探测中,如果所需的插槽已满,则只需移至表中的下一个插槽即可。如果该插槽为空,那就太好了!您可以将物品放在那里。但是,如果该插槽已满,则可以移动到表格中的下一个插槽,依此类推。(如果您碰到表格的末尾,只需回头就可以了)。
线性探测是一种构建哈希表的快速方法。 CPU缓存针对locality of reference进行了优化,因此相邻内存位置中的内存查找往往比分散位置中的内存查找快得多。由于线性探测插入或删除是通过击中某个阵列插槽然后线性向前移动来进行的,因此它导致很少的高速缓存未命中,并且最终比理论上通常的预测要快得多。 (碰巧这种情况是理论预测它将会非常快!)
最近流行的另一种策略是cuckoo hashing。我喜欢将杜鹃哈希视为哈希表的“冻结”。我们没有两个哈希表和两个哈希函数,而没有一个哈希表和一个哈希函数。每个项目都可以恰好位于两个位置之一中-位于第一个哈希函数给定的第一个表中的位置,或者位于第二个哈希函数给定的第二个表中的位置。这意味着查找是最坏情况的效率,因为您只需检查两个位置即可查看表中是否有东西。
布谷鸟哈希中的插入使用与以前不同的策略。我们首先查看两个可以容纳该物品的插槽是否空闲。如果是这样,那就太好了!我们只是把物品放在那里。但是,如果那行不通,那么我们选择一个插槽,将其放到那里,然后踢出以前在那里的物品。该项目必须放在某个地方,因此我们尝试将其放在另一个表中的适当位置。如果可行,那就太好了!如果不是,我们从那个表中踢出一个项目,然后尝试将其插入另一个表中。这个过程一直持续到一切都变得静止,或者我们陷入循环之中。 (后一种情况很少见,如果发生这种情况,我们有很多选择,例如“将其放入辅助哈希表”或“选择新的哈希函数并重建表”。)
布谷鸟哈希有很多可能的改进,例如使用多个表,让每个插槽容纳多个项目以及制作“储藏物”来容纳其他地方无法容纳的物品,这是一个活跃的研究领域!
然后有混合方法。 Hopscotch hashing是开放式寻址和链式哈希之间的混合,可以将其视为采用链式哈希表,并将每个项目存储在每个存储桶中项目要存放的位置附近的插槽中。此策略与多线程配合使用。 Swiss table使用以下事实:某些处理器可以用一条指令并行执行多个操作,以加快线性探测表的速度。 Extendible hashing专为数据库和文件系统而设计,并结合使用Trie和链式哈希表来在加载各个存储桶时动态增加存储桶大小。 Robin Hood hashing是线性探测的一种变体,可以在插入物品后移动它们,以减少每个元素可以居住的距离。
有关哈希表基础的更多信息,请查看these lecture slides on chained hashing和these follow-up slides on linear probing and Robin Hood hashing。您可以了解有关cuckoo hashing here和theoretical properties of hash functions here的更多信息。
答案 14 :(得分: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()
是散列函数,其中必须发生所有唯一性魔法。
经验法则是: 对于要插入的给定值,存储桶必须是UNIQUE&amp;可以从它应该存储的价值中获得。
Bucket是存储值的任何空间 - 对于这里我将int保存为数组索引,但它也可能是一个内存位置。
答案 15 :(得分:0)
直接地址表直接使用键作为数组中槽的索引。 Universe 键的大小等于数组的大小。因为数组支持随机访问操作,所以在 O(1) 时间内访问这个密钥真的很快。
但是,在实现直接地址表之前有四个注意事项:
事实上,现实生活中符合上述要求的情况并不多,所以哈希表就派上用场了
哈希表不是直接使用键,而是首先应用数学哈希函数将任意键数据一致地转换为数字,然后使用该哈希结果作为键。
Universe 键的长度可以大于数组的长度,这意味着可以将两个不同的键散列到同一个索引(称为散列冲突)?
实际上,有几种不同的策略来处理它。这是一个常见的解决方案:我们不将实际值存储在数组中,而是存储一个指向链表的指针,该链表包含散列到该索引的所有键的值。
如果您仍然有兴趣了解如何从头开始实现哈希图,请阅读 following post