动态分配的阵列的理想增长率是多少?

时间:2009-07-08 20:15:49

标签: arrays math vector arraylist dynamic-arrays

C ++有std :: vector,Java有ArrayList,许多其他语言都有自己的动态分配数组。当动态数组空间不足时,它会重新分配到更大的区域,旧值将被复制到新数组中。这种阵列性能的核心问题是阵列的大小增长速度。如果你总是只能变得足够大以适应当前的推动,那么你每次都会重新分配。因此,将数组大小加倍,或将其乘以1.5倍是有意义的。

是否有理想的生长因子? 2倍? 1.5倍?理想上,我的意思是数学上合理,最佳平衡性能和浪费的记忆。理论上,我认识到,鉴于您的应用程序可能具有任何可能的推送分布,这在某种程度上取决于应用程序。但是我很想知道是否有一个“通常”最好的值,或者在一些严格的约束条件下被认为是最好的。

我听说有一篇论文在这个地方,但我一直都找不到。

11 个答案:

答案 0 :(得分:88)

我记得多年前读过为什么1.5比两个更受欢迎,至少应用于C ++(这可能不适用于托管语言,运行时系统可以随意重定位对象)。

原因是:

  1. 假设你从一个16字节的分配开始。
  2. 当您需要更多时,分配32个字节,然后释放16个字节。这在内存中留下了16个字节的漏洞。
  3. 当您需要更多时,分配64个字节,释放32个字节。这留下了一个48字节的洞(如果16和32相邻)。
  4. 当您需要更多时,您可以分配128个字节,从而释放64个字节。这留下了一个112字节的漏洞(假设之前的所有分配都是相邻的)。
  5. 等等等。
  6. 这个想法是,在2倍扩展的情况下,没有时间点产生的漏洞足够大,可以重复用于下一次分配。使用1.5x分配,我们改为:

    1. 以16个字节开头。
    2. 如果需要更多,请分配24个字节,然后释放16个字节,留下16个字节的空洞。
    3. 当你需要更多时,分配36个字节,然后释放24个字节,留下40个字节的空洞。
    4. 当你需要更多时,分配54个字节,然后释放36个字节,留下76个字节的空洞。
    5. 当你需要更多时,分配81个字节,然后释放54个,留下130个字节的空洞。
    6. 当您需要更多时,请使用130字节孔中的122个字节(向上舍入)。

答案 1 :(得分:39)

完全取决于用例。您是否更关心浪费复制数据(以及重新分配数组)或额外内存的时间?阵列要持续多久?如果它不会长时间存在,使用更大的缓冲区可能是一个好主意 - 惩罚是短暂的。如果它会徘徊(例如在Java中,进入老一代和老一代),那显然更多的是惩罚。

没有“理想的增长因素”。它不仅仅是理论上依赖于应用程序,它依赖于 应用程序。

2是一个非常常见的增长因素 - 我很确定这是.NET使用的ArrayListList<T>。 Java中的ArrayList<T>使用1.5。

编辑:正如Erich所指出的,.NET中的Dictionary<,>使用“加倍大小然后增加到下一个素数”,以便在桶之间合理分配哈希值。 (我确信我最近看到的文档表明素数实际上并不适合分发哈希桶,但这是另一个答案的论据。)

答案 2 :(得分:38)

理想情况下(在 n →∞的限制内),it's the golden ratio:φ= 1.618 ...

在实践中,你想要一些接近的东西,比如1.5。

原因是您希望能够重用较旧的内存块,以利用缓存并避免不断使操作系统为您提供更多内存页。您要解决的等式可以确保减少到 x n - 1 - 1 = x n + 1 - x n ,其解决方案接近 x =φ表示大 n

答案 3 :(得分:11)

在回答这样的问题时,一种方法就是“欺骗”并看看流行的图书馆做了什么,假设一个广泛使用的图书馆至少没有做出可怕的事情。

因此,只需快速检查,Ruby(1.9.1-p129)在追加到数组时似乎使用1.5x,而Python(2.6.2)使用1.125x加上常量(在Objects/listobject.c中):

/* This over-allocates proportional to the list size, making room
 * for additional growth.  The over-allocation is mild, but is
 * enough to give linear-time amortized behavior over a long
 * sequence of appends() in the presence of a poorly-performing
 * system realloc().
 * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
 */
new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

/* check for integer overflow */
if (new_allocated > PY_SIZE_MAX - newsize) {
    PyErr_NoMemory();
    return -1;
} else {
    new_allocated += newsize;
}
上面的

newsize是数组中元素的数量。请注意,newsize被添加到new_allocated,因此使用bitshifts和三元运算符的表达式实际上只是计算过度分配。

答案 4 :(得分:7)

假设您将数组大小增加x。所以假设你从大小T开始。下次生成数组时,其大小将为T*x。然后它将是T*x^2,依此类推。

如果您的目标是能够重用之前创建的内存,那么您需要确保分配的新内存小于您解除分配的先前内存的总和。因此,我们有这种不平等:

T*x^n <= T + T*x + T*x^2 + ... + T*x^(n-2)

我们可以从两侧移除T.所以我们得到了这个:

x^n <= 1 + x + x^2 + ... + x^(n-2)

非正式地说,我们所说的是在nth分配时,我们希望所有先前释放的内存大于或等于第n个分配时的内存需求,以便我们可以重用以前释放的内存。 / p>

例如,如果我们希望能够在第3步(即n=3)执行此操作,那么我们有

x^3 <= 1 + x 

这个等式适用于所有x,0 < x <= 1.3(大致)

请参阅以下不同n的x:

n  maximum-x (roughly)

3  1.3

4  1.4

5  1.53

6  1.57

7  1.59

22 1.61

请注意,自2以来,增长因素必须小于x^n > x^(n-2) + ... + x^2 + x + 1 for all x>=2

答案 5 :(得分:4)

这取决于。有些人分析常见的使用案例以找到最佳数字。

我看过1.5x 2.0x phi x,之前使用的是2的功率。

答案 6 :(得分:2)

如果你有一个超过数组长度的分布,并且你有一个效用函数可以说明你有多浪费空间而不是浪费时间,那么你绝对可以选择一个最佳的大小调整(和初始大小)策略。

使用简单常数倍的原因显然是每个附加都具有摊销的常数时间。但这并不意味着您不能为小尺寸使用不同(更大)的比率。

在Scala中,您可以使用查看当前大小的函数覆盖标准库哈希表的loadFactor。奇怪的是,可调整大小的数组只增加了一倍,这是大多数人在实践中所做的事情。

我不知道任何加倍(或1.5 * ing)数组实际上会消除内存错误并且在这种情况下增长更少。看来,如果你有一个巨大的单个数组,你就是想这样做。

我还要补充一点,如果你将可调整大小的数组保持足够长的时间,并且随着时间的推移你喜欢空间,那么最初可以显着地分配(对于大多数情况)并在然后重新分配到恰当的大小时是有意义的。你已经完成了。

答案 7 :(得分:1)

我同意Jon Skeet,即使我的理论家朋友坚持认为,当将因子设置为2x时,可以证明这是O(1)。

每台机器上的CPU时间和内存之间的比例是不同的,因此因素也会有所不同。如果你有一台拥有千兆字节ram和慢速CPU的机器,那么将元素复制到一个新阵列比在快速机器上复杂得多,后者可能会减少内存。对于统一的计算机来说,这是一个理论上可以回答的问题,在实际场景中根本不能帮助你。

答案 8 :(得分:0)

我知道这是一个老问题,但有些事情似乎每个人都缺失了。

首先,这乘以2:size&lt;&lt; 1.这是乘以任何在1和2之间:int(float(size)* x),其中x是数字,*是浮点数学,处理器必须运行其他指令用于在float和int之间进行转换。换句话说,在机器级别,加倍需要一个非常快速的指令来查找新的大小。乘以1到2之间的东西需要至少一条指令来将大小转换为浮点数,一条指令要相乘(这是浮点乘法,所以它可能需要至少两倍的周期,如果不是4或者甚至是8倍,以及一个返回int的指令,并假设您的平台可以在通用寄存器上执行浮点运算,而不需要使用特殊寄存器。简而言之,您应该期望每次分配的数学运算至少是简单左移的10倍。如果您在重新分配期间复制了大量数据,那么这可能没什么区别。

其次,可能是最大的挑战者:每个人似乎都认为被释放的内存既与自身连续,又与新分配的内存相邻。除非您自己预先分配所有内存然后将其用作池,否则几乎肯定不是这样。 OS 可能偶尔最终会这样做,但大多数时候,会有足够的可用空间碎片,任何一半体面的内存管理系统都能找到一个小洞,你的记忆将会恰到好处。一旦你真的有点块,你更有可能最终得到连续的部分,但到那时,你的分配足够大,你不会经常做足够的事情,因为它不再重要。简而言之,想象使用一些理想数字可以最有效地利用可用内存空间是很有趣的,但实际上,除非你的程序是在裸机上运行,​​否则它不会发生(例如,没有操作系统)在它下面作出所有决定。)

我对这个问题的回答?不,没有理想的数字。这是特定应用程序,没有人真正尝试过。如果你的目标是理想的内存使用,那你几乎没有运气。对于性能,较少频繁的分配更好,但如果我们只是这样,我们可以乘以4甚至8!当然,当Firefox一次性从1GB跳到8GB时,人们会抱怨,所以这甚至没有意义。以下是一些经验法则:

如果无法优化内存使用,至少不要浪费处理器周期。乘以2至少比进行浮点数学运算快一个数量级。它可能不会产生巨大的差异,但它至少会产生一些影响(尤其是在更频繁和更小的分配期间的早期)。

不要过度思考它。如果你只花了4个小时试图弄清楚如何做已经完成的事情,那你就浪费了你的时间。说实话,如果有比* 2更好的选择,那么几十年前就可以在C ++矢量类(以及许多其他地方)中完成。

最后,如果真的想要优化,请不要为这些小东西烦恼。现在几天,没有人关心浪费4KB的内存,除非他们正在使用嵌入式系统。当你得到1GB的每个介于1MB和10MB之间的对象时,加倍可能是太多了(我的意思是,这是100到1,000个对象之间)。如果您可以估算预期的扩张率,您可以将其调整到某个点的线性增长率。如果你希望每分钟大约有10个物体,那么每步增加5到10个物体尺寸(每30秒一分钟一次)可能就好了。

归根结底是,不要过度思考,优化您的能力,并根据您的需要自定义您的应用程序(和平台)。

答案 9 :(得分:0)

另外两美分

  • 大多数电脑都有虚拟内存!在物理内存中,您可以随处随机页面,这些页面在程序的虚拟内存中显示为单个连续空间。间接的解析由硬件完成。虚拟内存耗尽是32位系统的问题,但它确实不再是问题。因此,填充 hole 不再是一个问题(特殊环境除外)。由于Windows 7甚至Microsoft支持64位而无需额外的努力。 @ 2011
  • 使用任何 r &gt;达到O(1) 1因素。相同的数学证明不仅适用于2作为参数。
  • r = 1.5可以使用old*3/2计算,因此不需要浮点运算。 (我说/2因为如果他们认为合适,编译器会在生成的汇编代码中用位移代替它。)
  • MSVC选择了 r = 1.5,因此至少有一个主编译器不使用2作为比率。

正如有人提到的那样2感觉好于8.而且2感觉好于1.1。

我的感觉是1.5是一个很好的默认值。除此之外,它取决于具体情况。

答案 10 :(得分:0)

最高投票和接受的答案都很好,但都没有回答问题中要求“数学上合理的”“理想增长率”、“最佳平衡性能和浪费内存”的部分。 (票数第二的答案确实试图回答这部分问题,但其推理令人困惑。)

这个问题完美地指出了必须平衡的 2 个注意事项,性能和浪费的内存。如果您选择的增长率太低,则性能会受到影响,因为您会过快地用完额外空间并且不得不过于频繁地重新分配。如果您选择的增长率太高,例如 2 倍,则会浪费内存,因为您永远无法重用旧的内存块。

特别是,如果你 do the math1 你会发现增长率的上限是黄金比例 ϕ = 1.618… 。大于 ϕ 的增长率(如 2x)意味着您将永远无法重用旧的内存块。仅略低于 ϕ 的增长率意味着您将无法重用旧内存块,直到经过多次重新分配,在此期间您将浪费内存。因此,您希望在不牺牲太多性能的情况下尽可能低于 ϕ

因此,我建议将这些候选对象用于“数学上合理的”“理想增长率”、“最佳平衡性能和浪费的内存”:

  • ≈1.466x(x4=1+x+x2的解) 允许仅在 3 次重新分配后重用内存,一次比 1.5 倍快,而重新分配的频率仅略高
  • ≈1.534x(x5=1+x+x2的解+x3) 允许在 4 次重新分配后重用内存,与 1.5x 相同,同时重新分配的频率略低以提高性能
  • ≈1.570x(x6=1+x+x2 的解+x3+x4) 只允许在 5 次重新分配后重用内存,但会重新分配为了进一步提高性能(勉强)

很明显,那里有一些收益递减,所以我认为全局最优解可能就是其中之一。另外,请注意,无论全局最优值实际上是什么,1.5x 都是一个很好的近似值,并且具有非常简单的优点。

1感谢@user541686 这个优秀的来源。