Iterator Categories, Invalidation Rules & Essential STL Algorithms
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
An iterator is an abstraction holding both a memory pointer and container traversal metadata. A reference is a direct alias to the object in RAM. In node-based containers (unordered_map on rehash, deque on push), the bucket table or chunk map may move, which invalidates iterators because their traversal path is broken. However, the underlying node memory is untouched, so references and raw pointers to existing elements remain 100% valid.
If v.size() < v.capacity(), memory is not reallocated: existing elements do not move, so all iterators to existing elements remain valid, and only v.end() is invalidated. If v.size() == v.capacity(), the vector allocates a larger memory block and moves/copies all elements, invalidating ALL iterators, pointers, and references without exception.
std::unordered_map is a closed-addressing hash table using separate chaining. The container owns a dynamic array of bucket pointers (Node**). When rehash() runs, it allocates a larger array of bucket pointers and re-links each Node* into its new bucket. The Node structs storing key-value pairs are never reallocated or copied. Thus, iterators (which iterate across the bucket array) are invalidated, but references to pair<const Key, Value> inside the nodes remain valid.
Interviewer's lens
Interviewers obsess over iterator invalidation because it separates memorized trivia from genuine understanding of memory topology:
- Textbook trap: Blindly repeating 'vector invalidates on push' without understanding why or when.
- Hardware reality: Iterators are pointers wrapped in traversal logic. They break for 3 distinct physical reasons: heap buffer reallocation, contiguous memory shifting, or heap node deletion.
- Senior skills expected: Distinguishing iterator invalidation vs reference/pointer invalidation (e.g. why
unordered_maprehash destroys iterators while references stay 100% valid), writing crash-free mutation loops withit = c.erase(it), applying modern C++20std::erase_if, and knowing whystd::sortfails onstd::list.
Under the hood & memory layout
An iterator is simply a bookmark holding a pointer into container memory. When a container changes, the bookmark breaks under 3 distinct physical mechanics:
- 1. Moving the Entire Building (std::vector Reallocation): When a vector runs out of capacity, it moves every element to a brand new heap block and tears down the old one. Every bookmark, pointer, and reference to the old block immediately dangles.
- 2. Sliding Seats in a Row (std::vector Erase): Erasing index 2 shifts elements 3, 4, 5 left by one slot (memmove). The memory block stays, but bookmarks pointing to the erased slot or anything behind it now point to shifted data or dead space.
- 3. Snapping a Single Thread (std::list, std::map): Each element is an isolated node. Erasing an item deletes only that specific node, leaving every other node and bookmark completely untouched.
- 4. Replacing the Mailbox Rack (std::unordered_map Rehash): Rehashing reallocates the bucket pointer array (breaking all bucket-traversal iterators), but leaves individual element nodes untouched in memory (preserving all element references).
If size == capacity, push_back allocates a new 2Γ buffer elsewhere in heap memory, calls move/copy constructors, and frees the old buffer, turning all old pointers and iterators into dangling addresses. If size < capacity, only end() invalidates. erase(it) performs a memmove/move loop shifting $[it+1, end)$ left, invalidating pointers into the shifted region.
std::deque allocates elements in fixed-size blocks (e.g. 512 bytes) addressed via a central map of chunk pointers. Inserting at front() or back() may reallocate the central chunk map (invalidating iterators), but existing element chunks are never moved, keeping all references and pointers to elements completely valid.
Each element is allocated independently via new Node{val, prev, next}. Erasing an element unlinks its pointers and calls delete node. Only iterators pointing to the deleted node are invalidated; all other iterators, pointers, and references remain intact.
On rehash, std::unordered_map allocates a new larger array of bucket head pointers and re-links each node into its new bucket. Because the nodes themselves are not reallocated or copied, all references and pointers to std::pair<const Key, Value> remain valid, while all iterators (which traverse bucket lists) are invalidated.
Core concepts & syntax traps
The 5 Iterator Categories & Why std::sort Fails on std::list
The 5 Iterator Categories & Why std::sort Fails on std::list
C++ iterator categories define what physical navigation a container's memory topology supports:
- Input Iterator: Single-pass read-only stream, advance only via
++(e.g.std::istream_iterator). - Forward Iterator: Multi-pass read/write, advance only via
++(e.g.std::forward_list). - Bidirectional Iterator: Step forward with
++and backward with--, but cannot jump (e.g.std::list,std::set,std::map). - Random-Access Iterator: Jump arbitrary offsets in O(1) via
it + n, subtract iteratorsit2 - it1, and subscriptit[n](e.g.std::deque). - Contiguous Iterator (C++20): Random access with elements guaranteed to lie in adjacent physical memory bytes:
&*(it + n) == (&*it) + n(e.g.std::vector,std::array,std::string,std::span).
Why std::sort Rejects std::list:
std::sort uses Introsort (QuickSort hybrid), which requires jumping to middle pivots (first + (last - first)/2) in O(1) time. Because std::list::iterator is only Bidirectional, it lacks operator+ and operator-. Calling std::sort(lst.begin(), lst.end()) fails compilation! Instead, call the member function lst.sort(), which executes an O(N log N) merge sort by relinking node pointers with zero element copies.
std::vector<int> v = {4, 2, 5, 1};
std::sort(v.begin(), v.end()); // OK: contiguous random-access
std::list<int> l = {4, 2, 5, 1};
// std::sort(l.begin(), l.end()); // COMPILER ERROR: list lacks random-access!
l.sort(); // OK: member function relinks node pointers in O(N log N)Algorithms requiring random access (std::sort, std::nth_element, std::binary_search) cannot run on std::list; use member functions or advance iterators manually.
The Master STL Invalidation Table: Iterators vs References
The Master STL Invalidation Table: Iterators vs References
Senior interviewers test whether you realize that an iterator can die while a reference lives on:
- An iterator tracks container traversal logic (e.g. current bucket + position in chain).
- A reference or raw pointer points directly to the element's raw memory address.
| Container | Operation | Iterators Invalidated | References & Pointers Invalidated |
|---|---|---|---|
std::vector | push_back (full capacity) | ALL (buffer moved) | ALL (old buffer freed) |
std::vector | push_back (room available) | Only end() | NONE (buffer untouched) |
std::vector | erase(it) | it and everything after | it and everything after |
std::deque | push_front / push_back | ALL (map may resize) | NONE (100% stable in chunks!) |
std::deque | erase (in middle) | ALL | ALL |
std::list / map / set | insert | NONE | NONE |
std::list / map / set | erase(it) | ONLY it | ONLY it |
std::unordered_map | insert (triggers rehash) | ALL (buckets reallocated) | NONE (nodes never move!) |
std::unordered_map | erase(it) | ONLY it | ONLY it |
std::unordered_map<int, std::string> map = {{1, "alpha"}};
const std::string& ref = map[1]; // bind reference
// Force rehashing:
map.rehash(1000);
// Iterators are invalid, but the reference remains completely valid!
std::println("{}", ref); // Prints "alpha" safely: node address did not changeIn std::deque (push ends) and std::unordered_map (rehash), all iterators are invalidated while existing element references remain 100% valid.
The Classic Loop Crash & Safe Deletion via it = c.erase(it)
The Classic Loop Crash & Safe Deletion via it = c.erase(it)
Erasing an element inside a naive for loop is the #1 STL bug in production:
// DEADLY BUG: invalidates 'it' before increment!
for (auto it = c.begin(); it != c.end(); ++it) {
if (shouldDelete(*it)) c.erase(it);
}
Why This Breaks:
- Node Containers (
list,map) -> Use-After-Free: Callingc.erase(it)deallocates the node immediately. The loop header then executes++it, attempting to readit->nextfrom freed heap memory. Result: segfault or silent memory corruption. - Contiguous Containers (
vector,deque) -> Skipped Elements: Erasing shifts all subsequent elements left by one slot. The next element slides directly intoit's current slot. When the loop header does++it, it skips that element completely!
The Universal Idiomatic Fix:
Control iterator advancing manually: assign it = c.erase(it) when deleting, and only execute ++it when keeping the element:
for (auto it = c.begin(); it != c.end(); /* manual advance */) {
if (shouldDelete(*it)) {
it = c.erase(it); // Returns iterator to next valid element!
} else {
++it;
}
}
std::vector<int> v = {10, 20, 25, 30};
for (auto it = v.begin(); it != v.end(); ) {
if (*it % 2 != 0) {
it = v.erase(it); // returns iterator pointing to 30
} else {
++it;
}
}Always control loop advancing manually when erasing: it = c.erase(it) on deletion, ++it otherwise.
The Erase-Remove Idiom vs C++20 std::erase_if
The Erase-Remove Idiom vs C++20 std::erase_if
Deleting elements one by one with v.erase(it) inside a vector loop runs in O(N^2) time because every erase shifts all remaining elements left.
The Classic Erase-Remove Idiom (O(N)):
v.erase(std::remove(v.begin(), v.end(), val), v.end());
- Why two steps?
std::removeoperates only on iterators. It has no access to the container itself, so it cannot changev.size(). It slides kept elements to the front and returns an iterator pointing to the first garbage tail element. - The outer
v.erase(new_end, v.end())call actually truncates the container and updatessize().
The Modern C++20 Solution: C++20 obsoletes this boilerplate with unified non-member functions:
std::erase(v, val); // Erase matching values
std::erase_if(v, [](int x) { return x % 2 == 0; }); // Erase by predicate
Runs in linear O(N) time across vector, deque, list, map, and unordered_map with zero boilerplate.
std::vector<int> nums = {1, 2, 3, 4, 5, 6};
// Pre-C++20 (Erase-Remove Idiom):
nums.erase(std::remove_if(nums.begin(), nums.end(), [](int x) { return x % 2 == 0; }), nums.end());
// Modern C++20:
std::erase_if(nums, [](int x) { return x % 2 == 0; });Use C++20 std::erase_if for clean, bug-free O(N) element filtering across all STL containers.
std::sort vs std::stable_sort vs O(N) std::nth_element
std::sort vs std::stable_sort vs O(N) std::nth_element
Selecting the right algorithm is a classic systems design question:
std::sort(Unstable O(N log N)):- Uses Introsort (starts with QuickSort, switches to HeapSort if recursion depth exceeds 2 log N to prevent O(N^2) worst cases, and finishes with InsertionSort on small partitions).
- Not stable: Equivalent elements may swap relative order.
std::stable_sort(Stable O(N log N)):- Uses MergeSort to preserve the relative ordering of equivalent keys.
- Allocates a temporary buffer of up to N/2 elements. If memory allocation fails, it degrades to O(N log^2 N) in-place sorting.
std::nth_element(Linear O(N) Selection):- Uses Introselect to partition elements such that the element at index
nthis the exact element that would be there if the range were fully sorted. - Elements before
nthare <=*nth; elements after are >=*nth. - Performance Superpower: When computing medians, 99th percentiles, or top-K cutoffs,
std::nth_elementavoids sorting sub-partitions, running up to 10x faster thanstd::sort.
- Uses Introselect to partition elements such that the element at index
std::vector<int> latencies = {120, 45, 900, 310, 50, 720, 15, 600};
// Find median in strictly linear O(N) time:
auto medianIt = latencies.begin() + latencies.size() / 2;
std::nth_element(latencies.begin(), medianIt, latencies.end());
std::println("Median latency: {}", *medianIt);Use std::nth_element for O(N) percentile and median computation instead of fully sorting with std::sort.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.