std::vector 的容量如何自动增长?费率是多少?

Posted

技术标签:

【中文标题】std::vector 的容量如何自动增长?费率是多少?【英文标题】:How does the capacity of std::vector grow automatically? What is the rate? 【发布时间】:2011-07-11 01:43:38 【问题描述】:

我一直在阅读这本书: C++ Primer, Third Edition By Stanley B. Lippman, Josée Lajoie,在Article 6.3 How a vector Grows Itself 下给出的程序中发现1 个错误,该程序在couts 中遗漏了一个“

#include <vector>
#include <iostream>

using namespace std;

int main() 
    vector<int> ivec;
    cout < "ivec: size: " < ivec.size() < " capacity: "  < ivec.capacity() < endl;
    
    for (int ix = 0; ix < 24; ++ix) 
        ivec.push_back(ix);
        cout < "ivec: size: " < ivec.size()
        < " capacity: "  < ivec.capacity() < endl;
        

在那篇文章的后面:

“在 Rogue Wave 实现下,无论是大小还是容量 ivec 定义后的值为 0。在插入第一个元素时, 但是ivec的容量是256,大小是1。”

但是,在更正和运行代码时,我得到以下输出:


ivec: size: 0 capacity: 0
ivec[0]=0 ivec: size: 1 capacity: 1
ivec[1]=1 ivec: size: 2 capacity: 2
ivec[2]=2 ivec: size: 3 capacity: 4
ivec[3]=3 ivec: size: 4 capacity: 4
ivec[4]=4 ivec: size: 5 capacity: 8
ivec[5]=5 ivec: size: 6 capacity: 8
ivec[6]=6 ivec: size: 7 capacity: 8
ivec[7]=7 ivec: size: 8 capacity: 8
ivec[8]=8 ivec: size: 9 capacity: 16
ivec[9]=9 ivec: size: 10 capacity: 16
ivec[10]=10 ivec: size: 11 capacity: 16
ivec[11]=11 ivec: size: 12 capacity: 16
ivec[12]=12 ivec: size: 13 capacity: 16
ivec[13]=13 ivec: size: 14 capacity: 16
ivec[14]=14 ivec: size: 15 capacity: 16
ivec[15]=15 ivec: size: 16 capacity: 16
ivec[16]=16 ivec: size: 17 capacity: 32
ivec[17]=17 ivec: size: 18 capacity: 32
ivec[18]=18 ivec: size: 19 capacity: 32
ivec[19]=19 ivec: size: 20 capacity: 32
ivec[20]=20 ivec: size: 21 capacity: 32
ivec[21]=21 ivec: size: 22 capacity: 32
ivec[22]=22 ivec: size: 23 capacity: 32
ivec[23]=23 ivec: size: 24 capacity: 32

容量是否随着公式2^N 而增加,其中N 是初始容量?请解释一下。

【问题讨论】:

【参考方案1】:

向量容量的增长速度取决于实现。实现几乎总是选择指数增长,以满足push_back 操作的摊销常数时间要求。 摊销常数时间意味着什么以及指数增长如何实现这一点很有趣。

每次向量的容量增加时,都需要复制元素。如果您在向量的整个生命周期内“摊销”此成本,结果表明,如果您以指数倍增加容量,您最终会得到摊销的恒定成本。

这可能看起来有点奇怪,所以让我向你解释一下它是如何工作的......

大小:1 容量 1 - 未复制任何元素,复制的每个元素的成本为 0。 size: 2 capacity 2 - 当向量的容量增加到 2 时,必须复制第一个元素。每个元素的平均副本数为 0.5 size: 3 capacity 4 - 当向量的容量增加到 4 时,必须复制前两个元素。每个元素的平均副本数为 (2 + 1 + 0) / 3 = 1。 大小:4 容量 4 - 每个元素的平均副本数为 (2 + 1 + 0 + 0) / 4 = 3 / 4 = 0.75。 大小:5 容量 8 - 每个元素的平均副本数为 (3 + 2 + 1 + 1 + 0) / 5 = 7 / 5 = 1.4 ... 大小:8 容量 8 - 每个元素的平均副本数为 (3 + 2 + 1 + 1 + 0 + 0 + 0 + 0) / 8 = 7 / 8 = 0.875 大小:9 容量 16 - 每个元素的平均副本数为 (4 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 0) / 9 = 15 / 9 = 1.67 ... 大小 16 容量 16 - 每个元素的平均副本数为 15 / 16 = 0.938 大小 17 容量 32 - 每个元素的平均副本数为 31 / 17 = 1.82

如您所见,每次容量跳跃时,副本数都会增加数组之前的大小。但是由于数组在容量再次跳跃之前必须翻倍,所以每个元素的副本数始终保持小于 2。

如果您将容量增加 1.5 * N 而不是 2 * N,您最终会得到非常相似的效果,除了每个元素的副本上限会更高(我认为应该是 3)。

我怀疑实现会选择 1.5 而不是 2 既可以节省一点空间,也因为 1.5 更接近 golden ratio。我有一个直觉(目前没有任何硬数据支持),符合黄金比例的增长率(因为它与斐波那契数列的关系)将被证明是现实世界负载的最有效增长率在最大限度地减少使用的额外空间和时间方面。

【讨论】:

+1 用于解释 amortized constant time 的含义。重要的是要注意,实现不选择指数增长,这是标准所要求的(push_back 必须是摊销常数时间),他们只是选择他们增长价值的因素K(标准要求大于1)。 @Omnifarious:您对黄金比例的直觉是正确的。如果我没记错的话,Alexandrescu 已经在 comp.lang.c++.moderated 上发布了一些措施来支持它。虽然它的论点是你有时可以“就地”增长,因为分配器按 2 的幂进行分配对我来说总是很奇怪(如果没有错过第一次真正增长而没有任何空间损失的机会,那么就地增长是什么?)跨度> @Matthieu M.:“如果没有错过第一次真正增长而没有任何空间损失的机会,那么什么是增长?”。这是一个权衡。如果你第一次增长然后不使用额外的容量,你只是浪费了内存。 开启或略低于黄金比例的一个优势是,丢弃的内存块最终将加起来足够大,可以重用。如果你总是加倍大小,你每次都需要一个新的块。 @Omnifarious:我担心allocator 协议不允许像你说的那样增长std::vector 使用分配器,分配器不支持增长 -> 需要分配不同的缓冲区和副本,需要指数增长才能线性摊销。您可以设想一个支持此功能的分配器(扩展),但分配器是 std::vector 的扩展点,因此它仍然需要支持用户提供的分配器的指数增长。【参考方案2】:

为了能够在 std::vector 的末尾提供 amortized constant time 插入,实现必须将向量的大小(在需要时)增加一个因子 K&gt;1 (*) ,这样当尝试附加到大小为N 的已满向量时,该向量将增长为K*N

不同的实现使用不同的常量K,它们提供不同的好处,特别是大多数实现都使用K = 2K = 1.5。更高的K 会使其更快,因为它需要更少的增长,但同时会产生更大的内存影响。例如,在 gcc K = 2,而在 VS (Dinkumware) K = 1.5

(*) 如果向量增长一个常数,那么push_back 的复杂度将变为线性而不是摊销常数。例如,如果向量在需要时增长了 10 个元素,那么增长的成本(将所有元素复制到新的内存地址)将是 O( N / 10 )(每 10 个元素,移动所有元素)或 O( N )

【讨论】:

【参考方案3】:

只是在vector::push_back上添加一些时间复杂度的数学证明,假设向量的大小为n,我们这里关心的是到目前为止发生的副本数,例如y,注意副本每次增长向量时都会发生。

增长系数为 K

  y = K^1 + K^2 + K^3 ... K^log(K, n)
K*y =     + K^2 + K^3 ... K^log(K, n) + K*K^log(K, n)

K*y-y = K*K^log(K, n) - K
y = K(n-1)/(K-1) = (K/(K-1))(n-1)

T(n) = y/n = (K/(K-1)) * (n-1)/n < K/(K-1) = O(1)

K/(K-1) 是一个常量,看看最常见的情况:

K=2,T(n) = 2/(2-1) = 2 K=1.5,T(n) = 1.5/(1.5-1) = 3

实际上在不同的实现中选择 K 为 1.5 或 2 是有原因的,请参见 graph: 因为当 K 约为 2 时 T(n) 达到最小值,使用更大的没有太多好处K,以分配更多内存为代价

以恒定数量增长 C

y = C + 2*C + 3*C + 4*C +  ... (n/C) * C
  = C(1+2+3+...+n/C), say m = n/C
  = C*(m*(m-1)/2)
  = n(m-1)/2

T(n) = y/n = (n(m-1)/2)/n = (m-1)/2 = n/2C - 1/2 = O(n)

我们可以看到它是班轮

【讨论】:

这是不正确的。 K^log(K,n) = n 对于所有 K,所以你的总和不是几何级数。复制总数的实际公式约为T(n) = (b^ceil( log(n)/log(b) ) - 1)/(b-1) - (1/2)*(ceil(b) - floor(b))*ceil( log(n)/log(b) ),其中 n 是插入次数,b 是调整向量大小后的值(符号中的 K)。这个公式对于整数 b 是精确的。通过一些基本代数,您可以证明T(n)/n &lt; b/(b-1) 表示整数 b,并且在大 n 的极限内表示有理 b。【参考方案4】:

vector 的容量完全依赖于实现,没有人知道它是如何增长的..

【讨论】:

它是依赖于实现,但不是完全所以,它需要增长一个因子K&gt;1,否则push_back 摊销的固定时间成本将无法实现。【参考方案5】:

您使用的是“Rogue Wave”实现吗?

容量如何增长取决于实施。你的使用 2^N。

【讨论】:

-1 : 不告诉 OP 他不知道的事情。 @Omnifarious:告诉他容量增长取决于实施,这解释了为什么他没有得到预期的结果。 精确的基础取决于实现。不是指数增长;这就是您可以实现强制摊销恒定时间 push_back 的方式。【参考方案6】:

是的,每次超出容量都会翻倍。这取决于实现。

【讨论】:

以上是关于std::vector 的容量如何自动增长?费率是多少?的主要内容,如果未能解决你的问题,请参考以下文章

C++STL(23) 研究std::vector的内存增长规律

使用 std::vector 的指数内存消耗增长

保留STD向量的容量 模板专业化

即使根据容量()仍有未使用的空间,std::vector 能否将其数据移动到 emplace_back()处的另一个地址?

std::vector 向下调整大小

C++中向量的初始容量