为啥动态数组必须以几何方式增加其容量以获得 O(1) 摊销的 push_back 时间复杂度?

Posted

技术标签:

【中文标题】为啥动态数组必须以几何方式增加其容量以获得 O(1) 摊销的 push_back 时间复杂度?【英文标题】:Why do dynamic arrays have to geometrically increase their capacity to gain O(1) amortized push_back time complexity?为什么动态数组必须以几何方式增加其容量以获得 O(1) 摊销的 push_back 时间复杂度? 【发布时间】:2020-11-08 04:02:53 【问题描述】:

我了解到动态数组(例如 std::vector)在达到其容量时会使其容量翻倍,以使 push_back 操作的摊销时间为 O(1)。

但是,为什么首先需要这样做?不是在vector 的末尾为一个元素分配空间并在那里复制新元素已经 O(1) 了吗?

【问题讨论】:

注:底层容量翻倍不常用;它过于激进,并且很可能最终导致过多的内存浪费。从长远来看,它几乎总是一种计算方式,表现为某种乘数,但没有 2 倍那么高。 1.3x 或 1.5x 等得到相同的摊销 O(1) 而不会浪费内存。 请注意,计算机是懒惰的,你会发现它们在做各种各样的事情,比如“是的,我可以扩展那块内存!”一个好的 vector 实现将在它发生时利用这一点。 【参考方案1】:

如果您想在数组末尾分配空间,则只有在该位置的内存可用时才有效。其他东西可能已经存在,或者该内存可能无法使用。所以调整数组大小的方式(在一般中):

    创建一个新的更大的数组,

    将原始数组中的元素复制到更大的数组中,然后

    销毁原始数组。

如您所见,当您增加数组的大小时,您付出的成本与数组的原始大小成正比。

因此,如果您从一个包含一个元素的数组开始并添加第二个元素,则必须将第一个元素复制到另一个数组中。如果添加第三个元素,则必须复制其他两个元素。如果添加第四个元素,则必须复制前三个。这加起来是 1+2+3...+N,等于 N(N+1)/2,在 O(N2) 中。见Arithmetic Progression (Wikipedia)

如果你用几何级数调整数组的大小,你仍然需要每次复制元素,但是你复制的次数更少次。

如果您通过将数组加倍来调整大小,那么当您获得两倍大小 N 的幂时,N/2 将被复制 0 次,N/4 将被复制一次,N/8 将被复制两次,等等。 0N/2 + 1N/4 + 2N/8 + 3N/16... 的总和在 O(N) 中。见Geometric Series (Wikipedia)

您不需要选择加倍,您可以选择其他因素,例如 1.5 倍。选择不同的因子不会改变渐近复杂度,但会改变实际性能。

【讨论】:

您想将您的计算除以添加次数 (N) 以获得每次添加的平均成本吗? (问题的 O(1) 是每次添加的平均值。) 我曾考虑将其写入答案,但我认为调整数组大小的总成本比摊销成本更容易/更简单。因此,我避免谈论摊销成本,希望能够更清楚地讨论调整大小策略之间的差异。 试图在写一个更详细的答案和写一个更清晰的答案之间取得平衡,因为细节和清晰往往是不一致的。 这很合理。不过,我会留下评论,以证明这是考虑过的。【参考方案2】:

如果你可以在最后再为一个元素分配空间,并将新元素复制到那里,那确实是 O(1)。

但标准库没有提供这样做的方法,主要是因为您不能依赖实际能够做到这一点。

所以通常最终发生的事情是分配一个全新的内存块,将现有数据从现有块复制(或移动)到新块,然后将新元素添加到新块。并且将所有元素从现有块复制/移动到新块的额外步骤是线性的,这意味着添加新元素是线性的。

【讨论】:

【参考方案3】:

这里的困难在于std::vector 将其内容保存在连续 内存中。为单个附加元素分配内存并将其移动到那里是不够的。您需要保证在一个地方为整个容器提供足够的内存。每次分配都可能需要复制所有先前的内容以保持连续性,因此实际上不一定需要 O(1) 才能为单个附加元素获得足够的内存。

【讨论】:

以上是关于为啥动态数组必须以几何方式增加其容量以获得 O(1) 摊销的 push_back 时间复杂度?的主要内容,如果未能解决你的问题,请参考以下文章

Go 切片内存分配

list遍历陷阱分析原理

C++动态数组,增加容量

R中的动态类数组结构?

数据结构顺序表—纯C实现顺序表

为啥这个计数器以这种方式增加,而不是在这个分而治之的算法中一个一个增加?