为什么哈希表在调整大小时要扩大一倍?

11

在Java中检查并在网上搜索散列表的代码示例,似乎调整表格大小是通过加倍来完成的。
但大多数教科书都说最好的表格大小是质数。
所以我的问题是:
加倍的方法是因为:

  1. 实现容易,还是
  2. 寻找质数太低效了(但我认为通过 n+=2 找到下一个质数,并使用模运算测试素性的时间复杂度是O(loglogN),这很便宜),或者
  3. 这是我的误解,只有某些哈希表变量需要使用质数表大小?

更新:
教科书中提出的使用质数的方式是必须的,以确保某些属性可以工作(例如,二次探测需要一个质数大小的表来证明例如如果表不满项X将被插入)。
发布的重复链接通常询问增加任何百分比,例如25%或下一个质数,所接受的答案说明我们加倍是为了使调整大小操作“罕见”,以便我们可以保证平摊时间。
这并没有回答拥有质数大小的表格大小,并使用比加倍更大的质数进行调整大小的问题。 因此,想要保留质数大小的属性,同时考虑调整大小开销。


1
在https://dev59.com/7nM_5IYBdhLWcg3w-4Zg#1147232上还有一个很好的讨论。特别关注包含“所以你依赖于哈希函数不使用偶数乘数”的部分。 - yshavit
1
当表的大小是2的幂时,查找速度更快,因为可以使用位掩码进行余数计算,但这更多是一种微观优化。 - David Ehrmann
1
Java的哈希表是通过外部链接实现的,所以没有问题。我不明白这个问题。 - amit
1
我们应该记住的是,内置的Java集合在某种程度上都是基于妥协的:它们必须对极其广泛的使用模式工作得相当好。在应用程序中,您可以通过重新实现使用更适合特定用例的算法来摆脱集合,在其他情况下性能较差。这就是很多人所做的。 - biziclop
@amit:你有时间查看我们讨论的内容吗?我对你的意见很感兴趣。 - Jim
显示剩余20条评论
2个回答

6

问:但是大多数教科书都说表格的最佳大小是质数。

关于质数大小的问题:

就素数大小而言,这取决于您选择的冲突解决算法。有些算法需要素数表大小(双重散列,二次探测),而其他算法则不需要,并且它们可以从2的幂表大小中获益,因为它允许非常便宜的模运算。然而,当最接近的“可用表大小”相差2倍时,哈希表的内存使用可能是不可靠的。因此,即使使用线性散列或分离链接,您也可以选择非2的幂大小。在这种情况下,反过来,值得选择特定的素数大小,因为:

如果您选择素数表大小(无论是因为算法要求还是因为您对2的幂大小的内存使用不可靠感到不满意),则表格插槽计算(通过表格大小取模)可以与哈希组合使用。请参见this answer以了解更多信息。
Neil Coffey的答案指出,当哈希函数分布不好时,2的幂表大小是不可取的,但这一点并不实用,因为即使您有一个糟糕的哈希函数,将其avalanching并仍然使用2的幂大小也比切换到素数表大小更快,因为单个整除在现代CPU上仍然比许多乘法和移位操作慢,这些操作是良好的avalanching函数所需的,例如MurmurHash3。

Q: 说实话,我有点迷糊你是否推荐使用素数。似乎这取决于哈希表的变体和哈希函数的质量?

  1. 哈希函数的质量并不重要,您总是可以通过 MurMur3 平衡来“改进”哈希函数,这比从2的幂转换为素数表大小更便宜,详见上文。

  2. 我建议选择素数大小,使用 QHash 或者二次探测算法(不是同一个),仅当您需要精确控制哈希表负载因子以及可预测的高实际负载时。使用2的幂作为哈希表大小,最小调整因子为2,通常我们不能保证哈希表的实际负载因子会高于0.5。请参阅此答案。

    否则,我建议使用2的幂大小的哈希表和线性探测。

问:为什么会选择扩容策略是倍增?
是因为它易于实现,还是

基本上,在很多情况下是这样的。 请看关于负载因子的详细回答:

负载因子不是哈希表数据结构的必要部分——它是定义动态系统行为规则的方式(扩容/缩容哈希表是一种动态系统)。

此外,在我看来,在95%的现代哈希表案例中,这种方式过于简化,动态系统表现不佳。

什么是倍增?它只是最简单的调整大小策略。这个策略可以是任意复杂的,以最优的方式执行您的用例。它可以考虑当前哈希表大小、增长强度(自上次调整大小以来进行了多少次get操作)等因素。没有人禁止您实现这样的自定义调整大小逻辑。

问:查找质数太低效了吗(但我认为通过n+=2寻找下一个质数并使用模运算测试素性是O(loglogN),这是便宜的)

有一个好的做法是预先计算一些素数哈希表大小的子集,以便在运行时使用二分查找来选择它们之间的大小。请参见双哈希容量列表和解释QHash容量。或者,甚至可以使用直接查找,这非常快速。

问:或者这是我的误解,只有某些哈希表变体需要素数表大小?

是的,只有某些类型需要,详见上文。


@Jim,“二次探测版本的负载表应该小于50%才能有效”我不明白你的意思。任何哈希表在0.5负载因子下都比高负载因子更有效。可能你混淆了二次探测和线性探测。 - leventov
@Jim 线性探测 != 外部链接,线性探测是一种用于开放寻址的冲突解决技术。 - leventov
我误解了你的句子。我认为我们都同意当哈希表半空时,二次探测法是最有效的,对吗?所以我不清楚你的意思是什么,当你需要精确控制哈希表负载因子和可预测的高实际负载时。你的意思是我可以容忍半空的表格吗? - Jim
你的意思是我可以容忍一半空的表格吗?如果 0.5 负载因子对你来说可以接受,选择线性探测和 2 的幂次方表格大小。如果你需要保证负载因子在 0.5 以上,你不能选择 2 的幂次方大小,因为你无法保证这一点。在这种情况下,我建议选择质数大小和二次探测。 - leventov
@Jim 只需选择尽可能小的负载因子即可。没有确定的“红色”阈值。这取决于您分配给进程的RAM /内存量和数据量。例如,如果您有5 GB的内存和4 GB的数据,则应选择0.8。我几乎无法想象有人需要超过0.9。 - leventov
显示剩余24条评论

3
Java的HashMap (java.util.HashMap) 在一个链表(或[自JDK8起]树,取决于桶的大小和溢出)中链接桶碰撞。
因此,关于二次探测函数的理论不适用。似乎多年来,“在哈希表中使用质数大小”的信息已经与其适用的情况脱钩...
使用2的幂有一个优点(如其他答案所述),可以通过位掩码将哈希值减少到表项中。整数除法相对较昂贵,在高性能情况下,这可以提高效率。
我将观察到,“在哈希化时重新分配碰撞链对于从2的幂转换为2的幂的表格来说是易如反掌的”。
请注意,当使用2的幂时,扩大为两倍大小的再哈希会基于哈希码的“下一个”位将每个桶分成两个桶。也就是说,如果哈希表有256个桶并且因此使用哈希值的最低8位,那么重新哈希将基于第9位将每个碰撞链分成两部分,并且仍然保留在同一个桶B(第9位为0)或进入桶B+256(第9位为1)。这种分裂可以维护/利用桶处理方法。例如,java.util.HashMap将小桶以插入的相反顺序排序,然后将它们分成两个服从该顺序的子结构。它通过哈希码将大桶保持在二叉树中并类似地分割该树以保留该顺序。 NB:这些技巧直到JDK8才被实现。
(我非常确定)Java.util.HashMap只会增加大小(永远不会减小)。但是将哈希表减半与将其加倍相似。
这种策略的一个“缺点”是,并未明确要求Object实现者确保哈希码的低位是分布良好的。完全有效的哈希码总体上可能分布良好,但在其低位上分布不良。因此,遵守hashCode()的一般契约的对象在实际使用中可能仍然失败!Java.util.HashMap通过对提供的hashCode()实现应用额外的哈希“扩散”来减轻这种情况。那个“扩散”非常快粗糙(xors高16位和低位)。
如果对象实现者没有意识到,哈希码中的偏差(或缺乏偏差)可能会对使用哈希的数据结构的性能产生重要影响。
就记录而言,我基于此源代码进行了分析: http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/classes/java/util/HashMap.java

关于您提到的哈希表减半的技术,我从未读过任何暗示该技术的教科书。这种技术在实现中真正被使用了吗? - Jim
@Jim 是的,它通过加倍来调整大小。 "诀窍" 是意识到碰撞桶被整齐地减半,从而简化了加倍。在 JDK8 之前,代码显然没有使用 那个 技巧。减半是一个经常被忽视的操作。但是对于复杂或长时间运行的应用程序可能是必要的。我注意到 Java.util.HashMap 没有这样做。你是否注意到服务器受益于定期重新启动?不卸载类和内部“缓冲区”大小不会缩小等问题会导致系统不泄漏却不必要地持有空闲资源。 - Persixty
请查看此链接:http://www.cplusplus-soup.com/2010/01/freedelete-not-returning-memory-to-os.html。 - Jim
还有这个问题https://dev59.com/VF0a5IYBdhLWcg3wVHXU - Jim
在GNU平台上,请查看http://www.gnu.org/software/libc/manual/html_node/Malloc-Tunable-Parameters.html#Malloc-Tunable-Parameters,特别是`M_TRIM_THRESHOLD`。对于某些(甚至许多)应用程序来说,获取资源而永不归还是合适的。当然,并非所有情况都适用,绝对不是任何C++标准的要求。我可以坐在这里观察System Monitor中的C++程序分配达到2Gb(并减慢整个系统),然后降至约40Kb,看到系统恢复生机。放弃资源是真实的! - Persixty
显示剩余7条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接