Week 1 · Sat 2026-08-15
一个 vector 里其实有两个长度。搞混它们是 C++ 新手最常见的一类错误,
而「为什么增长因子是 2」是把这件事讲清楚的最好抓手 —— 因为标准根本没规定它。
| 名字 | 是什么 | 谁说了算 |
|---|---|---|
size() | 里面有几个元素。v[i] 的合法范围只看它 | 你的数据 |
capacity() | 买了多大的地 —— 不重新分配的话最多能长到多少 | 库的增长策略 |
| growth factor | 地不够时再买多大的规矩 | 标准库实现,不是标准 |
顺序是:growth factor 是政策 → capacity 是政策产生的结果 → size 是你自己的数据。
capacity >= size 任何时刻都成立。
下面这个演示就是你那个 C++ 程序的可视化版。蓝格子 = size(真实存在的元素),
虚线框内的灰格子 = capacity 里空着的部分(买了但没用的地)。
扩容发生时,被橙色高亮的那些格子就是 moved —— 它们要被逐个搬到新内存去。
试试:把因子切到 +1 再连按 ×20 —— 每一次 push 都触发扩容, moved 直接爆炸。那就是没有几何增长的世界。
按着 ×20 一直加,你会看到扩容越来越稀 —— 发生在第 1、2、3、5、9、17、33… 次 push 上。
贵的操作确实是 O(n)(那一次要搬全部),但它稀疏到总账还是 O(n),
摊到每次就是 O(1)。注意看 moved / size 那一格:它会稳定在 1 附近,
意思是平均每个元素一辈子只被搬 1 次。
moved 到底在数什么扩容不是「把原地那块地变大」—— 做不到,旁边可能早被别人占了。真实动作是三步:
① 在别处申请一块更大的内存
② 把老地方【已有的每一个元素】逐个搬过去 ← moved 数的就是这一步
③ 销毁老元素、把老内存还给系统
所以 moved 永远等于那一刻的 size。
cap 512→1024 那次搬了 512 个;1000 次 push 全程累计 1023 次。
那张表每一行是一次扩容事件,不是一次 push。push#3 时 size=3、capacity=4 ——
还塞得下,不扩容,所以那一行根本不打印。回头看输出,push# 那一栏是
0, 1, 2, 4, 8, 16…,3 本来就不在里面。
1000 次 push 里只有 11 次是「贵」的,另外 989 次就是往空格子里写一个 int。
「均摊」(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) ∎
写成 ≤ 3N 同样成立(因为 2N < 3N),只是松一点。
均摊分析要的只是「存在一个与 N 无关的常数 c,使总代价 ≤ cN」——
c 是 2 还是 3 不影响结论。在复杂度证明里,宁可把界放松,也不要写错,
这是正确的态度而不是偷懒。
上面证明了它 < 2,但具体是多少取决于 N 落在两个 2 的幂之间的哪个位置:
| N | 最终 capacity | 总搬运 | 搬运 / N | 位置 |
|---|---|---|---|---|
| 1000 | 1024 | 1023 | 1.023 | 你实测的 —— 贴在 1024 下面,运气好 |
| 513 | 1024 | 1023 | 1.994 | 刚过 512,几乎是最坏情况 |
| 1024 | 1024 | 1023 | 0.999 | 正好落在 2 的幂上,最好 |
| 1025 | 2048 | 2047 | 1.997 | 多推一个,比值几乎翻倍 |
| 10⁷ | 16777216 | 16777215 | 1.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 周一整整一晚的内容,也是整份题库里性价比最高的一题。
先把两个东西分开,这一条本身就是面试题:
语言标准 C++17 ← -std=c++17 控制的是这个
标准库实现 libc++ ← 增长因子 2 是【这里】定的
把 -std=c++17 换成 -std=c++20,因子还是 2。
换一个标准库才会变:
| 标准库 | 平台 | g | 能重用扔掉的内存吗 |
|---|---|---|---|
| libc++ | macOS clang(你的) | 2 | 不能 |
| libstdc++ | Linux gcc | 2 | 不能 |
| MSVC STL | Windows | 1.5 | 可以 |
| folly::fbvector | 1.5 | 可以 |
标准要求 push_back 是 amortized 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)。
g 大 → 扩容少、拷贝少 → 但空着的内存多(g=2 最坏浪费 50%)
g 小 → 空着的内存少 → 但拷贝多(g=1.5 总搬运是 2n,不是 n)
上面演示里的「浪费」那一行会实时告诉你这件事:刚扩完容那一刻,
g=2 有一半的地是空的,g=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 的动机,不是被实测证明的收益。面试里这么说反而加分: 知道一个论证的边界,比背下结论强。
capacity_growth.cpp| 量 | 实测 | 意思 |
|---|---|---|
| 增长因子 | 2.00 | libc++ 的选择,不是标准规定 |
| 1000 次 push 的扩容次数 | 11 | ≈ log₂(1000),不是 1000 |
| 总搬运 moved | 1023 | ≈ n ⇒ 平均每个元素搬 1 次 |
| 若每次 +1 增长 | 499500 | n(n−1)/2,差 488× |
| 最终 size / capacity | 1000 / 1024 | 差值就是买了没用的地 |
reserve_bench.cpp(10⁷ 次 push_back)| 做法 | ns / push_back | 倍数 |
|---|---|---|
| 不 reserve | 1.056 | — |
reserve(N) 之后 | 0.595 | 1.78× |
只快 1.78× 是正确答案,不是实验失败。 int 的搬运就是 memcpy,
非常便宜,所以 reserve 主要省掉的是反复向系统申请/释放内存那一半。
换成一个「拷贝会自己再分配内存」的类型,差距会大得多 —— 那正是 Week 2 周六
Buffer 类要演示的事。能解释这个 1.78 比记住它值钱。
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(),
而且标准只把它当作一个请求 —— 实现可以完全不理你。
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(10'000'000),
白占 40 MB。省时间是要拿内存换的,换多少得心里有数。
推完了才 reserve —— 一点用都没有,该发生的扩容早就发生过了。
reserve 必须在第一次 push 之前调用。
reserve 之后直接下标写 —— v.reserve(100); v[0] = 1;
是越界写入,因为 size() 还是 0。想「先占位再填」要用 resize。
你今晚只测到 1.78×,听起来不惊艳。但在低延迟场景里,
reserve 真正买到的是掐掉尾延迟:
扩容是一次不可预测的、和当前数据量成正比的停顿 ——
平时 1 ns,轮到扩容那次可能是几十微秒。
交易路径上要的是「每次都一样快」,不是「平均快」。
这和哈希表 rehash 造成的尖峰是同一类问题,也是有些机构在热路径上
禁用 unordered_map 的原因。答到这一层,
就从「知道 reserve 是干嘛的」升级成「知道它在什么系统里为什么重要」。
push_back vs emplace_backpush_back 收一个已经造好的对象,把它拷贝或移动进容器。
emplace_back 收造这个对象的原料,在容器里就地构造。
push_back — 重点是「一个」「成品」v.push_back(1, 2) 直接编译报错:
no matching member function。T 时,编译器会试着隐式转换出一个 T
—— 那是一个临时对象,不是免费的。explicit 禁止的正是这次隐式转换。加了它,你就必须把构造写在调用点上。emplace_back — 重点是「构造」,与 explicit 无关::new (ptr) T(args...),即直接初始化
(direct-initialization)。explicit 与否都调得到 ——
因为这里根本没有「转换」,只有「构造」。explicit 管的是转换。| 你手上有什么 | push_back | emplace_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); | 直接初始化(只构造) | 管不着 ✅ |
机制完全相同,后果取决于类的作者当初为什么写 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_backtakes an already-constructed object and copies or moves it in.emplace_backforwards 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. Andemplace_backuses direct-initialization, so it can reach explicit constructors thatpush_backwould reject — slightly faster in one case, but you lose a compile-time check.
| A | B | 差在哪 |
|---|---|---|
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× |
v.reserve(100);
v[0] = 1; // ← 越界写入,UB
reserve 之后 size() 还是 0,一个元素都没构造,
所以 v[0] 根本不存在。-Wall -Wextra 全程不吭声,
只有 ASan 抓得到。想「先占位再填」应该用 resize。
被问 "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.
这个答案同时证明三件事:你知道标准管什么不管什么、你跑过、 你知道这是权衡不是定理。