Inside std::vector Sat · 1 hour
← Handbook
中文EN

Week 1 · Sat 2026-08-15

size, capacity, and that factor of 2

A vector carries two lengths. Confusing them is one of the most common C++ mistakes, and “why is the growth factor 2?” is the best handle for pulling the whole thing apart — because the standard never specified it.

measured factor 2.00 (libc++) 1000 pushes 11 reallocations total moves 1023 ≈ n reserve 1.78×

1 · Three numbers, one sentence

NameWhat it isWho decides
size()How many elements are in there. The valid range of v[i] depends on this aloneYour data
capacity()How much land you bought — the largest it can grow to without reallocatingThe library's growth policy
growth factorThe rule for how much more to buy when the land runs outThe standard library implementation, not the standard

The order runs: growth factor is policy → capacity is the consequence of that policy → size is your own data. capacity >= size holds at every instant.

2 · Play with it: what happens on each push

The demo below is a visualisation of that C++ program. Blue cells = size (elements that really exist), grey cells inside the dashed box = the unused part of capacity (land bought but not built on). When a reallocation happens, the cells highlighted in orange are exactly what moved counts — each one has to be relocated to the new memory.

growth factor
buffer @ capacity = 0
size
0
capacity
0
reallocations
0
total moved
0
moved / size
Waiting for the first push_back…

Try this: switch the factor to +1, then hold down ×20 — every single push triggers a reallocation and moved explodes. That is the world without geometric growth.

This is what amortized O(1) looks like

Keep pressing ×20 and you will watch reallocations grow steadily rarer — they land on push 1, 2, 3, 5, 9, 17, 33… The expensive operation really is O(n) (that one has to relocate everything), but it is rare enough that the total stays O(n), which amortizes to O(1) per push. Watch the moved / size tile: it settles near 1, meaning each element is relocated about once in its entire lifetime.

3 · What moved is actually counting

Reallocation is not “growing the existing block in place” — that is impossible, the memory next door may already belong to someone else. What really happens is three steps:

1. allocate a larger block somewhere else
2. relocate [every element that already exists] one by one   <- this is what moved counts
3. destroy the old elements and return the old block to the system

So moved always equals the size at that moment. The cap 512→1024 step relocated 512 elements; across 1000 pushes the running total is 1023.

Why push#3 is missing from the table

Each row of that table is one reallocation event, not one push. At push#3, size=3 and capacity=4 — there is still room, no reallocation, so no row gets printed. Look back at the output: the push# column reads 0, 1, 2, 4, 8, 16… and 3 was never going to be there. Out of 1000 pushes only 11 are “expensive”; the other 989 just write an int into a free slot.

4 · Amortized O(1): deriving it

“Amortized” does not mean “every call is O(1)”. It means: the average cost per call once the occasional expensive operation is spread across the many cheap ones. For push_back:

WhenWhat happensCost of that callShare
size < capacitywrite one element into a free slotO(1)989 / 1000
size == capacityallocate 2× memory + relocate every old elementO(N)11 / 1000

The derivation

Reallocation only happens at capacities 1, 2, 4, 8… and the number of elements relocated each time is exactly the capacity at that moment. So the total number of relocations over the whole process is a geometric series:

total moves = 1 + 2 + 4 + 8 + ... + 2^k = 2^(k+1) - 1

since the largest capacity reached never exceeds 2N, we have 2^k <= N, hence

total moves = 2 * 2^k - 1 <= 2N - 1 < 2N

average relocation cost per push_back < 2N / N = 2 = O(1)   QED
A constant of 2 or 3 — both are correct

Writing ≤ 3N is equally valid (since 2N < 3N), just looser. Amortized analysis only needs “there exists a constant c, independent of N, such that the total cost is ≤ cN” — whether c is 2 or 3 does not affect the conclusion. In a complexity proof, a loose bound beats a wrong one; that is the correct attitude, not laziness.

That constant actually oscillates between 1 and 2

The proof above bounds it below 2, but the exact value depends on where N falls between two powers of two:

Nfinal capacitytotal movesmoves / NPosition
1000102410231.023the one you measured — sitting just under 1024, lucky
513102410231.994just past 512, almost the worst case
1024102410230.999exactly on a power of two, the best case
1025204820471.997one more push and the ratio nearly doubles
10716777216167772151.678

Look at rows 2 and 3: going from N=513 to N=1024 adds not a single relocation (both are 1023), but the denominator doubles, so the ratio drops from 1.994 to 0.999. That is why the ratio swings back and forth — total relocations jump only at the moment of a reallocation, while N grows continuously.

What the interview is really testing

The point is not what the constant equals — it is that the constant does not vary with N. N grows from 103 to 107 and the ratio is still between 1 and 2: that is the definition of O(1). Switch to linear “+1” growth and the total becomes N(N-1)/2, so the ratio becomes N/2a function of N itself. That is what O(N) means.

A follow-up about one word

Relocating the old elements” — what is the actual operation? For a trivially copyable type like int, the compiler just calls memcpy and moves a whole block at once, which is very fast (this is also why reserve only bought 1.78× tonight). For a type with a user-defined constructor or destructor, it is a per-element call to the move constructor — and only if that constructor is marked noexcept; otherwise it falls back to copying. That sentence is the whole of Monday night in Week 2, and the highest-value question in the entire question bank.

5 · Why 2 — the standard never said so

First separate two things; this distinction is itself an interview question:

language standard      C++17    <- this is what -std=c++17 controls
standard library impl  libc++   <- the growth factor of 2 is decided [here]

Swap -std=c++17 for -std=c++20 and the factor is still 2. Only swapping the standard library changes it:

Standard libraryPlatformgCan it reuse the memory it threw away?
libc++macOS clang (yours)2No
libstdc++Linux gcc2No
MSVC STLWindows1.5Yes
folly::fbvectorFacebook1.5Yes

The standard specifies exactly one thing, but it is a powerful one

The standard requires push_back to be amortized O(1). That single sentence forces growth to be multiplicative:

total moves ~= n / (g - 1)

g = 2    -> about n moves     (you measured 1023 for n = 1000)
g = 1.5  -> about 2n moves
g = 1    -> denominator is 0, the formula collapses
            -> the real answer is n(n-1)/2 = O(n^2)

As long as g is a constant greater than 1, the total is O(n). So “multiply” has a hard mathematical reason behind it; “multiply by 2” does not — 2, 1.5 and 1.1 all satisfy O(1).

So what separates 2 from 1.5

larger g -> fewer reallocations, fewer copies -> but more idle memory (g=2 wastes up to 50%)
smaller g -> less idle memory                 -> but more copying (g=1.5 totals 2n moves, not n)

The “wasted” readout in the demo above tells you this live: right after a reallocation, g=2 leaves half the land empty, while g=1.5 leaves only a third.

The killer argument for the 1.5 camp

A factor of 2 can never reuse the memory it threw away. As a vector grows it allocates and then frees blocks of 1, 2, 4, 8, 16… slots. By the time it asks for 64, everything it discarded so far adds up to 1+2+4+8+16 = 31 < 64always just short (a property of geometric series: the sum of all previous terms is always less than the next one).

Switch to 1.5 and the blocks are 1, 1.5, 2.25, 3.375, 5.06, 7.59… By the request for 17.09, the discarded blocks total 20.78, which is enough, so the allocator can reuse the space in place. The dividing line is exactly the golden ratio φ ≈ 1.618: only g ≤ φ makes reuse possible.

Stated honestly: this is a theoretical argument. Whether it holds depends on whether the allocator lays blocks out contiguously and can coalesce free ones — and modern malloc may well not. It is the motivation for choosing 1.5, not a measured benefit. Saying exactly that in an interview earns you points: knowing the limits of an argument beats reciting its conclusion.

6 · The numbers measured tonight

Verify ① — capacity_growth.cpp

QuantityMeasuredMeaning
growth factor2.00libc++'s choice, not a requirement of the standard
reallocations over 1000 pushes11≈ log₂(1000), not 1000
total moved1023≈ n ⇒ about one relocation per element
if growth were +1 each time499500n(n−1)/2, 488× worse
final size / capacity1000 / 1024the gap is land bought and never used

Verify ② — reserve_bench.cpp (107 push_backs)

Approachns / push_backSpeedup
no reserve1.056
after reserve(N)0.5951.78×

Only 1.78× is the right answer, not a failed experiment. Relocating an int is just a memcpy and therefore very cheap, so what reserve mainly saves is the other half: the repeated allocate/free round trips to the system. Use a type whose copy allocates memory of its own and the gap gets far larger — which is exactly what the Buffer class on Saturday of Week 2 is there to demonstrate. Being able to explain that 1.78 is worth more than remembering it.

7 · reserve: if you know the upper bound, buy the land up front

The core idea in one sentence: if you know in advance how many elements you will store at most, call reserve first, so that size never runs into capacity and not a single reallocation happens — which saves both the repeated trips to the allocator and the relocation of existing elements.

That is correct. The four details below were all measured, and every one of them is a plausible follow-up:

reserve(n) gives you exactly n, not the next power of two

reserve(1000)  ->  size = 0,  capacity = 1000

1000, not 1024. After a reserve, capacity is entirely yours to dictate; the geometric growth rules do not participate at that point.

reserve can only grow, never shrink

capacity is already 1000, then reserve(10)  ->  capacity is still 1000

Its meaning is “at least this big”, not “set it to this”. Returning the surplus requires shrink_to_fit(), and even then the standard treats it as merely a request — an implementation is free to ignore you.

③ Guessing low is not a disaster: it continues ×2 from the number you set

reserve(10), then push 40 elements  ->  capacity: 10 -> 20 -> 40

It does not fall back to 1, 2, 4, 8 and start over. Geometric growth continues from the starting point you gave, so guessing low merely means “you saved less”, never something worse.

④ The third benefit: pointers and iterators stay valid

This one is not about performance, it is about correctness, and it is the only part missing from the summary above:

reserved enough  ->  &v[0] is unchanged throughout     pointer still valid
not reserved     ->  0x...1a0 becomes 0x...350         dangling pointer -> UB

As long as no reallocation occurs, pointers, references and iterators to elements stay valid. In code that pushes into a vector while holding a pointer to one of its elements, this is a hard requirement that has nothing to do with speed. (Wednesday's warm-up will use ASan to catch exactly this bug in the act.)

Three ways to use reserve wrong

Guessing a big number — not knowing the bound and calling reserve(10'000'000) wastes 40 MB. Saving time costs memory, and you should know how much you are spending.

Reserving after the pushes — completely useless; every reallocation you were trying to avoid has already happened. reserve has to be called before the first push.

Subscripting right after a reservev.reserve(100); v[0] = 1; is an out-of-bounds write, because size() is still 0. To “allocate slots and fill them later”, use resize.

Interview angle: reserve's value is not average speed

You only measured 1.78× tonight, which does not sound spectacular. But in a low-latency setting what reserve really buys is cutting off the tail: a reallocation is an unpredictable pause proportional to the current data size — 1 ns most of the time, tens of microseconds on the call that reallocates.

What a trading path needs is “the same speed every time”, not “fast on average”. This is the same class of problem as the spike caused by a hash table rehash, and the reason some firms ban unordered_map on the hot path. Answering at this level upgrades you from “knows what reserve does” to “knows which systems it matters in, and why”.

8 · push_back vs emplace_back

One sentence; everything else follows from it

push_back takes an object that has already been built and copies or moves it into the container.
emplace_back takes the raw materials for building that object and constructs it in place inside the container.

push_back — the key words are “one” and “finished”

emplace_back — the key word is “construct”, and explicit is irrelevant

Measured call counts across four scenarios

What you are holdingpush_backemplace_backWinner
raw materials, 2 arguments
Widget(int,int)
push_back(1,2) cannot be written
only push_back(Widget(1,2)):
ctor + MOVE + dtor = 3 calls
emplace_back(3,4)
ctor = 1 call
emplace
raw material, 1 argument
Person(int)
push_back(20)
ctor + MOVE + dtor = 3 calls
the temporary went invisible, the cost did not
emplace_back(22)
ctor = 1 call
emplace
an existing object w COPY ctor = 1 call COPY ctor = 1 call identical
std::move(w) MOVE ctor = 1 call MOVE ctor = 1 call identical

So “emplace_back is faster” is false as a general statement. The accurate version: it only saves anything when it can avoid a temporary.

Why explicit stops one and not the other

CallEquivalent toForm of initializationexplicit
v.push_back(30)Person a = 30;copy-initialization (needs a conversion)blocks it ❌
v.emplace_back(30)Person a(30);direct-initialization (construction only)has no say ✅
“emplace_back is less safe” needs a qualifier

The mechanism is identical; the consequence depends on why the class author wrote explicit in the first place:

vector<Person>          emplace_back(30)         compiles -> harmless (guard against a slip)
vector<regex>           emplace_back(nullptr)    compiles -> silent UB
vector<unique_ptr<int>> emplace_back(new int(5)) compiles -> leaks if it throws

All three lines fail to compile with push_back. Not one rule of explicit has been violated — direct-initialization was always allowed to do this. What you lose is the compile-time check the class author installed, and how serious that is depends on why they installed it.

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 · Telling apart: four pairs that get confused

ABThe difference
size()capacity() size determines the valid range of v[i]; capacity is an implementation detail
reserve(n)resize(n) reserve changes capacity only and constructs nothing; resize really changes size and constructs n elements
amortized O(1)average O(1) amortized guarantees an O(n) total for any input; average is taken over a distribution of inputs and can be broken by a malicious one
geometric growth ×glinear growth +1 total moves n/(g−1) = O(n)  vs  n(n−1)/2 = O(n²). You measured a 488× gap
The easiest bug to write
v.reserve(100);
v[0] = 1;        // <- out-of-bounds write, UB

After a reserve, size() is still 0 and no element has been constructed, so v[0] simply does not exist. -Wall -Wextra stays silent throughout; only ASan catches it. To “allocate slots and fill them later”, use resize.

10 · How to answer it in an interview

Asked “What happens when you push_back into a vector that is at capacity?”, deliver all of this inside 60 seconds, then stop talking and wait for the follow-up:

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.

Asked “Why 2?”do not just answer “2”. The right shape is:

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.

That answer proves three things at once: you know what the standard does and does not govern, you ran it yourself, and you know this is a trade-off, not a theorem.

© 2026 Xuexun Lu · Licensed under CC BY-NC 4.0