我正在寻找一种简单易懂的方式解释哈希表的工作原理!
例如,我知道它需要获取键(key),计算哈希值(我想知道如何计算),然后执行某种取模运算,以确定其位于存储值的数组中的位置, 但我的知识就止步于此。
有没有人能够澄清这个过程呢?
编辑:我不是特别关心哈希码(hash code)如何计算,而是对哈希表的一般概述。
我正在寻找一种简单易懂的方式解释哈希表的工作原理!
例如,我知道它需要获取键(key),计算哈希值(我想知道如何计算),然后执行某种取模运算,以确定其位于存储值的数组中的位置, 但我的知识就止步于此。
有没有人能够澄清这个过程呢?
编辑:我不是特别关心哈希码(hash code)如何计算,而是对哈希表的一般概述。
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桶是否被“浪费”了?有点是,也有点不是。总的内存使用量相同。 - Tony Delroy用法和术语:
真实世界的例子:
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)之外。可能值得编辑一下。 - ryantucke5dc41578f88877b333c8b31634cf77e4911ed8c
的生成值。这只是一个160位(20字节)的十六进制数。然后,您可以使用此值确定将用于存储记录的桶(数量有限)。 - Jeach这是一个相当深入的理论领域,但基本概述很简单。
实际上,哈希函数只是将来自一个空间(比如任意长度的字符串)的内容映射到可用于索引的空间(无符号整数)的函数。
如果你只有一个小的哈希空间,你可能可以将那些内容视为整数并且直接使用(例如4字节的字符串),然后你就完成了
通常情况下,你要处理的空间更大。如果作为键的内容空间大于用于索引的空间(例如uint32),则不可能为每个键都生成唯一的值。当两个或多个内容散列到相同结果时,你必须以适当的方式处理冗余(通常称为冲突,并且你应该根据使用哈希的目的选择如何处理它)。
这意味着你希望重复的结果出现的概率很小,并且你可能真的希望哈希函数非常快速。
平衡这两个属性及其他几个属性一直占据着许多人的时间!
在实践中,你通常应该能够找到已知适用于你的应用程序的良好函数并使用它。
现在来使它作为哈希表:想象一下你不关心内存使用情况。那么,你可以创建一个与索引集(所有uint32)一样长的数组。当你向表中添加内容时,你会散列它的键并查看该索引位置处的数组。如果那里没有任何内容,你就将值放在那里。如果那里已经有了其他内容,你将把这个新条目添加到该地址的条目列表中,并添加足够的信息(你的原始键或一些聪明的东西)以查找哪个条目实际上属于哪个键。
因此,当你遍历哈希表时,哈希表中的每个条目(数组)都是空的、包含一个条目或多个条目的列表。检索操作只需要将其索引到数组中,并返回相应的值,或者遍历值列表并返回正确的值即可。
当然,在实践中,你通常无法这样做,它会浪费太多内存。因此,你需要基于稀疏数组进行所有操作(只有实际使用的条目才有输入,其他所有内容都默认为 null)。
有许多方案和技巧可以使其更好地工作,但这就是基础。
有很多答案,但它们没有一个是非常直观的,并且哈希表可以很容易地通过可视化进行“点击”。
哈希表通常被实现为链表数组。如果我们想象一个存储人名的表,在插入几个名字后,它可能在内存中布局如下,其中括号中的数字是文本/名称的哈希值。
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]
...)被称为一个桶,并开始一个(可能为空的)链接列表,其中包含了一些值(例如这个例子中的人名)42
的"fred"
)都从桶 [hash % number_of_buckets]
链接而来,例如 42 % 10 == [2]
;%
是模运算符——除以桶数后的余数42 % 10 == [2]
和 9282 % 10 == [2]
),但偶尔也可能是因为哈希值相同(例如"fred"
和 "jane"
在上面都显示了哈希值为 42
)
如果表格大小增加,像上面实现的哈希表往往会自行调整大小(例如创建一个更大的桶数组、在那里创建新的/更新的链接列表、删除旧数组),以使值与桶的比率(也称为负载因子)保持在0.5到1.0范围内。
汉斯在下面的评论中给出了其他负载因子的实际公式,但是具有指示性的值为:对于负载因子为1且具有加密强度的哈希函数,约有1/e (~36.8%) 的桶 tend to be empty,另外 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; int age; };`,以及只查看`name`字段(忽略年龄)的等式比较和哈希函数,然后出现了一些奇妙的事情:我们可以在表中存储`Value`记录,例如`{"sue", 63}`,然后稍后搜索"sue"而不知道她的年龄,找到存储的值并恢复甚至更新她的年龄-生日快乐Sue-这不会改变哈希值,因此不需要将Sue的记录移动到另一个桶中。
这样做时,我们将哈希表用作关联容器,也称为映射,它存储的值可被视为包含一个键(名称)和一个或多个其他字段,仍称为-令人困惑的是-值(在我的例子中只有年龄)。用作映射的哈希表实现称为哈希映射。
这与本答案前面的示例形成对比,我们存储如"sue"这样的离散值,您可以认为它是其自身的键:这种用法称为哈希集。
并非所有哈希表都使用链接列表(称为分离链接),但大多数通用哈希表都会使用,因为主要的替代方案闭散列(也称为开放寻址) - 特别是支持删除操作-具有不太稳定的性能特性,因为它容易发生键/哈希函数冲突。
一个通用的、最坏情况下冲突最小化的哈希函数的工作是将密钥有效地随机分散在哈希表桶中,同时始终为相同的密钥生成相同的哈希值。即使密钥中的任何一个位发生变化,理想情况下,哈希值中大约一半的位会被随机翻转。
通常,这需要我难以理解的复杂数学来进行编排。我将提及一种易于理解的方式——不是最可扩展或缓存友好的方式,但本质上是优美的(就像使用一次性密码进行加密一样!)——因为我认为它有助于强调上述所述的理想特质。假设您要对64位的double
进行哈希处理-可以创建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%),从而具有较少的冲突和较短的冲突元素链接列表,比随机映射实现的要少得多。 它还可以节省生成强哈希所需的时间,并且如果按顺序查找键,则它们将在内存附近的桶中找到,从而提高缓存命中率。 当键不适合递增时,希望它们足够随机,不需要强哈希函数来完全随机化它们放置到桶中。
你们已经非常接近完全解释这个问题了,但还是有一些遗漏。哈希表其实就是一个数组。这个数组的每个槽位都会存储某些内容。至少你需要在这个槽位中存储哈希值或者实际的值。除此之外,你还可以在这个槽位中存储发生碰撞的值的链表或者使用开放寻址法。你也可以在这个槽位中存储指向其他要检索出的数据的指针。
需要注意的是,哈希值本身通常并不表示要将值放入哪个槽位中。例如,哈希值可能是一个负整数值。显然,负数无法指向数组位置。此外,哈希值往往比可用槽位的数量大得多。因此,哈希表本身需要执行另一个计算来确定该值应该放入哪个槽位中。这是通过模运算操作完成的,例如:
uint slotIndex = hashValue % hashTableSize;
这个值是该值将要进入的插槽。在开放地址法中,如果插槽已经填充了另一个哈希值和/或其他数据,则模运算将再次运行以找到下一个插槽:
slotIndex = (remainder + 1) % hashTableSize;
我想可能有其他更先进的方法来确定插槽索引,但这是我看到的常见方法...如果有其他更优越的方法,我会很感兴趣。
使用模数法,如果您有一个大小为1000的表格,任何介于1和1000之间的哈希值都会进入相应的插槽。任何负值和大于1000的值将潜在地发生冲突的插槽值。发生这种情况的机会取决于您的哈希方法以及向哈希表中添加的总项数。通常,最好将散列表的大小设置为其大小的约70%,这样添加到其中的总值仅等于其大小的约70%。如果您的哈希函数能够均匀分布,您通常会遇到很少或没有桶/插槽碰撞,并且查找和写入操作都能够非常快速地执行。如果不能预先知道要添加的值的总数,请使用任何方法进行良好的估计,然后一旦添加到其中的元素数量达到容量的70%,就调整散列表的大小。
希望这可以帮助您。
PS-在C#中,GetHashCode()
方法相当缓慢,并在许多条件下导致实际值冲突。为了获得实际乐趣,请构建自己的哈希函数,并尝试使其在散列的特定数据上从不发生碰撞,运行速度比GetHashCode快,并具有相当均匀的分布。我已经使用长而不是int大小的哈希码值完成此操作,并在最多3200万个条目的哈希表中工作得非常好,没有冲突。不幸的是,我不能分享代码,因为它属于我的雇主...但我可以透露在某些数据域中是可能的。当您能够做到这一点时,哈希表非常快。
这是我理解的工作原理:
举个例子,可以将整个表格想象成一系列桶。假设你有一个使用字母数字哈希代码的实现,并且每个字母表中都有一个桶。此实现会将其哈希码以特定字母开头的每个项放入相应的桶中。
假设你有200个对象,但只有15个对象的哈希码以字母'B'开头。哈希表只需要查找并搜索'B'桶中的15个对象,而不是所有200个对象。
计算哈希码并没有什么神奇之处。目标只是使不同对象返回不同的代码,并使相等对象返回相等的代码。你可以编写一个类,始终为所有实例返回相同的整数哈希码,但你将破坏哈希表的可用性,因为它只会变成一个巨大的桶。
简短明了:
哈希表包装了一个数组,我们称之为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)的。
当我们的内部数组变得太满,也许在大约85%的容量时,我们可以重新调整内部数组大小,并将所有项目从旧数组移动到新数组中。
比这更简单。
散列表不过是一个数组(通常是sparse的),其中包含键/值对的向量。该数组的最大大小通常小于散列表中存储的数据类型可能具有的所有值的集合中的项目数。
散列算法用于根据将存储在数组中的项的值生成数组中的索引。
这就是把键/值对的向量存储在数组中的地方。因为可以用作数组索引的值的集合通常比类型可能具有的所有可能值的数量要小,所以您的散列算法有可能为两个单独的键生成相同的值。好的散列算法会尽可能避免这种情况(这就是为什么它通常被归类到类型中,因为它拥有一些一般的散列算法不可能知道的特定信息),但是防不胜防。
由于这个原因,您可以有多个键将生成相同的哈希代码。当发生这种情况时,迭代通过向量中的项,并直接比较向量中的键和正在查找的键。如果找到,则返回与键相关联的值,否则不返回任何内容。
你需要拿一些东西和一个数组。
对于每个东西,你都要为它创建一个索引,称为哈希。哈希的重要之处在于它会“分散”很多;你不希望两个相似的东西有相似的哈希值。
你将这些东西放入数组中,位置由哈希值指示。可能会有多个东西落在同一个哈希位置,所以你需要将这些东西存储在数组或其他适当的容器中,我们通常把这个容器称为桶。
当你在哈希表中查找东西时,需要执行相同的步骤,计算出哈希值,然后看看该位置的桶中是否有你要找的东西。
当哈希函数工作得很好且数组足够大时,任何特定索引处都只会有很少的东西,因此你不需要查看太多。
为了额外加分,使哈希表被访问时,如果找到了东西,就将其移到桶的最前面,下次查找时会首先检查它。
为什么人们使用梳妆台来存放衣服呢?除了看起来时尚和有型之外,它们的优点在于每件衣服都有一个应该放置的位置。如果你要找一双袜子,你只需要检查袜子抽屉。如果你要找一件衬衫,你就检查有衬衫的抽屉。无论你有多少件衬衫或裤子,当你要找袜子时,都不需要看它们。你只需查看袜子抽屉,并期望在那里找到袜子。
在高层次上,哈希表是一种存储东西的方式,有点像衣柜一样。基本思想如下:
这种系统的优点是,假设您的规则不太复杂并且您拥有适当数量的抽屉,您可以通过简单地在正确的位置查找来快速找到您要找的东西。
当你整理衣服时,你可能会使用类似于“袜子放在左上抽屉,衬衫放在大中间抽屉”这样的“规则”。然而,当你存储更抽象的数据时,我们使用称为哈希函数的东西来为我们完成此操作。 +---------+
|\| hash |/| --> hash code
data --> |/| function|\|
+---------+
一旦我们有了哈希函数,我们就可以构建一个非常简单的哈希表。我们将制作一个“桶”的数组,您可以将其视为类比于我们梳妆台上的抽屉。要在哈希表中存储项目,我们将计算对象的哈希码并将其用作表中的索引,这类似于“选择将此项放入哪个抽屉”。然后,我们将该数据项放在该索引处的桶中。如果该桶为空,则很好!我们可以将物品放在那里。如果该桶已满,我们有一些选择可供选择。一种简单的方法(称为链接哈希)是将每个桶视为项目列表,就像您的袜子抽屉可能存储多个袜子一样,然后只需将该项添加到该索引处的列表中即可。
要在哈希表中查找某个项,我们基本上使用相同的过程。我们首先计算要查找的项的哈希码,这告诉我们要查找哪个桶(抽屉)。如果该项在表中,则必须在那个桶中。然后,我们只需查看桶中的所有项目,看看我们的项目是否在其中。线性探测是构建哈希表的一种令人惊讶的快速方法。CPU缓存被优化为引用局部性,因此相邻内存位置的内存查找往往比散布位置的内存查找快得多。由于线性探测插入或删除通过命中某个数组槽然后向前线性行走来工作,所以它导致缓存未命中较少,最终比理论预测要快得多。(而且恰好情况是理论预测它将非常快!)
另一种近期变得流行的策略是布谷鸟哈希。我认为布谷鸟哈希就像哈希表中的“冰雪奇缘”。我们不再使用一个哈希表和一个哈希函数,而是使用两个哈希表和两个哈希函数。每个项目只能在两个位置中的一个 - 要么在第一个哈希函数给出的第一个表中的位置,要么在第二个哈希函数给出的第二个表中的位置。这意味着查找是最坏情况下高效的,因为您只需要检查两个位置以查看某些内容是否在表中。接下来介绍混合方法。Hopscotch hashing是开放地址和链式哈希的混合体,可以看作是将链式哈希表中每个桶中的每个项存储在靠近该项所需位置的插槽中。这种策略与多线程兼容。Swiss table利用一些处理器可以在单个指令中并行执行多个操作的事实来加速线性探测表。Extendible hashing专为数据库和文件系统设计,使用trie和链式哈希表的混合形式,在单独的存储桶被加载时动态增加桶大小。Robin Hood hashing是线性探测的一种变体,其中插入后可以移动项以减少每个元素与原始位置的距离方差。
想要了解关于哈希表基础的更多信息,请查看这些关于链式哈希的讲义幻灯片和这些关于线性探测和罗宾汉哈希的跟进幻灯片。您可以在此了解更多关于布谷鸟哈希的信息,以及哈希函数的理论属性信息。