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


83

C ++具有std :: vector,而Java具有ArrayList,许多其他语言都有其自己的动态分配数组形式。当动态数组空间不足时,它会重新分配到更大的区域中,并将旧值复制到新数组中。这种阵列性能的核心问题是阵列大小增长的速度。如果您总是只增长到足以容纳当前推送的大小,则最终每次都会重新分配。因此,将数组大小加倍或乘以1.5倍是有意义的。

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

我听说某处有一篇论文,但是我一直找不到。

Answers:


43

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

没有所谓的“理想增长因素”。这不只是理论上依赖于应用程序,这是绝对依赖于应用程序。

2是一个很常见的生长因子-我敢肯定,这就是ArrayListList<T>.NET中的用途。ArrayList<T>在Java中使用1.5。

编辑:正如Erich指出的那样,Dictionary<,>.NET在.NET中使用“大小加倍,然后增加到下一个质数”,以便哈希值可以在存储桶之间合理分配。(我敢肯定,我最近看过的文档表明,素数对于散列桶的分布实际上并不那么好,但这是另一个答案的理由。)


102

我记得很多年前读过的文章,为什么至少在C ++上首选1.5而不是两个(这可能不适用于托管语言,运行时系统可以随意重定位对象)。

原因是这样的:

  1. 假设您从16字节分配开始。
  2. 当您需要更多时,可以分配32个字节,然后释放16个字节。这在内存中留下了16个字节的空缺。
  3. 当您需要更多时,可以分配64个字节,以释放32个字节。这就留下了一个48字节的孔(如果16和32是相邻的)。
  4. 当您需要更多时,可以分配128个字节,以释放64个字节。这就留下了一个112字节的漏洞(假设所有先前的分配都相邻)。
  5. 等等等等。

这个想法是,通过2倍的扩展,没有时间点会导致产生的漏洞变得足够大,无法再用于下一次分配。使用1.5倍分配,我们改为:

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

5
我发现的一个随机论坛帖子(objectmix.com/c/…)的原因与此类似。张贴者声称(1 + sqrt(5))/ 2是重用的上限。
纳夫

19
如果该主张是正确的,则phi(==(1 + sqrt(5))/ 2)确实是要使用的最佳数字。
克里斯·杰斯特·杨

1
我喜欢这个答案,因为它揭示了1.5倍和2倍的基本原理,但是从技术上讲,乔恩(Jon's)对于我所说的方式是最正确的。我应该问过去为什么建议使用1.5:p
Joseph Garvin 2010年

6
Facebook在其FBVector实现中使用1.5,此处的文章解释了为什么1.5对于FBVector最佳。
csharpfolk

2
@jackmott是的,正像我的回答所指出的那样:“这可能不适用于托管语言,运行时系统可以在其中托管对象的位置。”
克里斯·杰斯特·杨

47

理想的情况下(在极限作为Ñ →∞),它是黄金比例:φ= 1.618 ...

实际上,您需要接近1.5的值。

原因是您希望能够重用较旧的内存块,以利用缓存并避免不断使OS为您提供更多的内存页。您要解决的方程式将简化为x n − 1 − 1 = x n + 1x n,对于大n,其解接近x = ϕ 。


15

在回答这样的问题时,一种方法是仅“欺骗”并查看流行的库所做的工作,前提是假定广泛使用的库至少没有做任何可怕的事情。

因此,只需快速检查一下,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,因此具有位移和三元运算符的表达式实际上只是在计算超额分配。


因此它将数组从n增长到n +(n / 8 +(n <9?3:6)),这意味着在问题的术语中,增长因子为1.25x(加上一个常数)。
ShreevatsaR

它不是1.125x加上常数吗?
杰森·克赖顿

10

假设您将数组大小增加了x。因此,假设您从size开始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个分配时的内存需求,以便我们可以重用先前释放的内存。

例如,如果我们希望能够在第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


您似乎声称已经可以在第二次分配中以1.5的比例重用先前释放的内存。这是不正确的(请参见上文)。让我知道我是否误解了你。
2013年

在第二次分配时,您正在分配1.5 * 1.5 * T = 2.25 * T,而在此之前您将要进行的总释放是T + 1.5 * T = 2.5 * T。因此2.5大于2.25。
CEGRD

嗯,我应该仔细阅读;您只是说要分配的总内存将大于在第n个分配处分配的内存,而不是您可以在第n个分配处重用它。
awx

4

真的要看 有人分析常用情况以找到最佳数量。

我见过1.5倍2.0倍phi x,以前使用过2的幂。


hi!这是一个很好的数字。我应该从现在开始使用它。谢谢!+1
克里斯·杰斯特·杨

我不明白...为什么要使用phi?它具有什么特性使其适合于此?
杰森·克赖顿

4
@Jason:phi构成了斐波那契数列,因此下一个分配大小是当前大小和先前大小的总和。这允许适度的增长率,快于1.5,但不快于2(关于为什么> = 2并不是一个好主意,至少对于非托管语言而言,请参见我的帖子)。
克里斯·杰斯特·杨

1
@Jason:另外,根据我的帖子的评论者,任何数字> phi实际上都是一个坏主意。我自己没有做过数学运算来确认这一点,因此请带一点盐。
克里斯·杰斯特·杨

2

如果您在数组长度上有分布,并且有一个实用程序函数可以说您喜欢浪费空间还是浪费时间,那么您绝对可以选择最佳的调整大小(和初始调整大小)策略。

使用简单常数倍的原因很明显,使得每个追加都有摊销常数时间。但这并不意味着您不能为小尺寸使用其他(较大)比率。

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

我不知道任何加倍(或1.5 * ing)的数组实际上可以捕获内存错误,并且在这种情况下增长得更少。看来,如果您有一个巨大的单个阵列,则需要这样做。

我还要补充一点,如果您将可调整大小的数组保持足够长的时间,并且随着时间的推移偏爱空间,那么在大多数情况下,最初将戏剧性地进行总体分配(对于大多数情况),然后在重新分配时将其分配为正确的大小可能是有意义的完成。


2

另外两分钱

  • 大多数计算机都有虚拟内存!在物理内存中,您到处都有随机页面,这些页面在程序的虚拟内存中显示为单个连续空间。间接解决是由硬件完成的。虚拟内存耗尽在32位系统上是个问题,但实际上不再是问题。因此,填充不再是问题(特殊环境除外)。由于Windows 7甚至Microsoft都支持64位,而无需额外的努力。@ 2011
  • 通过任何r > 1因子均可达到O(1)。相同的数学证明不仅适用于2作为参数。
  • r = 1.5可以用计算得出,old*3/2因此不需要浮点运算。(我说/2是因为编译器会在认为合适的情况下将其替换为生成的汇编代码中的位。)
  • MSVC的r = 1.5,所以至少有一个主要编译器不使用2作为比率。

正如某人所提到的,2感觉好于8。2感觉也好于1.1。

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


2
最好使用n + n/2延迟溢出。使用n*3/2将您可能的容量减少一半。
owacoder

@owacoder是的。但是,当n * 3不适合但n * 1.5适合时,我们正在谈论大量内存。如果n是32位超导,则当n为4G / 3时n * 3溢出,即大约1.333G。那是一个巨大的数字。单个分配中有很多内存。如果元素不是1字节,而是每个元素4字节,则再增加一次。想知道用例...
Notinlist,

3
的确,这可能是一种极端情况,但通常情况下,这种情况经常发生。养成寻找可能暗示更好设计的可能溢出或其他行为的习惯绝不是一个坏主意,即使在目前看来似乎牵强。以32位地址为例。现在我们需要64 ...
owacoder

1

我同意乔恩·斯凯特(Jon Skeet)的观点,甚至我的理论设计师朋友也坚持认为,将因子设置为2x时,可以证明这是O(1)。

每台计算机上的cpu时间和内存之间的比率是不同的,因此该因素也会有很大的不同。如果您有一台内存为千兆字节且CPU速度较慢的计算机,则将这些元素复制到新阵列的费用要比一台快速计算机上的昂贵得多,而后者可能会占用更少的内存。从理论上讲,对于一台统一的计算机,这是一个可以回答的问题,在实际情况下,这根本无法帮助您。


2
为了详细说明,加倍数组大小意味着你amotized O(1)插入。这个想法是,每次插入一个元素时,也要从旧数组中复制一个元素。假设您有一个大小为m的数组,其中包含m个元素。当添加元素m + 1时,没有空间,因此您分配了一个大小为2m的新数组。无需复制所有前m个元素,而是每次插入一个新元素时都复制一个。这样可以最大程度地减少方差(节省内存分配),并且一旦插入2m个元素,就将复制旧数组中的所有元素。
hvidgaard 2014年

-1

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

首先,这是乘以2:尺寸<< 1。这是乘以任何1和2之间:INT(浮动(大小)* x),其中x是数量,*是浮点数学和处理器具有运行有关在float和int之间进行转换的附加说明。换句话说,在机器级别,加倍需要一条非常快速的指令来找到新的大小。乘以1到2之间的值至少需要一条指令将大小转换为浮点数,另一条指令进行乘法运算(这是浮点乘法,因此它可能至少需要两倍的周期,如果不是四倍甚至八倍的话),而另一条指令则转换为int,并且假定您的平台可以在通用寄存器上执行浮点数学运算,而不需要使用特殊寄存器。简而言之,您应该期望每个分配的数学运算至少要花费简单左移时间的10倍。但是,如果您在重新分配期间要复制大量数据,则可能没有太大区别。

其次,可能还有一个大的推动力:每个人似乎都认为正在释放的内存既与自身相邻,又与新分配的内存相邻。除非您自己预先分配所有内存,然后将其用作池,否则几乎肯定不是这种情况。操作系统可能偶尔最终这样做,但是在大多数情况下,将会有足够的可用空间碎片,以至于任何一半的像样的内存管理系统都将能够找到一个适合您的内存的小孔。一旦真正掌握了块,就更有可能产生连续的块,但是到那时,您的分配就足够大了,以至于您没有足够频繁地进行分配,就不再重要了。简而言之,有趣的是,使用一些理想的数字可以最有效地利用可用内存空间,但是实际上,除非您的程序在裸机上运行(例如,没有操作系统),否则这不会发生在它下面做出所有决定)。

我对这个问题的回答?不,没有理想的数字。它是如此特定于应用程序,因此没有人真正尝试过。如果您的目标是理想的内存使用率,那么您很不走运。为了提高性能,分配频率越低越好,但是如果我们这样做,则可以乘以4甚至8!当然,当Firefox一次使用1GB的存储空间跃升至8GB的存储空间时,人们会抱怨,所以这甚至毫无意义。这是我会遵循的一些经验法则:

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

不要想太多。如果您只花了4个小时来弄清楚如何做已经完成的事情,那您就浪费了时间。坦白地说,如果有比* 2更好的选择,那将在几十年前的C ++向量类(以及许多其他地方)中完成。

最后,如果您真的想优化,请不要流血。如今,除非有人在嵌入式系统上工作,否则没人会在意4KB的内存浪费。当获得1GB的对象(每个对象介于1MB和10MB之间)时,加倍可能太多了(我的意思是,这在100到1,000个对象之间)。如果您可以估计预期的扩展速度,则可以在特定点将其扩展为线性增长率。如果您希望每分钟大约有10个对象,那么以每步5到10个对象大小(每30秒到一分钟一次)的速度增长可能很好。

最终的结果就是,不要过度思考,优化您可以做的,并在必要时针对应用程序(和平台)进行自定义。


11
当然n + n >> 1是一样的1.5 * n。针对您能想到的每个实际增长因素,提出类似的技巧都是很容易的。
比约恩·林德奎斯特(BjörnLindqvist)

这是个好的观点。但是请注意,在ARM之外,这至少使指令数量增加了一倍。(许多ARM指令(包括add指令)可以对其中一个参数进行可选转换,使您的示例可以在单个指令中运行。尽管如此,大多数体系结构都不能这样做。)否,在大多数情况下,数字加倍从一到两个的指令数不是一个大问题,但是对于数学更复杂的更复杂的增长因素,它可能会对敏感程序产生性能差异。
Rybec Arethdar

@Rybec-尽管可能有一些程序对一两个指令的时序变化很敏感,但是使用动态重新分配的任何程序都不太可能会担心。如果需要精确控制时序,则可能会使用静态分配的存储。
owacoder

我做游戏,在错误的地方,一条或两条指令可能会明显影响性能。就是说,如果内存分配处理得当,那么它应该不会频繁发生以使一些指令有所作为。
里贝克·阿里斯达尔
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.