哈希表 - 为啥它比数组快?

Posted

技术标签:

【中文标题】哈希表 - 为啥它比数组快?【英文标题】:Hash table - why is it faster than arrays?哈希表 - 为什么它比数组快? 【发布时间】:2012-08-14 19:10:26 【问题描述】:

如果我对每个元素都有一个键并且我不知道该元素在数组中的索引,则哈希表的性能优于数组(O(1) 与 O(n))。

这是为什么呢?我的意思是:我有一个密钥,我对其进行散列..我有散列..算法不应该将此散列与每个元素的散列进行比较吗?我认为内存配置背后有一些技巧,不是吗?

【问题讨论】:

【参考方案1】:

散列的目的是为底层数组生成一个索引,使您能够直接跳转到有问题的元素。这通常通过将哈希除以数组的大小并取余数index = hash%capacity 来完成。

散列的类型/大小通常是足以索引所有 RAM 的最小整数。在 32 位系统上,这是一个 32 位整数。在 64 位系统上,这是一个 64 位整数。在 C++ 中,这分别对应于 unsigned intunsigned long long。要成为迂腐的 C++ 在技术上指定其原语的最小大小,即至少 32 位和至少 64 位,但这不是重点。为了使代码可移植,C++ 还提供了一个size_t 原语,它对应于适当的无符号整数。在编写良好的代码中,您会在索引到数组的 for 循环中看到很多类型。在像 Python 这样的语言的情况下,整数原语可以增长到它需要的任何大小。这通常在名为“Big Integer”的其他语言的标准库中实现。为了解决这个问题,Python 编程语言简单地将您从 __hash__() 方法返回的任何值截断为适当的大小。

在这一点上,我认为值得对智者说一说。无论您是在最后还是在沿途的每一步计算余数,算术的结果都是相同的。截断相当于计算余数模 2^n,其中 n 是您保持不变的位数。现在你可能认为在每一步计算余数是愚蠢的,因为你在每一步都会产生额外的计算。然而,情况并非如此,原因有两个。首先,从计算上讲,截断非常便宜,远比广义除法便宜。其次,这是真正的原因,因为第一个是不够的,即使在没有它的情况下,索赔通常也会成立,在每个步骤中取剩余部分会使数量(相对)小。因此,您需要的不是product = 31*product + hash(array[index]) 之类的东西,而是product = hash(31*product + hash(array[index])) 之类的东西。内部 hash() 调用的主要目的是获取可能不是数字的东西并将其转换为一个,而外部 hash() 调用的主要目的是获取可能过大的数字并将其截断。最后我要注意的是,在像 C++ 这样整数基元具有固定大小的语言中,这个截断步骤会在每次操作后自动执行。

现在是房间里的大象。您可能已经意识到散列码通常比它们对应的对象小,更不用说从它们派生的索引通常更小,两个对象完全有可能散列到同一个索引。这称为哈希冲突。像 Python 的 setdict 或 C++ 的 std::unordered_setstd::unordered_map 这样的哈希表支持的数据结构主要以两种方式之一处理此问题。第一个称为separate chaining,第二个称为open addressing。在单独的链接中,作为哈希表的数组本身就是一个列表数组(或者在某些情况下,开发人员喜欢花哨的其他数据结构,如binary search tree),并且每次元素散列到给定索引时它被添加到相应的列表中。在开放寻址中,如果一个元素散列到一个已经被占用的索引,则数据结构会探测到下一个索引(或者在某些情况下,开发人员觉得很花哨,一个由其他函数定义的索引,如@987654324 中的情况@) 以此类推,直到它找到一个空槽,当它到达数组的末尾时当然会环绕。

接下来是关于负载系数的内容。在增加或减少负载因子时,当然存在固有的空间/时间折衷。负载因子越高,表占用的空间越少;然而,这是以增加性能降低冲突的可能性为代价的。一般来说,使用单独链接实现的哈希表对负载因子的敏感度低于使用开放寻址实现的哈希表。这是由于被称为clustering 的现象,其中开放寻址哈希表中的簇在正反馈循环中趋于变得越来越大,因为它们越大,它们就越有可能包含新添加元素的首选索引。这实际上是为什么前面提到的逐渐增加跳跃距离的二次探测方案通常是首选的原因。在负载因子大于 1 的极端情况下,开放寻址根本无法工作,因为元素的数量超过了可用空间。也就是说,大于 1 的负载因子通常非常罕见。在编写 Python 的 setdict 类时,最大负载因子为 2/3,而 Java 的 java.util.HashSetjava.util.HashMap 使用 3/4,而 C++ 的 std::unordered_setstd::unordered_map最大负载因子为 1。不出所料,Python 的哈希表支持的数据结构处理与开放寻址的冲突,而它们的 Java 和 C++ 对应物则通过单独的链接来处理。

最后评论一下桌子的大小。当超过最大负载因子时,哈希表的大小当然必须增加。由于这需要重新索引其中的每个元素,因此将表增长固定数量是非常低效的。这样做会在每次添加新元素时产生订单大小操作。此问题的标准修复与大多数dynamic array 实现所采用的相同。在我们需要扩大表格的每一个点上,我们只需将其大小增加其当前大小。不出所料,这被称为表加倍。

【讨论】:

【参考方案2】:

为什么 [它] [哈希表通过键执行查找比数组 (O(1) vs O(n))] 更好?我的意思是:我有一个密钥,我对其进行散列..我有散列..算法不应该将此散列与每个元素的散列进行比较吗?我认为内存配置背后有一些技巧,不是吗?

获得哈希后,您可以计算存储桶数组中的“理想”或预期位置:通常:

理想存储桶 = hash % num_buckets

问题是另一个值可能已经散列到该桶,在这种情况下,散列表实现有两个主要选择:

1) 尝试另一个存储桶

2) 让几个不同的值“属于”一个桶,可能是通过让桶持有指向值链表的指针

对于实现 1,称为open addressing or closed hashing,您可以跳过其他桶:如果您找到了自己的价值,那就太好了;如果您找到一个从未使用过的存储桶,那么您可以在插入时将您的值存储在其中,或者您知道在搜索时永远找不到您的值。如果您遍历替代存储桶的方式最终多次搜索同一个存储桶,则搜索可能会比 O(n) 更糟;例如,如果您使用quadratic probing,您尝试使用理想的桶索引 +1,然后 +4,然后 +9,然后 +16 等等 - 但您必须避免使用例如越界桶访问% num_buckets,所以如果有 12 个桶,那么 Ideal+4 和 Ideal+16 搜索同一个桶。跟踪搜索了哪些存储桶可能会很昂贵,因此也很难知道何时放弃:实现可以是乐观的,并假设它总是会找到值或未使用的存储桶(冒着永远旋转的风险),它可以有一个计数器,在尝试达到阈值后,要么放弃,要么开始线性逐桶搜索。

对于实现 2,称为closed addressing or separate chaining,您必须在容器/数据结构中搜索所有散列到理想存储桶的值。这有多有效取决于使用的容器类型。通常预计在一个桶中碰撞的元素数量会很少,这对于具有非对抗性输入的良好哈希函数来说是正确的,并且通常对于即使是平庸的哈希函数也足够正确,尤其是在具有素数桶的情况下。因此,尽管具有 O(n) 搜索属性,但通常使用链表或连续数组:链表易于实现和操作,并且数组将数据打包在一起以获得更好的内存缓存局部性和访问速度。最糟糕的情况是,您的表中的每个值都散列到同一个桶中,而该桶中的容器现在保存了所有值:您的整个哈希表只与 桶的 容器一样高效.如果散列到相同存储桶的元素数量超过阈值,一些 Java 哈希表实现已经开始使用二叉树,以确保复杂度永远不会比 O(log2n) 差。

Python 哈希是 1 = 开放寻址 = 封闭哈希的示例。 C++ std::unordered_set 是封闭寻址 = 分离链接的示例。

【讨论】:

【参考方案3】:

使用数组:如果您知道值,则必须平均搜索一半的值(除非已排序)才能找到它的位置。

使用哈希:根据值生成位置。因此,再次给出该值,您可以计算插入时计算的相同哈希值。有时,超过 1 个值会产生相同的散列,因此实际上每个“位置”本身就是散列到该位置的所有值的 数组(或链表)。在这种情况下,只需要搜索这个小得多(除非它是一个坏哈希)的数组。

【讨论】:

我知道这是一个古老的话题,但感谢您的回答!这个简单直接的解释是它最终让我回到原位的原因。我一直在想,当我可以从一个简单的数组中检索这些东西时,为什么还要对这些东西进行哈希处理......好吧,因为也许我不知道它的索引,duh smacks forehead . :)。 谢谢!多年来我就知道哈希表将值存储在各种存储桶中(理想情况下每个存储桶一个值),但是在我所做的所有阅读中,没有人解释过存储桶本身的位置本质上是由正在存储的对象 - 因此重新散列值的行为让您知道在哪里寻找它。我突然明白为什么哈希表有可能比数组快得多。简短但出色的答案。 这对我来说比接受的答案更有意义。谢谢。【参考方案4】:

哈希表不必比较哈希中的每个元素。它将根据密钥计算哈希码。例如,如果 key 是 4,那么 hashcode 可能是 - 4*x*y。现在指针确切地知道要选择哪个元素。

如果它是一个数组,它必须遍历整个数组来搜索这个元素。

【讨论】:

【参考方案5】:

哈希表有点复杂。他们根据哈希值将元素放入不同的 buckets 中。理想情况下,每个桶中的物品很少,空桶也不多。

一旦知道密钥,就可以计算哈希。根据哈希,您知道要查找哪个存储桶。并且如前所述,每个bucket中的item数量应该比较少。

哈希表在内部做了很多神奇的事情,以确保存储桶尽可能小,同时不会为空存储桶消耗太多内存。此外,很大程度上取决于密钥的质量 -> 哈希函数。

***提供very comprehensive description of hash table。

【讨论】:

哈希表并不总是使用桶。【参考方案6】:

如果我对每个元素都有一个键但我不知道 将元素索引到数组中,哈希表的性能优于 数组(O(1) vs O(n))。

哈希表搜索在平均情况下执行 O(1)。在最坏的情况下,哈希表搜索执行 O(n):当您有冲突并且哈希函数总是返回相同的槽时。人们可能会认为“这是一个遥远的情况”,但一个好的分析应该考虑到它。在这种情况下,您应该遍历数组或链表中的所有元素 (O(n))。

这是为什么呢?我的意思是:我有一个密钥,我散列它..我有散列.. 算法不应该将此哈希与每个元素的 哈希?我认为内存配置背后有一些技巧,不是吗 是吗?

你有一个键,你散列它.. 你有散列:元素所在的散列表的索引(如果它之前已经找到)。此时您可以访问 O(1) 中的哈希表记录。如果负载因子很小,则不太可能在那里看到多个元素。因此,您看到的第一个元素应该是您正在寻找的元素。否则,如果您有多个元素,则必须将在该位置找到的元素与您正在寻找的元素进行比较。在这种情况下,您有 O(1) + O(number_of_elements)。

在平均情况下,哈希表搜索复杂度为 O(1) + O(load_factor) = O(1 + load_factor)。

记住,在最坏的情况下,load_factor = n。因此,最坏情况下的搜索复杂度为 O(n)。

我不知道您所说的“记忆倾向背后的技巧”是什么意思。在某些观点下,哈希表(其结构和通过链接解决冲突)可以被认为是一种“聪明的技巧”。

当然,哈希表分析结果可以用数学来证明。

【讨论】:

谢谢你们 :-) @ZoranPavlovic:我为激情写答案。赞成票看起来像:“我喜欢你的回复!”它们有助于向社区强调好的答案。 在最坏的情况下负载因子 = n 怎么办?如果 load_factor 是 n/k,其中 n 是元素的数量,k 是数组的大小,那么 load_factor 在最坏的情况下不会为 1,因为您不能在哈希表中存储超过 k 个元素?跨度> 我不明白的事情:你说哈希表的最坏情况是 O(n) ,其中 n 是元素的总数。但是,您必须对元素进行线性迭代的唯一情况是发生冲突(在这种情况下,存储桶是某种数组/链表/树)。那么你是说在最坏的情况下,哈希表中的 每个元素 都会导致冲突,因此查找是 O(n) ?这甚至可能吗?不应该是 O(m),其中 m 是哈希到相同值的插入元素的数量吗?【参考方案7】:

我想你在那里回答了你自己的问题。 “算法不应该将此哈希与每个元素的哈希进行比较”。当它不知道您正在搜索的内容的索引位置时,它就是这样做的。它会比较每个元素以找到您要查找的元素:

例如假设您正在字符串数组中查找名为“Car”的项目。您需要检查每个项目并检查 item.Hash() == "Car".Hash() 以找出您要查找的项目。显然,它总是在搜索时不使用散列,但这个例子就成立了。然后你有一个哈希表。哈希表的作用是创建一个稀疏数组,或者有时像上面提到的那样创建一个桶数组。然后它使用 "Car".Hash() 来推断你的 "Car" 项目在稀疏数组中的实际位置。这意味着它不必搜索整个数组来找到您的项目。

【讨论】:

搜索时不比较哈希值。您计算为您提供值所在索引的哈希值,然后将您找到的列表中的 values 与您正在寻找的值进行比较

以上是关于哈希表 - 为啥它比数组快?的主要内容,如果未能解决你的问题,请参考以下文章

哈希表的基本原理?

为啥哈希表在存储桶的数组上使用链表?

为啥哈希表不保持插入顺序? [复制]

哈希表查找速度为什么那么快?快在哪里了?

为啥用二叉搜索树实现哈希表?

哈希是什么?为什么哈希存取比较快?