为什么我们使用链表来解决哈希表中的冲突?

时间:2015-05-13 20:49:14

标签: performance algorithm data-structures hashtable

我想知道为什么许多语言(Java,C ++,Python,Perl等)使用链表实现哈希表以避免冲突而不是数组?
我的意思是代替链表的桶,我们应该使用数组 如果问题是关于数组的大小那么这意味着我们有太多的冲突,所以我们已经有哈希函数的问题,而不是我们解决冲突的方式。我误解了什么吗?

5 个答案:

答案 0 :(得分:2)

  

我的意思是代替链表的存储桶,我们应该使用数组。

取决于许多因素,对一切事物的利弊。

数组的两大问题:

  1. 更改容量涉及将所有内容复制到另一个内存区域

  2. 您必须 选择

    a)Element*个数组,在表操作期间添加一个 额外间接 ,每个非一个 额外内存分配空桶 以及相关的堆管理开销

    b)Element s的数组,以便某些操作使预先存在的Element 迭代器/指针/引用无效 在其他节点上(例如insert)(链接列表方法 - 或上面的2a - 不需要使这些无效)

  3. ...将忽略关于数组间接的几个较小的设计选择......

    减少从1复制的实用方法包括保留多余的容量(即当前未使用的内存用于预期或已擦除的元素),以及 - 如果sizeof(Element) 多< / em>大于sizeof(Element*) - 你被推向了Element*s数组(带有“2a”问题),而不是Element[] s / 2b。

    还有一些其他答案声称数组中的擦除比链表更昂贵,但相反的通常为真:搜索连续的Element比扫描链表更快(代码中的步骤更少,缓存更友好),一旦找到,您可以将最后一个数组ElementElement*复制到正在删除的数组上,然后减小大小。

      

    如果问题是关于数组的大小那么这意味着我们有太多的冲突,所以我们已经有哈希函数的问题,而不是我们解决冲突的方式。我误解了什么吗?

    要回答这个问题,让我们来看看一个很棒的哈希函数会发生什么。使用加密强度哈希将一百万个元素打包到一百万个桶中,我的程序的几个运行计数0,1,2等元素散列的桶数... ...

    0=367790 1=367843 2=184192 3=61200 4=15370 5=3035 6=486 7=71 8=11 9=2
    0=367664 1=367788 2=184377 3=61424 4=15231 5=2933 6=497 7=75 8=10 10=1
    0=367717 1=368151 2=183837 3=61328 4=15300 5=3104 6=486 7=64 8=10 9=3
    

    如果我们将其增加到1亿个元素 - 仍然使用加载因子1.0:

    0=36787653 1=36788486 2=18394273 3=6130573 4=1532728 5=306937 6=51005 7=7264 8=968 9=101 10=11 11=1
    

    我们可以看到比率相当稳定。即使加载因子为1.0(C ++的unordered_set和 - map的默认最大值),36.8%的桶可能为空,另有36.8%处理一个Element,18.4%2元素等。对于任何给定的数组大小调整逻辑,您可以轻松了解调整大小所需的频率(并可能复制元素)。 对于这种理想主义的加密哈希案例,如果你正在进行大量的查找或迭代,那么它看起来并不坏,并且可能比链表更好。

    但是,优质的散列在CPU时间上相对昂贵,因此支持散列函数的通用散列表通常非常弱:例如std::hash<int>的C ++标准库实现返回它们的参数是很常见的,并且MS Visual C ++的std::hash<std::string>选择沿string间隔开的10个字符以包含在哈希值中,无论如何string是。{/ p>

    显然,实现的经验是,这种弱但快速的散列函数和链接列表(或树)的组合能够处理更大的碰撞倾向,平均速度更快 - 并且具有较少的用户对抗表现 - 令人讨厌的糟糕表现 - 用于日常钥匙和要求。

答案 1 :(得分:1)

策略1

使用(小)数组进行实例化,然后在发生冲突时填充。 1个堆操作用于分配数组,然后空间为N-1多。如果该桶再次没有发生冲突,则会浪费N-1条目容量。列表获胜,如果冲突很少,则不会为桶上溢出更多的概率分配多余的内存。删除物品也更昂贵。在数组中标记已删除的点或将其后面的内容移动到前面。如果阵列已满,该怎么办?链接数组列表或调整数组大小?

使用数组的一个潜在好处是进行排序插入,然后在检索时进行二进制搜索。链表方法无法与之竞争。但是否能够获得回报取决于写入/检索比率。写作的频率越低,就越能获得回报。

策略2

使用列表。你支付你得到的东西。 1次碰撞= 1次堆操作。没有急切的假设(以及记忆方面的代价)&#34;更多的将来#34;碰撞列表中的线性搜索。更便宜的删除。 (这里不算free())。考虑数组而不是列表的一个主要动机是减少堆操作的数量。有趣的是,一般的假设似乎是它们便宜。但实际上并不是很多人知道分配需要多少时间,比如遍历寻找匹配的列表。

策略3

既不使用数组也不使用列表,但将散列表中的溢出条目存储在另一个位置。上次我在这里提到过,我有点皱眉头。好处:0内存分配。如果您的表的填充等级确实很低并且碰撞很少,则可能效果最佳。

<强>摘要

确实有很多选择和权衡可供选择。诸如标准库中的通用哈希表实现不能做出关于写/读比率,哈希键的质量,用例等的任何假设。另一方面,哈希表应用程序的所有那些特征都是已知的(如果它值得付出努力),很有可能创建一个哈希表的优化实现,该哈希表是为应用程序所需的权衡集量身定制的。

答案 2 :(得分:1)

原因是,这些列表的预期长度很小,绝大多数情况下只有零个,一个或两个条目。然而,在真正糟糕的散列函数的最坏情况下,这些列表也可能变得任意长。即使最糟糕的情况不是散列表优化的情况,他们仍然需要能够优雅地处理它。

现在,对于基于数组的方法,您需要设置最小的数组大小。并且,如果该初始数组大小为零以外的任何值,则由于所有空列表,您已经具有显着的空间开销。最小阵列大小为2意味着您浪费了一半的空间。并且你需要实现逻辑来在数组变满时重新分配数组,因为你不能给列表长度设置一个上限,你需要能够处理最坏的情况。

在这些约束条件下,基于列表的方法效率更高:它只有节点对象的分配开销,大多数访问与基于数组的方法具有相同的间接量,并且编写起来更容易。

我并不是说编写基于数组的实现是不可能的,但它比基于列表的方法更复杂,效率更低。

答案 3 :(得分:1)

  

为什么许多语言(Java,C ++,Python,Perl等)使用链表实现哈希表以避免冲突而不是数组?

我几乎可以肯定,至少对于大多数来说,很多&#34;很多&#34;语言:

这些语言的哈希表的原始实现者只是遵循Knuth /其他算法书中的经典算法描述,并且甚至没有考虑这种微妙的实现选择。

一些观察结果:

  • 即使使用separate chains而不是open addressing进行碰撞解决,也可以使用&#34;大多数通用哈希表实现&#34;是一个严重怀疑的选择。我的个人信念 - 这不是正确的选择。

  • 当哈希表的加载因子相当低时(应该在接近99%的哈希表使用中选择),建议的方法之间的差异几乎不会影响整体数据结构的性能(如 cmaster 在他的回答开头解释, delnan 在评论中有意义地改进了)。由于语言中的通用哈希表实现不是为高密度设计的,因此链接列表与数组相关联。对他们来说不是一个紧迫的问题。

  • 回到主题问题本身,我没有看到链接列表应该优于数组的任何概念性原因。我可以很容易想象,事实上,现代硬件上的数组更快/使用现代语言运行时/操作系统内的现代momory分配器消耗更少的内存。特别是当哈希表的键是原始的或复制的结构时。您可以在此处找到支持此观点的一些论据:http://en.wikipedia.org/wiki/Hash_table#Separate_chaining_with_other_structures

    但找到正确答案的唯一方法(特定CPU,操作系统,内存分配器,虚拟机及其垃圾收集算法,以及哈希表用例/工作负载!)是实现这两种方法和比较它们。

  

我误解了什么吗?

不,你不会误解任何事情,你的问题是合法的。这是一个公平混淆的例子,当某些事情以某种特定方式完成时,并非强烈的理由,但主要是偶尔。

答案 4 :(得分:1)

如果使用数组实现,在插入的情况下,由于重新分配,在链接列表的情况下不会发生,这将是昂贵的。

在删除的情况下,我们必须搜索完整的数组,然后将其标记为删除或移动其余元素。 (在前一种情况下,由于我们必须搜索空插槽,因此插入更加困难。)

为了将最坏情况下的时间复杂度从o(n)提高到o(logn),一旦哈希桶中的项目数超过某个阈值,该桶将从使用链接的条目列表切换到平衡树(在java中)。