Sequence Containers (Vector, Deque, List)
Sequence Containers Memory Models
Section titled “Sequence Containers Memory Models”The C++ standard library provides three primary sequence containers: std::vector``std::deque And std::list. Each uses a different memory model with distinct trade-offs in terms of random Access, insertion/deletion performance, cache locality, and iterator invalidation guarantees. This Section covers their internal structure, growth strategies, and practical usage patterns.
std::vector: Contiguous Memory, Capacity, and Reallocation
Section titled “std::vector: Contiguous Memory, Capacity, and Reallocation”std::vector is a sequence container that encapsulates dynamic-size arrays [N4950 §22.3.11]. Elements are stored contiguously, meaning that a pointer to the first element can be used as a C-style array. This layout provides random access via pointer arithmetic and excellent cache Locality, making std::vector the default choice for most use cases.
#include <vector>#include <iostream>#include <cassert>
int main() { std::vector<int> v; v.reserve(10); v.push_back(1); v.push_back(2); v.push_back(3);
// Contiguous guarantee [N4950 §22.3.11.1 Table 79] int* p = v.data(); assert(p[0] == 1); assert(p[1] == 2); assert(p[2] == 3);
// Random access is O(1) [N4950 §22.3.11.1 Table 79] std::cout << "v[1] = " << v[1] << "\n"; // 2
std::cout << "size=" << v.size() << " capacity=" << v.capacity() << "\n"; // size=3 capacity=10 (reserved)}The relationship between size() and capacity() is fundamental. size() returns the number of Elements currently stored, while capacity() returns the number of elements for which space has Been allocated [N4950 §22.3.11.3]. The invariant is:
shrink_to_fit() is a non-binding request to reduce capacity() to size() [N4950 §22.3.11.3]. Implementations are free to ignore it.
Internal Layout of std::vector
Section titled “Internal Layout of std::vector”A std::vector is implemented as three pointers [N4950 §22.3.11.1]:
┌──────────────────────────────────────────────────┐│ _M_start _M_finish _M_end_of_storage ││ ↓ ↓ ↓ ││ ┌─────┬─────┬─────┬─────┬───┬─────┬─────┬─────┐ ││ │ 1 │ 2 │ 3 │ ? │...│ ? │ ? │ ? │ ││ └─────┴─────┴─────┴─────┴───┴─────┴─────┴─────┘ ││ ◄── size() ──►◄──── capacity() - size() ────► ││ ◄──────────── capacity() ──────────────────────► │└──────────────────────────────────────────────────┘_M_startpoints to the beginning of the allocated block._M_finishpoints one past the last constructed element (size() = _M_finish - _M_start)._M_end_of_storagepoints one past the allocated capacity (capacity() = _M_end_of_storage - _M_start).
This three-pointer structure means sizeof(std::vector<int>) == 24 on 64-bit systems, regardless of The number of elements. The vector itself is always on the stack (or as part of another object); Only the element storage is on the heap.
#include <vector>#include <iostream>
int main() { std::cout << "sizeof(std::vector<int>): " << sizeof(std::vector<int>) << "\n"; std::cout << "sizeof(std::vector<double>): " << sizeof(std::vector<double>) << "\n"; // Both print 24 on 64-bit (three pointers, independent of T)}Growth Factor and Amortized O(1) push_back
Section titled “Growth Factor and Amortized O(1) push_back”When push_back is called and size() == capacity()The vector must reallocate: allocate a New, larger block, move or copy elements into it, and deallocate the old block. The standard does Not mandate a specific growth factor [N4950 §22.3.11.5], but most major implementations (libstdc++, Libc++, MSVC) use a factor of (geometric growth).
Formal Amortized Analysis Proof
Section titled “Formal Amortized Analysis Proof”We prove that geometric growth with factor yields amortized per push_back.
Theorem. Starting from an empty vector, inserting elements by push_back with geometric Growth factor incurs total element-copy cost Hence amortized per insertion.
Proof. Let denote the capacity after the -th reallocation, with . Then . The total number of element copies across all reallocations is the Sum of capacities at each reallocation step:
For This gives So total copies are at most for insertions, Yielding amortized cost of 2 element copies per insertion.
For We get . Each insertion is still amortized But the Constant is slightly worse. QED.
Why 1.5x Can Be Preferred Over 2x
Section titled “Why 1.5x Can Be Preferred Over 2x”Although both factors give amortized The choice of growth factor affects peak memory Usage. Consider a vector that just reallocated from capacity to capacity . Before The old buffer is freed, the vector temporarily holds bytes of allocated (but unused) Memory. The peak allocated memory at this point is .
For : peak = (the old buffer plus the new buffer of size ). For : Peak = .
More critically, a factor of exactly 2 can lead to the allocator being unable to reuse freed memory. When the vector grows from to The old block of size is freed. On the next reallocation From to The old block of size is freed. If the heap allocator places blocks Contiguously, the freed block of size or may be too small to hold the next allocation of Forcing the allocator to find a completely new region. With The old block of Size is freed when growing to And the next reallocation needs . Because The previously freed space can sometimes be reused.
This is why some production allocators (e.g., Facebook”s folly fbvector) use a factor of 1.5.
#include <vector>#include <iostream>
int main() { std::vector<int> v;
// Observe growth pattern std::size_t last_cap = 0; for (int i = 0; i < 30; ++i) { v.push_back(i); if (v.capacity() != last_cap) { std::cout << "Reallocated at size=" << v.size() << " new capacity=" << v.capacity() << "\n"; last_cap = v.capacity(); } }}Reallocation invalidates all iterators, pointers, and references to elements of the vector [N4950 §22.3.11.5]. This is a critical correctness concern: any iterator obtained before a Reallocation-triggering operation becomes undefined behavior if dereferenced afterward.
The invalidation rules for std::vector [N4950 §22.3.11.5 Table 80]:
| Operation | Iterator | Pointer | Reference |
|---|---|---|---|
push_back (no realloc) | valid | valid | valid |
push_back (realloc) | invalidated | invalidated | invalidated |
insert (no realloc) | valid if position <= insertion point | same | same |
insert (realloc) | invalidated | invalidated | invalidated |
erase | valid if position < erased element | same | same |
pop_back | valid if not pointing to last | same | same |
reserve (realloc) | invalidated | invalidated | invalidated |
resize (grow, realloc) | invalidated | invalidated | invalidated |
swap | valid (refers to exchanged elements) | valid | valid |
#include <vector>#include <iostream>#include <cassert>
int main() { std::vector<int> v = {1, 2, 3, 4, 5};
// Pre-reserve to avoid reallocation during push_back v.reserve(100);
// Safe: no reallocation will occur because capacity is sufficient auto it = v.begin(); std::cout << "Before: *it = " << *it << "\n"; // 1
v.push_back(6); // No reallocation: capacity was 100 std::cout << "After: *it = " << *it << "\n"; // Still 1, iterator valid
// Demonstrate invalidation std::vector<int> v2; v2.push_back(1); v2.push_back(2);
auto it2 = v2.begin(); // points to element 1 std::cout << "Before realloc: *it2 = " << *it2 << "\n";
// Force reallocation by exhausting capacity // capacity is likely 2, so push_back triggers realloc v2.push_back(3); // May or may not reallocate depending on initial capacity
// it2 is now INVALIDATED — undefined behavior to dereference // std::cout << *it2 << "\n"; // UB!
// Safe approach: store indices, not iterators std::size_t idx = 0; v2.push_back(4); if (idx < v2.size()) { std::cout << "v2[" << idx << "] = " << v2[idx] << "\n"; // Safe }}std::deque (double-ended queue) is a sequence container that supports insertion and Deletion at both the beginning and the end [N4950 §22.3.8]. Unlike std::vector``std::deque is Not guaranteed to store elements contiguously. Typical implementations use a map of fixed-size Blocks (segments):
A central map array stores pointers to each block. Insertion at the front or back adds to The first or last block (allocating a new block if the current one is full). This means push_front And push_back are both amortized And no reallocation of existing elements ever occurs [N4950 §22.3.8.4 Table 77].
Deque Segment Layout
Section titled “Deque Segment Layout”Map array (central control block):┌────────┬────────┬────────┬────────┬────────┐│ block0 │ block1 │ block2 │ block3 │ block4 ││ ptr │ ptr │ ptr │ ptr │ ptr │└───┬────┴───┬────┴───┬────┴───┬────┴───┬────┘ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐│ elem ││ elem ││ elem ││ elem ││ elem ││ 0..15 ││ 16..31 ││ 32..47 ││ 48..63 ││ 64..79 │└────────┘└────────┘└────────┘└────────┘└────────┘ front ◄───────Each block holds a power-of-two number of elements (e.g., 16 or 512 bytes worth). The map Array itself is a small heap-allocated array of pointers. When the map array fills up, it is Reallocated (but the element blocks are never moved). This means:
- Random access requires two pointer dereferences (map lookup, then element access), giving with a higher constant than
std::vector. - No contiguous guarantee — you cannot pass
d.data()to a C API expecting a flat array and expect all elements to be contiguous.
#include <deque>#include <iostream>
int main() { std::deque<int> d;
// O(1) insertion at both ends [N4950 §22.3.8.4 Table 77] d.push_back(1); d.push_back(2); d.push_front(0); d.push_front(-1);
for (int x : d) std::cout << x << " "; // Output: -1 0 1 2
// Random access is O(1) but with higher constant than vector std::cout << "\nd[2] = " << d[2] << "\n"; // 1
// No reallocation of existing elements occurs [N4950 §22.3.8.4] // Iterators remain valid unless the element is erased}| Operation | Iterator | Pointer | Reference |
|---|---|---|---|
push_back / push_front | valid | valid | valid |
insert at front/back | invalidated if only front/back iterators | valid | valid |
insert in middle | all invalidated | valid | valid |
erase at front/back | only erased element invalidated | valid | valid |
erase in middle | all invalidated | valid | valid |
Note: pointers and references to elements are never invalidated by insertion or erasure in std::deque (unless the element itself is erased), unlike iterators.
std::list: Doubly-Linked List, Stable Splice
Section titled “std::list: Doubly-Linked List, Stable Splice”std::list is a doubly-linked list that supports bidirectional iteration and insertion and Deletion at any position, given an iterator [N4950 §22.3.9]. Each element is stored in a separate Node, with forward and backward pointers to adjacent nodes. This means:
- No contiguous storage guarantee
- No random access ( to access the -th element)
- insertion and erasure at any position (given an iterator)
- Stable addresses: iterators, pointers, and references to non-erased elements are never invalidated [N4950 §22.3.9.5 Table 78]
Node Overhead
Section titled “Node Overhead”Each std::list node allocates a separate heap block containing:
┌──────────┬──────────┬──────────┬──────────┐│ prev* │ next* │ element │ padding ││ (8 bytes)│ (8 bytes)│ (sizeof(T))│ │└──────────┴──────────┴──────────┴──────────┘On 64-bit systems, the per-node overhead is 16 bytes (two pointers) plus any alignment padding. For std::list<int> (4-byte int), the node is 24 bytes: 16 bytes of metadata + 4 bytes of Data + 4 bytes of padding to 8-byte alignment. This is a 6x overhead compared to storing int Values in a std::vector.
The most distinctive operation is spliceWhich transfers elements between lists in time without copying or moving elements [N4950 §22.3.9.5]. This is a pointer manipulation, not a Copy:
#include <list>#include <iostream>
int main() { std::list<int> a = {1, 2, 3, 4, 5}; std::list<int> b = {10, 20, 30};
// splice transfers nodes without copying [N4950 §22.3.9.5] auto pos = a.begin(); // points to 1 std::advance(pos, 2); // points to 3
a.splice(pos, b); // Insert all of b before position 3 in a
std::cout << "a: "; for (int x : a) std::cout << x << " "; // Output: a: 1 2 10 20 30 3 4 5
std::cout << "\nb: "; std::cout << "b.size() = " << b.size() << "\n"; // 0 — b is now empty
// Pointers/iterators to spliced elements remain valid // and now refer to elements in 'a'}std::array is a fixed-size container that wraps a C-style array with the standard container Interface [N4950 §22.3.7]. It has no heap allocation, no dynamic growth, and zero overhead compared To a raw array. Since C++17, all member functions of std::array are constexprEnabling Compile-time computation.
#include <array>#include <iostream>#include <algorithm>
int main() { // Fully constexpr since C++17 [N4950 §22.3.7] constexpr std::array<int, 5> arr = {5, 3, 1, 4, 2};
constexpr auto sorted = [&]() { std::array<int, 5> copy = arr; std::sort(copy.begin(), copy.end()); return copy; }();
static_assert(sorted[0] == 1); static_assert(sorted[4] == 5);
std::cout << "sizeof(std::array<int,5>) = " << sizeof(std::array<int, 5>) << "\n"; // 20 bytes (5 * sizeof(int)), same as int[5]
// Bounds-checked access try { std::cout << arr.at(10) << "\n"; // throws std::out_of_range } catch (const std::out_of_range& e) { std::cout << "Caught: " << e.what() << "\n"; }}Key properties:
sizeof(std::array<T, N>) == N * sizeof(T)— no padding, no overhead [N4950 §22.3.7.1].- Aggregate initialization:
std::array<int, 3> a = {1, 2, 3}. - No iterator invalidation: the container never reallocates.
at()provides bounds-checked access withstd::out_of_rangeon failure [N4950 §22.3.7.2].operator[]does not bounds-check (same as raw arrays).
Choosing Between Sequence Containers
Section titled “Choosing Between Sequence Containers”| Criterion | vector | deque | list | array |
|---|---|---|---|---|
| Random access | (higher constant) | |||
push_back | Amortized | Amortized | N/A | |
push_front | Amortized | N/A | ||
| Insert in middle | with iterator | N/A | ||
| Cache locality | Excellent | Good | Poor | Excellent |
| Memory overhead | Low (capacity > size) | Moderate (block pointers) | High (2-3 pointers per node) | None |
| Iterator invalidation | High (on realloc) | Moderate | Low (only on erase) | None |
| Stable addresses | No | No | Yes | Yes (stack/static) |
| Heap allocation | Yes (for elements) | Yes (for blocks + map) | Yes (per node) | No |
| Size | Dynamic | Dynamic | Dynamic | Fixed at compile |
#include <vector>#include <deque>#include <list>#include <iostream>#include <chrono>#include <random>
template <typename Container>void benchmark_push_back(std::size_t n, const char* name) { Container c; auto start = std::chrono::high_resolution_clock::now(); for (std::size_t i = 0; i < n; ++i) { c.push_back(static_cast<int>(i)); } auto end = std::chrono::high_resolution_clock::now(); auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count(); std::cout << name << " push_back " << n << " elements: " << ms << " ms\n";}
int main() { constexpr std::size_t N = 10'000'000; benchmark_push_back<std::vector<int>>(N, "vector"); benchmark_push_back<std::deque<int>>(N, "deque"); benchmark_push_back<std::list<int>>(N, "list");}std::vector<bool>: A Specialization That Is Not a Container
Section titled “std::vector<bool>: A Specialization That Is Not a Container”std::vector<bool> is a partial specialization of std::vector that stores one bit per Element instead of one byte [N4950 §22.3.11.2]. It packs bits into unsigned long words, reducing Memory usage by 8x but introducing several surprising behaviors:
#include <vector>#include <iostream>#include <cassert>
int main() { std::vector<bool> vb = {true, false, true, true, false};
// operator[] returns a PROXY object, not bool& auto ref = vb[0]; (void)ref;
// The following does NOT compile: // bool& bad = vb[0]; // error: cannot convert proxy to bool&
// You CAN assign through the proxy vb[1] = true;
// But taking the address of an element is not straightforward: // bool* p = &vb[0]; // error: address of proxy, not a real bool
// Reference invalidation on swap: std::vector<bool> other = {false, true, false}; vb.swap(other); // After swap, any saved references/proxies from 'vb' refer to 'other' elements}The proxy reference (std::vector<bool>::reference) is a library-defined class that overloads operator bool``operator=And operator~. This causes problems with generic code that assumes T& semantics from operator[] [N4950 §22.3.11.2]. Specifically, std::vector<bool> does not Satisfy the container requirements in [N4950 §22.2] because its elements are not addressable.