vector 的内部 周六 · 1 小时
← Handbook
中文EN

Week 1 · Sat 2026-08-15

size、capacity,和那个 2

一个 vector 里其实有两个长度。搞混它们是 C++ 新手最常见的一类错误, 而「为什么增长因子是 2」是把这件事讲清楚的最好抓手 —— 因为标准根本没规定它。

实测因子 2.00(libc++) 1000 次 push 11 次扩容 总搬运 1023 ≈ n reserve 1.78×

1 · 三个数,一句话

名字是什么谁说了算
size()里面有几个元素v[i] 的合法范围只看它你的数据
capacity()买了多大的地 —— 不重新分配的话最多能长到多少库的增长策略
growth factor地不够时再买多大的规矩标准库实现,不是标准

顺序是:growth factor 是政策 → capacity 是政策产生的结果 → size 是你自己的数据capacity >= size 任何时刻都成立。

2 · 动手玩:每按一次 push 会发生什么

下面这个演示就是你那个 C++ 程序的可视化版。蓝格子 = size(真实存在的元素), 虚线框内的灰格子 = capacity 里空着的部分(买了但没用的地)。 扩容发生时,被橙色高亮的那些格子就是 moved —— 它们要被逐个搬到新内存去。

增长因子
buffer @ capacity = 0
size
0
capacity
0
扩容次数
0
总搬运 moved
0
moved / size
等待第一次 push_back…

试试:把因子切到 +1 再连按 ×20 —— 每一次 push 都触发扩容, moved 直接爆炸。那就是没有几何增长的世界。

这就是 amortized O(1) 长的样子

按着 ×20 一直加,你会看到扩容越来越稀 —— 发生在第 1、2、3、5、9、17、33… 次 push 上。 贵的操作确实是 O(n)(那一次要搬全部),但它稀疏到总账还是 O(n), 摊到每次就是 O(1)。注意看 moved / size 那一格:它会稳定在 1 附近, 意思是平均每个元素一辈子只被搬 1 次

3 · moved 到底在数什么

扩容不是「把原地那块地变大」—— 做不到,旁边可能早被别人占了。真实动作是三步:

① 在别处申请一块更大的内存
② 把老地方【已有的每一个元素】逐个搬过去   ← moved 数的就是这一步
③ 销毁老元素、把老内存还给系统

所以 moved 永远等于那一刻的 size。 cap 512→1024 那次搬了 512 个;1000 次 push 全程累计 1023 次。

为什么表里没有 push#3

那张表每一行是一次扩容事件,不是一次 push。push#3 时 size=3、capacity=4 —— 还塞得下,不扩容,所以那一行根本不打印。回头看输出,push# 那一栏是 0, 1, 2, 4, 8, 16…,3 本来就不在里面。 1000 次 push 里只有 11 次是「贵」的,另外 989 次就是往空格子里写一个 int。

4 · 均摊 O(1):把它算出来

「均摊」(amortized)不是「每次都是 O(1)」,而是: 把偶发的昂贵操作分摊到大量便宜操作上之后,平均单次的成本。 对 push_back 来说:

什么时候发生什么单次成本占比
size < capacity往空格子里写一个元素O(1)989 / 1000
size == capacity申请 2× 内存 + 搬运全部旧元素O(N)11 / 1000

推导

扩容只发生在容量为 1、2、4、8… 的时刻,每次搬运的元素个数正好等于当时的容量。 所以整个过程的搬运次数是一个几何级数:

总搬运 = 1 + 2 + 4 + 8 + ... + 2^k = 2^(k+1) − 1

因为扩容到的最大容量不会超过 2N,有 2^k ≤ N,于是

总搬运 = 2·2^k − 1 ≤ 2N − 1 < 2N

平均每次 push_back 的搬运成本 < 2N / N = 2 = O(1)   ∎
常数取 2 还是 3 都对

写成 ≤ 3N 同样成立(因为 2N < 3N),只是一点。 均摊分析要的只是「存在一个与 N 无关的常数 c,使总代价 ≤ cN」—— c 是 2 还是 3 不影响结论。在复杂度证明里,宁可把界放松,也不要写错, 这是正确的态度而不是偷懒。

那个常数其实在 1 和 2 之间摆动

上面证明了它 < 2,但具体是多少取决于 N 落在两个 2 的幂之间的哪个位置:

N最终 capacity总搬运搬运 / N位置
1000102410231.023你实测的 —— 贴在 1024 下面,运气好
513102410231.994刚过 512,几乎是最坏情况
1024102410230.999正好落在 2 的幂上,最好
1025204820471.997多推一个,比值几乎翻倍
10⁷16777216167772151.678

看第 2、3 行:N 从 513 变成 1024,总搬运一个都没多(都是 1023), 但分母翻了一倍,比值就从 1.994 掉到 0.999。这解释了为什么比值会来回摆 —— 搬运总量只在扩容那一刻跳一次,而 N 是连续在长。

面试里真正的考点

重点不是这个常数等于几,而是它不随 N 变化。 N 从 10³ 涨到 10⁷,比值还在 1 和 2 之间 —— 这就是 O(1) 的定义。 如果换成「每次 +1」的线性增长,总搬运是 N(N−1)/2, 比值变成 N/2,它自己就是 N 的函数,那才叫 O(N)。

一个用词上的追问

「把旧元素过去」—— 搬的动作到底是什么?对 int 这种 trivially copyable 的类型,编译器直接 memcpy,一次拷一大片,非常快 (这也是今晚 reserve 只快 1.78× 的原因)。 对有自定义构造/析构的类型,是逐个元素调用移动构造函数 —— 而且只有当它标了 noexcept 时才会用移动,否则退回去拷贝。 这条是 Week 2 周一整整一晚的内容,也是整份题库里性价比最高的一题。

5 · 为什么是 2 —— 标准没规定过

先把两个东西分开,这一条本身就是面试题:

语言标准   C++17      ← -std=c++17 控制的是这个
标准库实现 libc++     ← 增长因子 2 是【这里】定的

-std=c++17 换成 -std=c++20,因子还是 2。 换一个标准库才会变:

标准库平台g能重用扔掉的内存吗
libc++macOS clang(你的)2不能
libstdc++Linux gcc2不能
MSVC STLWindows1.5可以
folly::fbvectorFacebook1.5可以

标准只规定了一件事,但那一件事很有力

标准要求 push_backamortized O(1)。这句话逼出了「必须按倍数长」:

总搬运次数 ≈ n / (g - 1)

g = 2    → 约 n 次      (你实测 1023,n = 1000)
g = 1.5  → 约 2n 次
g = 1    → 分母是 0,公式塌掉 → 真实答案 n(n-1)/2 = O(n²)

只要 g 是大于 1 的常数,总量就是 O(n)。所以「乘法」有数学硬理由, 「乘 2」没有 —— 2、1.5、1.1 都满足 O(1)。

那 2 和 1.5 差在哪

g 大 → 扩容少、拷贝少 → 但空着的内存多(g=2 最坏浪费 50%)
g 小 → 空着的内存少   → 但拷贝多(g=1.5 总搬运是 2n,不是 n)

上面演示里的「浪费」那一行会实时告诉你这件事:刚扩完容那一刻, g=2一半的地是空的,g=1.5 只空三分之一。

选 1.5 那一派的杀手锏

因子 2 永远用不上自己扔掉的内存。 vector 一路扩容会依次申请再释放 1、2、4、8、16… 个位置的块。轮到申请 64 时,之前扔掉的全部加起来是 1+2+4+8+16 = 31 < 64 —— 永远差一点 (几何级数的性质:前面所有项之和永远小于下一项)。

换成 1.5:块是 1、1.5、2.25、3.375、5.06、7.59…,申请 17.09 那次, 前面扔掉的加起来 20.78 够了,分配器可以原地重用。 分界线正好是黄金比例 φ ≈ 1.618:g ≤ φ 才可能重用。

诚实标注:这是个理论论证, 成不成立取决于分配器是否把块连续摆放、能否合并空闲块 —— 现代 malloc 未必如此。 它是选 1.5 的动机,不是被实测证明的收益。面试里这么说反而加分: 知道一个论证的边界,比背下结论强。

6 · 今晚实测到的数

Verify ① — capacity_growth.cpp

实测意思
增长因子2.00libc++ 的选择,不是标准规定
1000 次 push 的扩容次数11≈ log₂(1000),不是 1000
总搬运 moved1023≈ n ⇒ 平均每个元素搬 1 次
若每次 +1 增长499500n(n−1)/2,差 488×
最终 size / capacity1000 / 1024差值就是买了没用的地

Verify ② — reserve_bench.cpp(10⁷ 次 push_back)

做法ns / push_back倍数
不 reserve1.056
reserve(N) 之后0.5951.78×

只快 1.78× 是正确答案,不是实验失败。 int 的搬运就是 memcpy, 非常便宜,所以 reserve 主要省掉的是反复向系统申请/释放内存那一半。 换成一个「拷贝会自己再分配内存」的类型,差距会大得多 —— 那正是 Week 2 周六 Buffer 类要演示的事。能解释这个 1.78 比记住它值钱。

7 · reserve:已知上限就先把地买够

核心理解一句话:如果事先知道最多会放多少个元素,就先 reserve, 这样 size 永远撞不到 capacity,扩容一次都不会发生 —— 既省掉反复向系统申请内存,也省掉搬运已有元素。

这是对的。下面四条是实测出来的细节,每一条都能被追问到:

reserve(n) 给的是精确值,不会向上取整到 2 的幂

reserve(1000)  →  size = 0,  capacity = 1000

是 1000,不是 1024。reserve 之后 capacity 完全由你说了算, 几何增长那套规矩这时候不参与。

reserve 只能变大,不能变小

capacity 已经是 1000,再 reserve(10)  →  capacity 仍然是 1000

它的语义是「至少要这么大」,不是「设成这么大」。 想把多余的地还回去得用 shrink_to_fit(), 而且标准只把它当作一个请求 —— 实现可以完全不理你。

③ 估小了不是灾难:从你设的那个数接着 ×2

reserve(10),然后 push 40 个  →  capacity: 10 → 20 → 40

不是退回 1、2、4、8 重来。几何增长从你给的起点继续走, 所以估小了只是「少省了一部分」,不会更糟。

④ 第三个好处:指针和迭代器不会失效

这一条不是性能,是正确性,也是你上面那段理解里唯一没提到的:

reserve 够   →  &v[0] 前后一致                     指针依然有效
不 reserve   →  0x...1a0  变成  0x...350            指针悬空 → UB

只要不发生扩容,指向元素的指针 / 引用 / 迭代器就一直有效。 在「一边往 vector 里加东西、一边握着某个元素的指针」的代码里,这是硬需求, 跟快慢无关。(周三的热身会专门用 ASan 把这个 bug 抓出来看。)

三种 reserve 用错的方式

猜一个大数 —— 不知道上限就 reserve(10'000'000), 白占 40 MB。省时间是要拿内存换的,换多少得心里有数。

推完了才 reserve —— 一点用都没有,该发生的扩容早就发生过了。 reserve 必须在第一次 push 之前调用。

reserve 之后直接下标写 —— v.reserve(100); v[0] = 1; 是越界写入,因为 size() 还是 0。想「先占位再填」要用 resize

面试角度:reserve 的价值不在平均速度

你今晚只测到 1.78×,听起来不惊艳。但在低延迟场景里, reserve 真正买到的是掐掉尾延迟: 扩容是一次不可预测的、和当前数据量成正比的停顿 —— 平时 1 ns,轮到扩容那次可能是几十微秒。

交易路径上要的是「每次都一样快」,不是「平均快」。 这和哈希表 rehash 造成的尖峰是同一类问题,也是有些机构在热路径上 禁用 unordered_map 的原因。答到这一层, 就从「知道 reserve 是干嘛的」升级成「知道它在什么系统里为什么重要」。

8 · push_back vs emplace_back

一句话,其余全是它的推论

push_back一个已经造好的对象,把它拷贝或移动进容器。
emplace_back造这个对象的原料,在容器里就地构造

push_back — 重点是「一个」「成品」

emplace_back — 重点是「构造」,与 explicit 无关

四个场景的实测调用次数

你手上有什么push_backemplace_back谁赢
原料,2 个参数
Widget(int,int)
push_back(1,2) 写不出来
只能 push_back(Widget(1,2)):
ctor + MOVE + dtor = 3 次
emplace_back(3,4)
ctor = 1 次
emplace
原料,1 个参数
Person(int)
push_back(20)
ctor + MOVE + dtor = 3 次
临时对象隐形了,代价一分没少
emplace_back(22)
ctor = 1 次
emplace
已有对象 w COPY ctor = 1 次 COPY ctor = 1 次 完全一样
std::move(w) MOVE ctor = 1 次 MOVE ctor = 1 次 完全一样

所以「emplace_back 更快」作为一般说法是假的。 准确说法:它只在「能避免临时对象」时更省

explicit 为什么拦得住一个、拦不住另一个

写法等价于初始化方式explicit
v.push_back(30)Person a = 30;拷贝初始化(要转换)拦得住 ❌
v.emplace_back(30)Person a(30);直接初始化(只构造)管不着 ✅
「emplace_back 更不安全」要加条件

机制完全相同,后果取决于类的作者当初为什么写 explicit:

vector<Person>          emplace_back(30)         能编过 → 无害(只是防手滑的转换)
vector<regex>           emplace_back(nullptr)    能编过 → 静默 UB
vector<unique_ptr<int>> emplace_back(new int(5)) 能编过 → 抛异常就泄漏

三行 push_back 全部编译报错。explicit 一条规则都没被违反 —— 直接初始化本来就是它允许的。丢掉的是「类作者装的那道编译期检查」, 严不严重取决于他当初为什么装。

push_back takes an already-constructed object and copies or moves it in. emplace_back forwards the constructor arguments and builds the element in place, so it avoids a temporary when you're building from raw arguments. If you pass an object that already exists, the two are identical. And emplace_back uses direct-initialization, so it can reach explicit constructors that push_back would reject — slightly faster in one case, but you lose a compile-time check.

9 · 区分:四组容易混的东西

AB差在哪
size()capacity() size 决定 v[i] 的合法范围;capacity 是实现细节
reserve(n)resize(n) reserve 只改 capacity,一个元素都不构造;resize 真的改 size 并构造出 n 个元素
amortized O(1)average O(1) amortized 对任意输入都保证总量 O(n);average 是对输入分布取平均,会被恶意输入打崩
几何增长 ×g线性增长 +1 总搬运 n/(g−1) = O(n)  vs  n(n−1)/2 = O(n²)。你实测差 488×
最容易写出来的那个 bug
v.reserve(100);
v[0] = 1;        // ← 越界写入,UB

reserve 之后 size() 还是 0,一个元素都没构造, 所以 v[0] 根本不存在。-Wall -Wextra 全程不吭声, 只有 ASan 抓得到。想「先占位再填」应该用 resize

10 · 面试怎么答

被问 "What happens when you push_back into a vector that is at capacity?", 60 秒内给完这些,然后闭嘴等追问:

It allocates a larger buffer, moves the existing elements over, destroys the old ones and frees the old block. The growth is geometric — I measured a factor of 2 on libc++. That's what makes push_back amortized O(1): a single push can be O(n), but reallocation only happens about log n times, so the total work over n pushes stays O(n) — I measured 1023 element moves for 1000 pushes, about one move per element. All iterators, pointers and references into the old buffer are invalidated.

被问 "Why 2?" —— 不要直接答 2。正确的形状是:

The standard doesn't specify it — it only requires amortized O(1), which forces geometric growth. The factor itself is a library choice: libc++ and libstdc++ use 2, MSVC uses 1.5. I measured 2 on my machine. It's a trade-off — a larger factor means fewer copies but up to 50% wasted memory, and there's an argument that a factor below the golden ratio lets the allocator reuse freed blocks, which 2 never can.

这个答案同时证明三件事:你知道标准管什么不管什么你跑过你知道这是权衡不是定理

© 2026 Xuexun Lu · 内容采用 CC BY-NC 4.0 许可