为啥哈希表扩展通常通过将大小加倍来完成?

Posted

技术标签:

【中文标题】为啥哈希表扩展通常通过将大小加倍来完成?【英文标题】:Why are hash table expansions usually done by doubling the size?为什么哈希表扩展通常通过将大小加倍来完成? 【发布时间】:2011-01-23 02:11:30 【问题描述】:

我对哈希表进行了一些研究,并且一直遵循经验法则,即当有一定数量的条目(最大或通过 75% 之类的负载因子)时,应该扩展哈希表.

几乎总是,建议将哈希表的大小加倍(或加倍加 1,即 2n+1)。但是,我还没有找到一个很好的理由。

为什么要将大小增加一倍,而不是增加 25%,或者将其增加到下一个素数或下 k 个素数(例如三个)的大小?

我已经知道,选择一个素数的初始哈希表大小通常是一个好主意,至少如果您的哈希函数使用诸如通用哈希之类的模数。我知道这就是为什么通常建议使用 2n+1 而不是 2n(例如,http://www.concentric.net/~Ttwang/tech/hashsize.htm)

然而,正如我所说,我还没有看到任何真正的解释来解释为什么加倍或加倍实际上是一个不错的选择,而不是其他选择新哈希表大小的方法。

(是的,我已经阅读了关于哈希表的***文章 :) http://en.wikipedia.org/wiki/Hash_table

【问题讨论】:

我相信这个问题背后的基本问题可以用一种更通用的方式来表述,而不仅仅是哈希表特有的问题。比如:“为什么许多集合通过将内部数组的大小增加一倍来调整自己的大小?”有关一个很好的解释,请参阅 Pete Kirkham 的回答:***.com/questions/1424826/why-is-vector-array-doubled/… 【参考方案1】:

我在这个网站上阅读了关于增长战略的非常有趣的讨论......只是找不到了。

虽然2 很常用,但已证明它不是最佳值。一个经常被引用的问题是它不能很好地处理分配器方案(通常分配两个块的功率),因为它总是需要重新分配,而实际上可能在同一块中重新分配较小的数量(模拟就地增长)从而更快。

因此,例如,VC++ 标准库在对邮件列表进行了广泛讨论后,使用了1.5 的增长因子(如果正在使用首先适合的内存分配策略,理想情况下应该是黄金数字)。推理解释here:

如果任何其他向量实现使用 2 以外的增长因子,我会很感兴趣,我也想知道 VC7 使用 1.5 还是 2(因为我这里没有那个编译器)。

更喜欢 1.5 而不是 2 是有技术原因的——更具体地说,是更喜欢小于 1+sqrt(5)/2 的值。

假设您使用的是首次拟合内存分配器,并且您正在逐步追加到向量。然后每次重新分配时,分配新内存,复制元素,然后释放旧内存。这留下了一个空白,最终能够使用该内存会很好。如果向量增长过快,对于可用内存来说总是太大。

事实证明,如果增长因子为>= 1+sqrt(5)/2,那么新的内存对于到目前为止已经留下的洞来说总是太大了;如果是< 1+sqrt(5)/2,新内存最终会适合。所以 1.5 小到足以让内存被回收。

当然,如果增长因子是>= 2,那么新的内存对于迄今为止留下的洞来说总是太大了;如果是< 2,新内存最终会适合。想必(1+sqrt(5))/2的原因是……

初始分配是s。 第一次调整大小是k*s。 第二个调整大小是k*k*s,它将适合孔当夫k*k*s <= k*s+s,即当夫k <= (1+sqrt(5))/2

...孔可以尽快回收。

它可以通过存储它之前的大小来斐波那契地增长。

当然,应该根据内存分配策略量身定制。

【讨论】:

这是针对哪个实现的,您可以链接到讨论吗? @Praxeolitic:我有一条金鱼的记忆,所以很遗憾,我不记得 6 年前(已经!)的确切含义了。话虽如此,查询(Google)显示,2003 年在comp.lang.c++.moderated 上进行了讨论,当时 Dirkumware(VC++)已经使用了 1.5 的向量。 2004 年有一个关于 gcc 的讨论,关于从 2 切换到 1.5,但考虑到这个 thread from 2013,它当时并没有膨胀。 很好,我以前没见过那个黄金数字的说法。对于像我一样对 comp.lang.c++.moderated 线程感到困惑的任何人,这个explanation 更完整一些。 @Praxeolitic:链接简洁!我个人(现在)想知道 1.5 是否仍然比 2 更好,因为现代分配器如 jemalloc/tcmalloc 使用大小桶(桶之间有自定义增长因子),这不太清楚;它可能适用于 C++11 和简单的移动构造函数,在这种情况下,您可以使用 realloc 将项目保持在相同大小的桶中......但实际上我们在这里看到的是 std::allocator 设计中的限制 =>当你请求 N 字节的内存时,它不仅应该给你一个至少 N 字节的内存块,还应该告诉你该块包含多少字节 让我们看看,Facebook 的 FBVector 中的一些逻辑是 here 在函数 computePushBackCapacity 下。它首先根据存储桶的大小在较小时增加(实际上只是增长因子 2),然后在中等大小时增长 1.5,最后再次增长 2(我猜是使用整个内存页面)。【参考方案2】:

散列容器特定的大小加倍的一个原因是,如果容器容量始终是 2 的幂,那么可以使用通用模数将散列转换为偏移量,而不是使用通用模数来实现相同的结果位移。取模是一种缓慢的运算,原因与整数除法缓慢的原因相同。 (在程序中发生的任何其他事情的上下文中,整数除法是否“慢”当然取决于大小写,但它肯定比其他基本整数算术慢。)

【讨论】:

【参考方案3】:

例如,如果调整大小是按恒定增量进行的,则哈希表不能声明“摊销恒定时间插入”。在这种情况下,调整大小的成本(随着哈希表的大小而增长)将使一次插入的成本与要插入的元素总数成线性关系。因为随着表的大小调整大小变得越来越昂贵,所以它必须“越来越少”发生以保持插入的摊销成本不变。

大多数实现允许平均存储桶占用增长到在调整大小之前预先固定的界限(0.5 到 3 之间的任意值,这些都是可接受的值)。使用这个约定,在调整平均存储桶占用量之后,它的大小就变成了这个界限的一半。通过加倍调整大小将平均存储桶占用保持在宽度 *2 的范围内。

附注:由于统计聚类,如果您希望多个存储桶最多具有一个元素(忽略缓存大小的复杂影响的最大查找速度),则必须将平均存储桶占用率降低至 0.5,或者如果您想要最少数量的空桶(对应于浪费的空间),则最高为 3。

【讨论】:

@andras 对。 1.5 和 3 只是合理值的示例,加倍策略使负载因子在这些值之间变化。我采用了我知道的在我最喜欢的语言中使用的值,但它们并没有什么特别之处。 @andras 我改变了公式。如果您出于好奇查了一下,.Net 和 Java 的值是什么? @andras 因为那样,摊销的插入时间实际上将由调整大小的成本决定(调整大小时必须再次计算所有存储值的哈希)。当然,您可以使用 1.5 或 3,而 2 的因数恰好可以以可接受的方式平衡各种成本。这纯粹是启发式的,任何大于 1 的因子都会给出相同的渐近复杂度。即使你知道你想要什么样的内存/速度权衡,也没有最佳值,因为一切都取决于提供的哈希和相等函数的成本。 @andras 好吧,如果你深入了解它,是的,一方面是大量调整大小操作所花费的时间,另一方面是调整大小后浪费的空间。为什么要删除带有 .Net 和 Java 中的值的评论? @andras 新版本的劣质真的太糟糕了,因为我根据您的建议(反复)编辑了它。您是否考虑过编辑您的自己的答案以向我们提供您对哈希表的看法? PS:请随意给这个答案投票。【参考方案4】:

同样的道理也适用于向量/数组列表实现的两倍大小,请参阅this answer。

【讨论】:

【参考方案5】:

如果您不知道最终将使用多少个对象(比如说 N), 通过将空间加倍,您最多可以进行 log2N 次重新分配。

我假设如果你选择一个正确首字母“n”,你的几率会增加 2*n + 1 将在随后的重新分配中产生素数。

【讨论】:

【参考方案6】:

在扩展任何类型的集合时将内存加倍是一种常用的策略,可以防止内存碎片并且不必经常重新分配。正如您指出的那样,可能有理由拥有质数的元素。在了解您的应用程序和数据后,您还可以预测元素数量的增长,从而选择另一个(更大或更小的)增长因子,而不是加倍。

在库中找到的通用实现正是:通用实现。他们必须专注于在各种不同情况下成为合理的选择。了解上下文后,几乎总是可以编写更专业、更高效的实现。

【讨论】:

以上是关于为啥哈希表扩展通常通过将大小加倍来完成?的主要内容,如果未能解决你的问题,请参考以下文章

为啥哈希表的大小为 127(素数)优于 128?

C 无法调整哈希表的大小

为啥哈希表键通常被认为是无序的?

使用线性探测实现 Hashtable 时调整大小权衡

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

哈希表