【问题标题】:Why are we using linked list to address collisions in hash tables?为什么我们使用链表来解决哈希表中的冲突?
【发布时间】:2015-07-25 08:05:30
【问题描述】:

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

【问题讨论】:

  • 哈希表似乎有 3 个应用领域: 1. 那些真正以内存换取速度的人——他们希望避免冲突。 2. 依赖溢出机制的人,希望自己的桌子有相当高的填充等级,不介意碰撞。 3. 通常称之为转置表的游戏,它们跳过任何溢出机制并假设同义词总体上无害。
  • 您是否完全确定:大多数语言都使用链表实现哈希表以避免冲突?请提供参考。
  • 你是断言他们使用链表的人;证明陈述的责任在你身上。
  • @Jim 是的,因为 Java 8,java.util.HashMap 正在使用 balanced trees
  • Python 根本不使用链表或任何桶。它使用开放寻址。

标签: performance algorithm data-structures hashtable


【解决方案1】:

我的意思是,我们应该使用数组,而不是链表桶。

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

数组最大的两个问题:

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

  2. 您必须选择

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

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

...将忽略一些关于数组间接的较小设计选择...

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


还有其他几个答案声称在数组中擦除比链表更昂贵,但相反的通常是正确的:搜索连续的Elements 比扫描链表更快(更少的步骤在代码中,对缓存更友好),一旦找到,您可以将最后一个数组 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 有多长.

显然,实现的经验是这种弱但快速的哈希函数和链表(或树)的组合以处理更大的冲突倾向,平均而言运行得更快 - 并且具有令人讨厌的糟糕性能的用户对抗表现更少 -用于日常关键和要求。

【讨论】:

  • 数组内容的拷贝问题严重吗?我希望在一个像样的哈希表中,我们只会有少量的冲突,例如数组中最多 8 个元素/列表中最多 8 个节点
  • 为了防止其他人对不同的负载因子执行相同的分析,我在这个链接中留下了明确的公式:cs.stackexchange.com/q/31881/9350
  • @Jim:如果你使用一个好的散列函数,复制的成本可能不是一个严重的问题,但如果你使用一个弱的,它可能是一个严重的问题散列函数(并且许多语言实现提供的散列函数很弱)和/或具有由敌对用户提供的计算碰撞的键。
  • @leventov:非常感谢 - 很高兴看到它背后的数学原理,并且确实这些数字与我的负载因子 0.75 的结果一致(一次运行时为 0=472183 1=354381 2=133140 3=32985 4=6299 5=892 6=111 7=9)。
  • @TonyD,是的,你的 36.8% 只是 1/e :)
【解决方案2】:

策略 1

使用(小)数组,一旦发生冲突,这些数组就会被实例化并随后被填充。 1 堆操作为数组分配,则空间多为N-1。如果该存储桶不再发生冲突,则浪费了 N-1 个条目容量。列表获胜,如果冲突很少,则不会分配多余的内存,只是为了在存储桶上有更多溢出的可能性。移除物品也更昂贵。要么在阵列中标记已删除的点,要么将其后面的东西移到前面。如果阵列已满怎么办?数组链表还是调整数组大小?

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

策略 2

使用列表。你为你得到的东西付费。 1 次碰撞 = 1 次堆操作。没有急切的假设(以及在内存方面付出的代价)“还会有更多”。碰撞列表中的线性搜索。更便宜的删除。 (这里不算free())。考虑数组而不是列表的一个主要动机是减少堆操作的数量。有趣的是,普遍的假设似乎是它们很便宜。但实际上并没有多少人知道与遍历列表寻找匹配项相比,分配需要多少时间。

策略 3

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

总结

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

【讨论】:

    【解决方案3】:

    原因是,这些列表的预期长度很小,在绝大多数情况下只有零个、一个或两个条目。然而,在哈希函数非常糟糕的最坏情况下,这些列表也可能变得任意长。即使哈希表没有针对这种最坏的情况进行优化,它们仍然需要能够优雅地处理它。

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

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

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

    【讨论】:

    • 如果对数组很聪明,链表不会更小。不要过度分配小数组。一个单元素数组就是长度 + 元素,与链表节点(下一个指针 + 元素)一样大。两个元素的列表已经更有效了。空数组同样小:如果您总是在外部分配数组,只需在哈希表中存储一个空指针,就像对链表一样。如果要内联列表的第一个节点,数组的等价物是存储 (length, first_element) 如果长度 = 1 和 (length, pointer) 否则。同样大小。
    • 对于多个元素,您实际上开始节省空间并获得更多缓存命中。无论如何,我的观点不是数组更适合存储桶(我不确定),而是链接列表没有您声称的那么多优势。代码也没有那么复杂,尽管设计可能更复杂一些。
    • @delnan 是的,如果你非常小心,你可以控制数组的开销。然而,通常应用数组的方式(从八个元素开始,每次重新分配的容量翻倍)不能在这种情况下应用。所以这需要相当多的不寻常的想法。对于代码的复杂性:链表不需要考虑容量或处理重新分配,不需要移动条目,既不需要插入,也不需要删除。插入实际上是带有链表的两行代码。您无法通过数组接近它。
    【解决方案4】:

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

    我几乎可以肯定,至少对于那些“多种”语言中的大多数来说:

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

    一些观察:

    • 即使使用separate chains 而不是open addressing 的冲突解决方案,因为“最通用的哈希表实现”是非常值得怀疑的选择。我个人的信念——这不是正确的选择。

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

    • 回到主题问题本身,我看不出为什么链表应该比数组更好的任何概念上的原因。我可以很容易地想象,事实上,数组在现代硬件上更快/在现代语言运行时/操作系统中使用现代内存分配器消耗更少的内存。特别是当哈希表的键是原始的或复制的结构时。你可以在这里找到一些支持这种观点的论据:http://en.wikipedia.org/wiki/Hash_table#Separate_chaining_with_other_structures

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

    我是不是误会了什么?

    不,你没有误解任何事情,你的问题是合法的。这是一个相当混乱的例子,当某事以某种特定的方式完成时,不是出于强烈的原因,而是在很大程度上是偶然的。

    【讨论】:

    • 有趣的答案+1。但是您如何支持您的说法,即开放寻址通常是正确的选择。由于聚类效应,所有教科书都将其称为“有问题”
    • @Jim 集群效应在高密度上变得相当可观(这真的很少需要,我再次坚信,正如我在下一段中所说的那样)。在低密度时,平均链长约为 1.1-1.3 个项目,(即大多数“链”只是唯一的条目),聚类,是吗?你可以说“如果有人想要 DOS 我们怎么办?”真实但罕见的案例。但现在在 Java 中,最通用的哈希映射实现考虑了这种罕见的情况。
    • @Jim 好吧,我理解他们为什么在 Java 8 中这样做,因为一些旧的 Web 服务器软件只是使用通用映射来缓存公开接受的数据,没有人会重写这个软件,但是从链表切换当链大于 8 时到 rb-trees 根本不会影响正常的地图性能。
    • @Jim btw 从 chains as arrays (在这个问题中讨论)切换到 rb-trees 也是可能的,任何方式链接的条目都不会在这个开关上重用,因为 rb 条目有不同的类。
    • 我喜欢开放寻址 - 当您进行插入和查找且没有擦除操作时,对于小对象可以快 10 倍左右。支持擦除需要区分 never used 和 in-use 和 was-in-use 桶,如果你的表在没有调整大小的情况下快速插入和擦除,never used 与 was-in-use 桶的比率会越来越低,这意味着您在进行查找、插入、擦除等操作时会花费时间迭代使用过的存储桶。完整的重新哈希通常是恢复最佳性能的唯一实用方法,这对于实时来说并不是很好应用程序。
    【解决方案5】:

    如果使用数组实现,在插入的情况下,由于重新分配而代价高昂,而在链表的情况下不会发生。

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

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2014-06-05
      • 1970-01-01
      • 1970-01-01
      • 2014-10-28
      • 1970-01-01
      • 2015-02-28
      • 2023-03-13
      相关资源
      最近更新 更多