Container Complexity & Iterator Invalidation
STL Containers showed you what each container is for. In interviews and in real code reviews you will be asked two deeper questions: how expensive is this operation? and is this iterator, pointer or reference still valid after I changed the container? Getting the first wrong makes programs slow; getting the second wrong causes undefined behaviour.
This lesson gives you the complexity guarantees the C++ standard makes, explains vector growth, lists the invalidation rules you need to remember, and shows the safe ways to erase while looping.
Big-O in one minute
Big-O notation describes how the work grows as the number of items n grows. It ignores constant factors and only describes the trend:
| Notation | Name | Meaning for 1,000,000 items |
|---|---|---|
| O(1) | Constant | Same work no matter how many items |
| O(log n) | Logarithmic | About 20 steps (2 to the power 20 is about a million) |
| O(n) | Linear | Work proportional to a million |
| O(n log n) | Good sorting algorithms, such as std::sort | |
| O(n²) | Quadratic | A loop inside a loop over all items: often too slow |
Amortised O(1) means "usually constant, occasionally expensive, but constant on average over many operations". vector::push_back is the classic example.
What each operation costs
These are the guarantees the standard library gives (n is the container size):
| Operation | vector | deque | list | map / set | unordered_map / unordered_set |
|---|---|---|---|---|---|
| Access by index | O(1) | O(1) | not available | not available | not available |
| Add or remove at the end | amortised O(1) | O(1) | O(1) | n/a | n/a |
| Add or remove at the front | O(n) | O(1) | O(1) | n/a | n/a |
| Insert or erase in the middle | O(n) | O(n) | O(1) with an iterator | O(log n) | O(1) average, O(n) worst |
| Find by value or key | O(n) | O(n) | O(n) | O(log n) | O(1) average, O(n) worst |
| Kept sorted | No | No | No | Yes | No |
Two warnings about this table. First, the hash containers are O(1) on average; with a poor hash function, or many keys landing in the same bucket, a lookup can scan many items. Second, Big-O ignores memory layout. A vector stores items side by side, which the CPU cache loves, so a linear scan of a small vector is often faster than a map or list lookup. For a few hundred items, measure before choosing a "faster" container (see Performance & Profiling).
Vector growth, capacity and reserve
A vector has a size (items stored) and a capacity (items it has room for). When push_back finds the vector full, it allocates a bigger block, moves every item across, and frees the old block. The new capacity is a multiple of the old one, which is what makes push_back amortised O(1). The exact growth factor is up to the library.
#include <cstddef>
#include <print>
#include <vector>
int count_reallocations(bool reserve_first) {
std::vector<int> tickets;
if (reserve_first) {
tickets.reserve(1000);
}
int moves = 0;
std::size_t last_capacity = tickets.capacity();
for (int i = 0; i < 1000; ++i) {
tickets.push_back(i);
if (tickets.capacity() != last_capacity) {
++moves;
last_capacity = tickets.capacity();
}
}
return moves;
}
int main() {
std::println("Without reserve: {} reallocations", count_reallocations(false));
std::println("With reserve: {} reallocations", count_reallocations(true));
}Without reserve: 11 reallocations With reserve: 0 reallocations
The first line is what GCC's library prints, because it doubles the capacity each time (1, 2, 4, ... 1024). Microsoft's library grows by about one and a half times and reports more reallocations. The second line is the same everywhere: after reserve(1000), the standard guarantees no reallocation until the size exceeds the capacity. When you know roughly how many items are coming, call reserve first. shrink_to_fit() asks the vector to release unused capacity, but the library may ignore the request.
Iterator invalidation rules
An iterator, pointer or reference into a container becomes invalid when the element it refers to moves or disappears. Using it afterwards is undefined behaviour. The rules to remember:
| Container | Insert | Erase |
|---|---|---|
vector, string | If capacity changed: all invalid. Otherwise: those at and after the insert point | Those at and after the erased element |
deque | At either end: all iterators invalid, references stay valid. In the middle: all invalid | At either end: only the erased ones (plus end() when erasing at the back). In the middle: all |
list, forward_list | None | Only the erased element |
map, set (and multi versions) | None | Only the erased element |
unordered_map, unordered_set | If a rehash happens: all iterators invalid, references still valid. Otherwise none | Only the erased element |
The node-based containers (list, map, set) are the most forgiving because each element lives in its own memory block and never moves. A vector is the least forgiving. This is the bug from Undefined Behaviour & Sanitizers: a pointer to runs[0] was kept while push_back reallocated.
The same trap appears in a range-for loop:
std::vector<int> queue = {1, 2, 3};
for (int x : queue) {
if (x == 2) {
queue.push_back(99); // undefined behaviour: the loop's hidden iterators may now dangle
}
}Fix it by collecting the new items in a second vector and appending them after the loop, or by looping with an index and reading queue.size() once before the loop.
Erasing safely while looping
erase returns an iterator to the element after the erased one. Use that iterator instead of incrementing the old, now invalid one:
#include <map>
#include <print>
#include <string>
#include <vector>
int main() {
std::vector<int> waiting = {101, 102, 103, 104, 105, 106};
for (auto it = waiting.begin(); it != waiting.end();) {
if (*it % 2 == 0) {
it = waiting.erase(it); // continue from the element after the erased one
} else {
++it;
}
}
std::println("{} {} {}", waiting[0], waiting[1], waiting[2]);
std::map<std::string, int> seats = {
{"Shatabdi", 0}, {"Rajdhani", 12}, {"Duronto", 0}, {"Garib Rath", 40}};
auto removed = std::erase_if(seats, [](const auto& entry) { return entry.second == 0; });
std::println("Removed {} full trains", removed);
for (const auto& [train, free_seats] : seats) {
std::println("{}: {}", train, free_seats);
}
}101 103 105 Removed 2 full trains Garib Rath: 40 Rajdhani: 12
The C++20 function std::erase_if does the whole job in one call for every standard container and returns how many items it removed. Prefer it to hand-written loops; for vectors it is also faster, because it moves each remaining item only once instead of shifting the tail after every single erase (which makes the hand-written loop O(n²) in the worst case).
Map lookups that change the map
map[key] is convenient, but if the key is missing it inserts a default value. Reading with [] silently grows the map:
#include <map>
#include <print>
#include <string>
int main() {
std::map<std::string, int> marks = {{"Priya", 88}};
if (marks["Rohan"] > 50) { // Rohan did not exist: now added with 0
std::println("Rohan passed");
}
std::println("Students: {}", marks.size());
if (auto it = marks.find("Isha"); it != marks.end()) { // lookup without inserting
std::println("Isha: {}", it->second);
}
marks.insert_or_assign("Priya", 91); // update or add, clearly
std::println("Priya: {}, students: {}", marks.at("Priya"), marks.size());
}Students: 2 Priya: 91, students: 2
Use find or contains to check, at to read (it throws std::out_of_range if the key is missing), insert_or_assign to set, and try_emplace to add only if the key is new. operator[] also cannot be used on a const map, which is another hint that it is meant for writing.
Common mistakes
- Keeping pointers or iterators into a vector across
push_backorinsert. Store indexes, or callreservefirst if you truly need stable addresses (or use a node-based container). ++itaftererase(it). The old iterator is invalid; use the return value oferaseorstd::erase_if.- Using
map[key]to check whether a key exists. It inserts the key. Usecontainsorfind. - Choosing
listfor speed. Its O(1) insert needs an iterator you already have, and its scattered memory makes traversal slow. - Assuming hash containers are always O(1). Bad hashes or many collisions make them linear.
Key points
- Vector: O(1) index access and amortised O(1)
push_back, but O(n) insert or erase anywhere except the end. mapandsetare O(log n) and sorted;unordered_containers are O(1) on average and unsorted.reserveprevents reallocations; growth factors differ between libraries.- Vector reallocation invalidates all iterators, pointers and references; node-based containers only invalidate erased elements.
- Erase with
it = c.erase(it)or C++20std::erase_if; read maps withfind,containsorat, not[].
Exercise
A canteen in Jaipur keeps orders in a std::vector<std::string>. Write code that removes every order for "Samosa" using std::erase_if, prints how many were removed and the remaining orders, and then explain why the following loop is wrong: for (auto it = v.begin(); it != v.end(); ++it) if (*it == "Samosa") v.erase(it);
Show solution
#include <print>
#include <string>
#include <vector>
int main() {
std::vector<std::string> orders = {"Samosa", "Kachori", "Samosa", "Lassi", "Samosa"};
auto removed = std::erase_if(orders, [](const std::string& o) { return o == "Samosa"; });
std::println("Removed {}", removed);
for (const auto& o : orders) {
std::println("{}", o);
}
}Removed 3 Kachori Lassi
In the broken loop, v.erase(it) makes it invalid, and then ++it uses it: undefined behaviour. Even if it happened to work, it would skip the element right after each erased one, because erasing shifts that element into the erased position.
Quick Quiz
1. What is the complexity of push_back on a std::vector?
Most calls are constant; occasional reallocations average out because capacity grows by a multiple.
2. After v.push_back(x) causes a reallocation, which iterators into v are still valid?
Reallocation moves every element to new memory.
3. Which statement about std::map::operator[] is true?
Use find, contains or at to read without inserting.
4. Which container keeps iterators to other elements valid when you erase one element?
In node-based containers only iterators to the erased element become invalid.
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
