Skip to content

Sequence Containers (Vector, Deque, List)

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 O(1)O(1) 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:

\mathrm{size() \leq \mathrm{capacity()

shrink_to_fit() is a non-binding request to reduce capacity() to size() [N4950 §22.3.11.3]. Implementations are free to ignore it.

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_start points to the beginning of the allocated block.
  • _M_finish points one past the last constructed element (size() = _M_finish - _M_start).
  • _M_end_of_storage points 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 ×2\times 2 (geometric growth).

We prove that geometric growth with factor α>1\alpha \gt 1 yields amortized O(1)O(1) per push_back.

Theorem. Starting from an empty vector, inserting nn elements by push_back with geometric Growth factor α\alpha incurs total element-copy cost O(n)O(n)Hence amortized O(1)O(1) per insertion.

Proof. Let ckc_k denote the capacity after the kk-th reallocation, with c0=1c_0 = 1. Then ck=αkc_k = \lceil \alpha^k \rceil. The total number of element copies across all reallocations is the Sum of capacities at each reallocation step:

C(n)=k=0logαnckk=0logαnαk=αlogαn+11α1αnα1C(n) = \sum_{k=0}^{\lceil \log_\alpha n \rceil} c_k \leq \sum_{k=0}^{\lceil \log_\alpha n \rceil} \alpha^k = \frac{\alpha^{\lceil \log_\alpha n \rceil + 1} - 1}{\alpha - 1} \leq \frac{\alpha \cdot n}{\alpha - 1}

For α=2\alpha = 2This gives C(n)2nC(n) \leq 2nSo total copies are at most 2n2n for nn insertions, Yielding amortized cost of 2 element copies per insertion.

For α=1.5\alpha = 1.5We get C(n)3nC(n) \leq 3n. Each insertion is still amortized O(1)O(1)But the Constant is slightly worse. QED.

Although both factors give amortized O(1)O(1)The choice of growth factor affects peak memory Usage. Consider a vector that just reallocated from capacity cc to capacity αc\alpha c. Before The old buffer is freed, the vector temporarily holds αc\alpha c bytes of allocated (but unused) Memory. The peak allocated memory at this point is c+αc=c(1+α)c + \alpha c = c(1 + \alpha).

For α=2\alpha = 2: peak = 3c3c (the old buffer plus the new buffer of size 2c2c). For α=1.5\alpha = 1.5: Peak = 2.5c2.5c.

More critically, a factor of exactly 2 can lead to the allocator being unable to reuse freed memory. When the vector grows from cc to 2c2cThe old block of size cc is freed. On the next reallocation From 2c2c to 4c4cThe old block of size 2c2c is freed. If the heap allocator places blocks Contiguously, the freed block of size cc or 2c2c may be too small to hold the next allocation of 4c4cForcing the allocator to find a completely new region. With α=1.5\alpha = 1.5The old block of Size cc is freed when growing to 1.5c1.5cAnd the next reallocation needs 2.25c2.25c. Because c+1.5c=2.5c>2.25cc + 1.5c = 2.5c \gt 2.25cThe 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();
}
}
}
### Iterator, Pointer, and Reference Invalidation Rules

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]:

OperationIteratorPointerReference
push_back (no realloc)validvalidvalid
push_back (realloc)invalidatedinvalidatedinvalidated
insert (no realloc)valid if position <= insertion pointsamesame
insert (realloc)invalidatedinvalidatedinvalidated
erasevalid if position < erased elementsamesame
pop_backvalid if not pointing to lastsamesame
reserve (realloc)invalidatedinvalidatedinvalidated
resize (grow, realloc)invalidatedinvalidatedinvalidated
swapvalid (refers to exchanged elements)validvalid
#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`: Segment-Based Memory, No Reallocation

std::deque (double-ended queue) is a sequence container that supports O(1)O(1) 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):

\mathrm{deque = \underbrace{[\mathrm{block_0][\mathrm{block_1] \cdots [\mathrm{block_{n-1}]}_{\mathrm{fixed-size 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 O(1)O(1)And no reallocation of existing elements ever occurs [N4950 §22.3.8.4 Table 77].

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 O(1)O(1) 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
}
Invalidation rules for `std::deque` differ from `std::vector` [N4950 §22.3.8.4 Table 77]:
OperationIteratorPointerReference
push_back / push_frontvalidvalidvalid
insert at front/backinvalidated if only front/back iteratorsvalidvalid
insert in middleall invalidatedvalidvalid
erase at front/backonly erased element invalidatedvalidvalid
erase in middleall invalidatedvalidvalid

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 O(1)O(1) 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 (O(n)O(n) to access the kk-th element)
  • O(1)O(1) 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]

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 O(1)O(1) 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`: Fixed-Size, Zero Overhead

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 with std::out_of_range on failure [N4950 §22.3.7.2].
  • operator[] does not bounds-check (same as raw arrays).
Criterionvectordequelistarray
Random accessO(1)O(1)O(1)O(1) (higher constant)O(n)O(n)O(1)O(1)
push_backAmortized O(1)O(1)Amortized O(1)O(1)O(1)O(1)N/A
push_frontO(n)O(n)Amortized O(1)O(1)O(1)O(1)N/A
Insert in middleO(n)O(n)O(n)O(n)O(1)O(1) with iteratorN/A
Cache localityExcellentGoodPoorExcellent
Memory overheadLow (capacity > size)Moderate (block pointers)High (2-3 pointers per node)None
Iterator invalidationHigh (on realloc)ModerateLow (only on erase)None
Stable addressesNoNoYesYes (stack/static)
Heap allocationYes (for elements)Yes (for blocks + map)Yes (per node)No
SizeDynamicDynamicDynamicFixed 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&lt;bool>: A Specialization That Is Not a Container

Section titled “std::vector&lt;bool>: A Specialization That Is Not a Container”

std::vector&lt;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&lt;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&lt;bool> does not Satisfy the container requirements in [N4950 §22.2] because its elements are not addressable.