Week 1 · Sat 2026-08-15
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.
| Name | What it is | Who decides |
|---|---|---|
size() | How many elements are in there. The valid range of v[i] depends on this alone | Your data |
capacity() | How much land you bought — the largest it can grow to without reallocating | The library's growth policy |
| growth factor | The rule for how much more to buy when the land runs out | The 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.
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.
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.
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.
moved is actually countingReallocation 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.
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.
“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:
| When | What happens | Cost of that call | Share |
|---|---|---|---|
size < capacity | write one element into a free slot | O(1) | 989 / 1000 |
size == capacity | allocate 2× memory + relocate every old element | O(N) | 11 / 1000 |
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
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.
The proof above bounds it below 2, but the exact value depends on where N falls between two powers of two:
| N | final capacity | total moves | moves / N | Position |
|---|---|---|---|---|
| 1000 | 1024 | 1023 | 1.023 | the one you measured — sitting just under 1024, lucky |
| 513 | 1024 | 1023 | 1.994 | just past 512, almost the worst case |
| 1024 | 1024 | 1023 | 0.999 | exactly on a power of two, the best case |
| 1025 | 2048 | 2047 | 1.997 | one more push and the ratio nearly doubles |
| 107 | 16777216 | 16777215 | 1.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.
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/2 — a function of N itself. That
is what O(N) means.
“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.
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 library | Platform | g | Can it reuse the memory it threw away? |
|---|---|---|---|
| libc++ | macOS clang (yours) | 2 | No |
| libstdc++ | Linux gcc | 2 | No |
| MSVC STL | Windows | 1.5 | Yes |
| folly::fbvector | 1.5 | Yes |
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).
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.
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 < 64 — always 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.
capacity_growth.cpp| Quantity | Measured | Meaning |
|---|---|---|
| growth factor | 2.00 | libc++'s choice, not a requirement of the standard |
| reallocations over 1000 pushes | 11 | ≈ log₂(1000), not 1000 |
| total moved | 1023 | ≈ n ⇒ about one relocation per element |
| if growth were +1 each time | 499500 | n(n−1)/2, 488× worse |
| final size / capacity | 1000 / 1024 | the gap is land bought and never used |
reserve_bench.cpp (107 push_backs)| Approach | ns / push_back | Speedup |
|---|---|---|
| no reserve | 1.056 | — |
after reserve(N) | 0.595 | 1.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.
reserve: if you know the upper bound, buy the land up frontThe 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 tworeserve(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 shrinkcapacity 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.
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.
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.)
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 reserve — v.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.
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”.
push_back vs emplace_backpush_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”v.push_back(1, 2) fails to compile outright:
no matching member function.T, the compiler tries to
implicitly convert one into existence — that is a temporary object, and it
is not free.explicit forbids precisely that implicit conversion. Add it and you are forced to
write the construction at the call site.emplace_back — the key word is “construct”, and explicit is irrelevant::new (ptr) T(args...), i.e. direct-initialization.explicit governs
conversions.| What you are holding | push_back | emplace_back | Winner |
|---|---|---|---|
raw materials, 2 argumentsWidget(int,int) |
push_back(1,2) cannot be writtenonly push_back(Widget(1,2)):ctor + MOVE + dtor = 3 calls |
emplace_back(3,4)ctor = 1 call |
emplace |
raw material, 1 argumentPerson(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.
explicit stops one and not the other| Call | Equivalent to | Form of initialization | explicit |
|---|---|---|---|
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 ✅ |
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_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 | The 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 ×g | linear growth +1 | total moves n/(g−1) = O(n) vs n(n−1)/2 = O(n²). You measured a 488× gap |
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.
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.