我正在寻找有关哈希表如何工作的解释-用朴素的英语代表像我这样的简单人!
例如,我知道它需要键,计算哈希值(我正在寻找一种解释方式),然后执行某种模运算以求出它在存储值的数组中的位置,但是这就是我的知识所在。
谁能澄清这个过程?
编辑:我不是专门询问哈希码的计算方式,而是关于哈希表工作原理的一般概述。
我正在寻找有关哈希表如何工作的解释-用朴素的英语代表像我这样的简单人!
例如,我知道它需要键,计算哈希值(我正在寻找一种解释方式),然后执行某种模运算以求出它在存储值的数组中的位置,但是这就是我的知识所在。
谁能澄清这个过程?
编辑:我不是专门询问哈希码的计算方式,而是关于哈希表工作原理的一般概述。
Answers:
这是外行的解释。
假设您想在图书馆中装满书籍,而不仅仅是将它们装在其中,而且还希望能够在需要时轻松地再次找到它们。
因此,您决定,如果想读书的人知道这本书的书名和要启动的确切书名,那么这便是全部。有了书名,这个人在图书馆员的帮助下应该能够轻松,快速地找到这本书。
那么,你该怎么做呢?好吧,很明显,您可以保留每本书放置位置的某种列表,但是然后您遇到了与搜索图书馆相同的问题,您需要搜索列表。当然,列表会更小并且更易于搜索,但是您仍然不想从库(或列表)的一端到另一端依次搜索。
您想要的是带有书名的内容,可以一次为您提供正确的位置,因此您所要做的就是漫步到正确的书架上,然后拿起书。
但是那怎么办呢?好吧,在填充库时需要一些周全的考虑,而在填充库时需要做很多工作。
您可以设计一个聪明的小方法,而不仅仅是开始从一端到另一端填充库。您拿起这本书的书名,通过一个小的计算机程序运行它,这会吐出书架号和该书架上的插槽号。这是放置书的地方。
该程序的优点在于,以后,当有人返回阅读本书时,您可以再次通过该程序输入书名,并获得与最初给定的相同的架子号和插槽号。这本书所在的位置。
正如其他人已经提到的那样,该程序称为哈希算法或哈希计算,通常通过获取输入的数据(在本例中为书名)并从中计算出一个数字来工作。
为了简单起见,假设它只是将每个字母和符号转换为数字并将它们加起来。实际上,它要比这复杂得多,但是现在让我们保留它。
这种算法的优点在于,如果您一次又一次地向相同的输入输入相同的输入,则每次都会不断吐出相同的数字。
好的,基本上就是哈希表的工作方式。
技术内容如下。
首先,是数字的大小。通常,此类哈希算法的输出在一定范围内,通常大于表中的空间。例如,假设我们在图书馆中可以容纳一百万本书。哈希计算的输出可能在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号插槽中。
这导致下一个问题。碰撞。由于该算法无法将书隔开,因此它们无法准确地填满库(如果需要的话,也可以填入哈希表),因此它总是会计算出以前使用过的数字。从图书馆的角度来说,当您到达要放入书架的书架和插槽编号时,那里已经有一本书。
存在各种冲突处理方法,包括将数据运行到另一个计算中以获取表中的另一个位置(双哈希),或者只是找到与您所给定的空间接近的空间(即紧挨着上一本书的位置,假定有插槽)可用,也称为线性探测)。这意味着您稍后尝试查找该书时,需要做一些挖掘工作,但这仍然比仅从库的一端开始要好。
最后,在某个时候,您可能想将超出图书馆允许数量的书籍放入图书馆。换句话说,您需要构建一个更大的库。由于库中的确切位置是使用库的当前大小来计算的,因此,如果您调整库的大小,则可能最终不得不为所有书籍找到新的位置,因为计算完成后才找到它们的位置已经改变。
我希望这个解释比水桶和功能更扎实:)
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桶是否被“浪费”?有点吧 使用的总内存相同。
用法和术语:
真实示例:
Hash&Co .成立于1803年,缺乏任何计算机技术,共有300个文件柜,可为大约30,000个客户保留详细的信息(记录)。每个文件夹都清楚地标明了其客户编号,客户编号为0到29,999之间的唯一编号。
当时的归档文员必须快速获取并存储工作人员的客户记录。工作人员已决定,使用哈希方法存储和检索他们的记录会更有效。
要提交客户记录,提交文员将使用写在文件夹上的唯一客户编号。他们使用此客户号将哈希密钥调制300,以识别其中包含的文件柜。当他们打开文件柜时,他们会发现其中包含许多按客户号排序的文件夹。确定正确的位置后,他们只需将其滑入。
要检索客户记录,将在纸条上为归档文员提供客户编号。他们将使用此唯一的客户编号(哈希键)将其调制300,以确定哪个文件柜具有客户文件夹。当他们打开文件柜时,他们会发现它包含许多按客户编号排序的文件夹。通过搜索记录,他们将快速找到客户端文件夹并进行检索。
在我们的实际示例中,我们的存储桶是文件柜,我们的记录是文件夹。
要记住的重要一点是,计算机(及其算法)处理数字要比处理字符串更好。因此,使用索引访问大型数组比顺序访问要快得多。
正如Simon提到的那样,我认为非常重要的是,哈希部分是转换大空间(具有任意长度,通常是字符串等)并将其映射到小空间(具有已知大小,通常是数字)以进行索引。要记住这一点非常重要!
因此,在上面的示例中,大约30,000个可能的客户端被映射到较小的空间。
其主要思想是将整个数据集划分为多个段,以加快实际搜索的速度,这通常很耗时。在上面的示例中,每个300个文件柜(统计上)将包含大约100条记录。搜索100条记录(无论顺序如何)比处理30,000条记录要快得多。
您可能已经注意到一些实际已经这样做了。但是,在大多数情况下,他们没有设计一种哈希方法来生成哈希密钥,而是仅使用姓氏的第一个字母。因此,如果您有26个文件柜,每个文件柜都包含一个从A到Z的字母,那么从理论上讲,您已经对数据进行了分段,并增强了归档和检索过程。
希望这可以帮助,
!
100
记录(3万条记录/ 300柜= 100)。可能值得编辑。
事实证明这是一个相当深的理论领域,但基本概述很简单。
本质上,哈希函数只是一个函数,它从一个空间(例如任意长度的字符串)中获取内容,并将其映射到对索引有用的空间(例如,无符号整数)。
如果您只有一小部分要哈希的内容,则可以将这些内容解释为整数就可以了(例如4个字节的字符串)
不过,通常情况下,您会有更大的空间。如果您允许用作键的事物的空间大于用于索引的事物(您的uint32或其他东西)的空间,则每个对象都不可能具有唯一的值。当两个或两个以上的事物散列到相同的结果时,您将不得不以适当的方式处理冗余(这通常称为冲突,而您如何处理它或不这样做将在某种程度上取决于您的身份)使用哈希值)。
这意味着您希望它不太可能具有相同的结果,并且您也可能真的希望哈希函数更快。
平衡这两个属性(以及其他几个属性)使很多人忙!
在实践中,通常应该能够找到一个对您的应用程序有效的函数,然后使用它。
现在将其用作哈希表:想象一下,您并不关心内存使用情况。然后,只要创建索引集,就可以创建一个数组(例如所有uint32的索引集)。在向表中添加内容时,您将其哈希,然后查看该索引处的数组。如果那里什么都没有,那就把价值放在那。如果那里已经有东西,则可以将此新条目添加到该地址的事物列表中,并添加足够的信息(您的原始密钥或一些巧妙的信息)以查找哪个条目实际上属于哪个密钥。
因此,随着时间的流逝,哈希表(数组)中的每个条目都为空,或者包含一个条目或条目列表。检索很简单,就像索引数组一样,要么返回值,要么遍历值列表并返回正确的值。
当然,实际上您通常无法做到这一点,因为它浪费了太多内存。因此,您将基于稀疏数组执行所有操作(其中唯一的条目是您实际使用的条目,其他所有条目隐式为null)。
有很多方案和技巧可以使它更好地工作,但这是基础。
int
键为1的1000稀疏度和4k页面=触摸的大多数页面),以及何时当地址空间充足时,操作系统会有效地处理全0页(因此,所有未使用的存储桶页都不需要后备内存)..
答案很多,但都不是很直观,并且哈希表在可视化时可以轻松地“单击”。
哈希表通常实现为链接列表的数组。如果我们想象一个存储人名的表,则在插入几行后可能会如下所示将其布置在内存中,其中- ()
括起来的数字是文本/名称的哈希值。
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位double
s 进行哈希处理-您可以创建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时为%),因此与随机映射相比,碰撞次数更少,碰撞元素的链接列表更长。节省生成强哈希值所花费的时间也很棒,而且如果对键进行查找以便在内存附近的存储桶中找到它们,则可以改善缓存命中率。当密钥不能很好地递增时,希望它们会足够随机,它们将不需要强大的哈希函数来完全随机化它们在存储桶中的位置。
你们非常接近于完整地解释这一点,但是缺少一些东西。哈希表只是一个数组。数组本身将在每个插槽中包含一些内容。至少您将在此插槽中存储哈希值或值本身。除此之外,您还可以存储在此插槽上发生冲突的值的链接/链表,或者可以使用开放寻址方法。您也可以存储一个指针或指向要从此插槽中检索的其他数据的指针。
重要的是要注意,哈希值本身通常并不指示将值放入的插槽。例如,哈希值可能是负整数值。显然,负数不能指向数组位置。另外,哈希值往往会比可用插槽大很多倍。因此,哈希表本身需要执行另一次计算,以找出该值应放入哪个插槽。这是通过模数数学运算完成的,例如:
uint slotIndex = hashValue % hashTableSize;
该值是该值将进入的插槽。在开放式寻址中,如果该插槽已被另一个哈希值和/或其他数据填充,则将再次运行模数运算以查找下一个插槽:
slotIndex = (remainder + 1) % hashTableSize;
我想可能还有其他更高级的方法来确定插槽索引,但这是我见过的常见方法……对其他性能更好的方法感兴趣。
使用模数方法,如果您有一个大小为1000的表,则介于1到1000之间的任何哈希值都将进入相应的插槽。任何负值以及任何大于1000的值都可能是冲突的插槽值。发生这种情况的可能性既取决于您的哈希方法,又取决于您添加到哈希表中的项目总数。通常,最佳实践是使散列表的大小使得添加到其上的值的总数仅等于其大小的大约70%。如果您的散列函数在均匀分布方面做得很好,那么通常您将遇到很少甚至没有桶/槽冲突,并且对于查找和写入操作,它将非常快速地执行。如果预先不知道要添加的值的总数,请使用任何手段进行合理估计,
希望对您有所帮助。
PS-在C#中,该GetHashCode()
方法相当慢,并且在我测试过的许多条件下都会导致实际值冲突。为了获得一些真正的乐趣,请构建自己的哈希函数,并尝试使其永远不会与正在哈希的特定数据发生冲突,比GetHashCode运行得更快,并且分布相当均匀。我使用long而不是int大小的哈希码值来完成此操作,并且它在哈希表中最多0冲突的情况下,最多可处理3200万个整个哈希值。不幸的是,我无法共享代码,因为它属于我的雇主……但是我可以透露某些数据域是可能的。当您可以实现此目标时,哈希表非常快。:)
remainder
指的是原始模运算的结果,我们将其加1以便找到下一个可用插槽。
long
哈希值的论述暗示您已经实现了这一点),但是要确保在不执行mod /%操作之后它们不会在哈希表中发生冲突(通常情况下) )。
简短而甜美:
哈希表包装了一个数组,让其称为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%的容量,我们可以调整内部数组的大小并将所有项目从旧数组移到新数组中。
比这更简单。
哈希表只不过是包含键/值对的向量的数组(通常是稀疏的)。该数组的最大大小通常小于哈希表中存储的数据类型的可能值集中的项目数。
哈希算法用于根据将存储在数组中的项目的值生成该数组的索引。
这是在数组中存储键/值对的向量的地方。因为可以作为数组索引的值的集合通常小于该类型可以拥有的所有可能值的数量,所以散列很可能算法将为两个单独的键生成相同的值。一个好的哈希算法将尽可能避免这种情况(这就是为什么将它归为类型通常是因为它具有一般哈希算法无法知道的特定信息)的原因,但无法避免。
因此,您可以具有多个将生成相同哈希码的键。发生这种情况时,将遍历向量中的项,并在向量中的键和正在查找的键之间进行直接比较。如果找到,则返回great并返回与键关联的值,否则不返回任何值。
您需要一堆东西和一个数组。
对于每件事,您都为它组成一个索引,称为哈希。哈希的重要之处在于它“分散”了很多。您不希望两个相似的事物具有相似的哈希值。
您将您的东西放到哈希指示的位置的数组中。在给定的哈希值中,可能会发生不止一件事情,因此您将它们存储在数组或其他适当的东西中,我们通常将其称为存储桶。
当您在哈希中查找内容时,您将执行相同的步骤,找出哈希值,然后查看该位置存储桶中的内容,并检查其是否为所需内容。
当您的哈希工作良好且数组足够大时,数组中的任何特定索引最多只会有几件事,因此您不必花太多时间。
为了获得加分,请确保它在访问哈希表时将找到的内容(如果有)移动到存储桶的开头,因此下次它是第一个检查的内容。
到目前为止,所有答案都是好的,它们涉及哈希表的工作原理的不同方面。这是一个简单的示例,可能会有所帮助。假设我们要存储一些带有小写字母字符串的项作为键。
如simon所述,哈希函数用于从大空间映射到小空间。在我们的示例中,哈希函数的一个简单,简单的实现可以采用字符串的第一个字母,然后将其映射为整数,因此“ alligator”的哈希码为0,“ bee”的哈希码为1,斑马”将为25,依此类推。
接下来,我们有一个26个存储桶的数组(在Java中可以是ArrayLists),然后将该项放入与键的哈希码匹配的存储桶中。如果我们有多个项目的键以相同字母开头,那么它们将具有相同的哈希码,因此所有哈希码都将放入存储桶中,因此必须在存储桶中进行线性搜索才能查找特定项目。
在我们的示例中,如果我们只有几十个项目的键跨越整个字母,那么它将很好地工作。但是,如果我们有一百万个项目,或者所有键都以“ a”或“ b”开头,那么我们的哈希表将不是理想的。为了获得更好的性能,我们需要一个不同的哈希函数和/或更多的存储桶。
这是另一种查看方式。
我假设您了解数组A的概念。这支持索引操作,无论A有多大,您都可以一步一步到达Ith元素A [I]。
因此,例如,如果您要存储有关一群年龄恰好不同的人的信息,一种简单的方法是拥有一个足够大的数组,并将每个人的年龄用作该数组的索引。这样,您可以单步访问任何人的信息。
但是当然可以有一个以上相同年龄的人,因此在每个条目中输入的数组是所有具有该年龄的人的列表。因此,您可以一步一步地获取个人信息,然后在该列表中进行一些搜索(称为“存储桶”)。只有在有很多人以至于桶变大的情况下,它才会放慢速度。然后,您需要一个更大的数组,以及一些其他方式来获取有关此人的更多标识信息,例如其姓氏的前几个字母,而不是使用年龄。
这是基本思想。代替使用年龄,可以使用产生良好价值观传播的人的任何功能。那就是哈希函数。就像您可以使用该人的姓名的ASCII表示形式的每三位一位,以某种顺序进行加扰。重要的是,您不希望有太多的人哈希到同一个存储桶,因为速度取决于存储桶保持很小的速度。
哈希表完全适用于以下事实:实际计算遵循随机访问机器模型,即可以在O(1)时间或恒定时间内访问内存中任何地址的值。
因此,如果我有一个键的Universe(我可以在应用程序中使用的所有可能的键的集合,例如学生的卷号,如果它是4位数字,则此Universe是一组从1到9999的数字),并且将它们映射到一组有限大小的方法我可以在系统中分配内存,理论上我的哈希表已经准备好了。
通常,在应用程序中,键的Universe的大小比我要添加到哈希表中的元素数大得多(我不想浪费1 GB的内存来进行哈希,例如,10000或100000整数值,因为它们是32在二进制表示形式中位长)。因此,我们使用此哈希。这是一种混合的“数学”运算,它将我的大宇宙映射到我可以在内存中容纳的一小部分值。在实际情况下,哈希表的空间通常与(元素数*每个元素的大小)具有相同的“顺序”(big-O),因此,我们不会浪费太多内存。
现在,一个大集合映射到一个小集合,映射必须是多对一的。因此,不同的键将分配给相同的空间(??不公平)。有几种方法可以解决此问题,我只知道其中两种流行的方法:
CLRS的算法介绍为该主题提供了很好的见解。
对于所有寻求编程的人来说,这是它的工作原理。高级哈希表的内部实现在存储分配/解除分配和搜索方面有许多复杂性和优化方法,但是顶级思想将非常相似。
(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()
是散列函数,所有的魔法唯一必须发生。
经验法则是: 对于要插入的给定值,存储桶必须是唯一且可从应存储的值中得出。
存储桶是存储值的任何空间-在这里,我将其保留为数组索引,但它也可能是内存位置。