C++ STL Containers Explained: Choosing the Right Container for Every Situation
Introduction: Why Choosing the Right Container Matters
Imagine you’re building a game inventory system. You start with the first container you think of — maybe an array. Then you need to add and remove items dynamically. Your array doesn’t support that well. You switch to a linked list. Now iterating through your inventory is slow. By the time you optimize, you’ve rewritten half your code.
This is the story of a developer who didn’t understand STL containers.
The C++ Standard Template Library gives you many containers, each optimized for different access patterns and operations. Picking the wrong one can cost you significant performance. Picking the right one makes your code efficient, clean, and often smaller.
In this guide, we’ll explore every major STL container, understand their performance characteristics, and build mental models for choosing the right one for your situation.
Overview of STL Container Categories
The STL organizes containers into four categories:
- Sequence containers: Ordered collections (vector, deque, list, array)
- Associative containers: Sorted key-value pairs (map, set, multimap, multiset)
- Unordered containers: Hash-based lookup (unordered_map, unordered_set)
- Container adaptors: Interfaces built on other containers (stack, queue, priority_queue)
Let’s explore each.
Sequence Containers: Linear Storage with Different Access Patterns
std::vector — The Go-To Container
vector is a dynamic array. It stores elements contiguously in memory and grows as you add more elements.
Mental model: A resizable list in a line, with random access to any element.
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {10, 20, 30};
// Add elements
numbers.push_back(40); // O(1) amortized
numbers.insert(numbers.begin() + 1, 15); // O(n) - shifts elements
// Access by index
std::cout << numbers[2] << "\n"; // O(1)
std::cout << numbers.at(2) << "\n"; // O(1) with bounds checking
// Iteration
for (int num : numbers) {
std::cout << num << " ";
}
// Remove
numbers.pop_back(); // O(1)
numbers.erase(numbers.begin() + 2); // O(n)
return 0;
}
When to use vector:
- Default choice for almost everything
- Need fast random access by index
- Most additions/removals at the end
- Memory locality matters (modern CPUs love contiguous memory)
When NOT to use:
- Frequent insertions/deletions in the middle
- Must guarantee pointer/iterator stability
- Large objects that move frequently during reallocation
std::deque — Fast Front and Back Insertion
deque (double-ended queue) is a container optimized for adding and removing from both ends. Internally, it uses multiple memory blocks instead of a single contiguous array.
Mental model: A double-ended list where you can efficiently push/pop from either end.
#include <deque>
int main() {
std::deque<int> numbers;
// Fast insertion at both ends
numbers.push_back(10); // O(1)
numbers.push_front(5); // O(1) - vector can't do this efficiently
numbers.push_back(15);
// Random access still works
std::cout << numbers[1] << "\n"; // O(1)
// But iteration is slightly slower than vector (cache misses)
for (int num : numbers) {
std::cout << num << " "; // 5 10 15
}
numbers.pop_front(); // O(1)
numbers.pop_back(); // O(1)
return 0;
}
When to use deque:
- Need O(1) operations at both front and back
- Classic use: work queues where you add at one end, process at the other
- When you need to access elements by index but don’t need strict contiguity
When NOT to use:
- Iteration speed is critical (cache locality matters less than with vector)
- You only need operations at one end (use vector instead)
std::list — Doubly Linked List (When It Actually Makes Sense)
list is a doubly-linked list. Every element is in its own memory block, linked to neighbors.
Mental model: A chain of boxes, each pointing to the next and previous box. You can insert/remove anywhere if you already have an iterator to that position.
#include <list>
int main() {
std::list<int> numbers = {10, 20, 30};
// Iteration
for (int num : numbers) {
std::cout << num << " ";
}
// Find and insert
auto it = std::find(numbers.begin(), numbers.end(), 20);
if (it != numbers.end()) {
numbers.insert(it, 15); // O(1) - insert if you have iterator
}
// Remove from middle
numbers.erase(it); // O(1) if you have iterator
// But random access is slow!
// std::cout << numbers[2]; // Not supported - no operator[]
// You'd have to iterate:
auto iter = numbers.begin();
std::advance(iter, 2); // O(n) !
std::cout << *iter << "\n";
return 0;
}
Critical insight about list: It’s only fast if you already have an iterator to the position where you want to insert/remove. If you need to find the position first, the find operation is O(n), negating any benefit.
When to use list:
- You frequently insert/remove from the middle, AND you already have iterators
- You need true pointer stability (iterators never invalidate)
- Example: implementing a doubly-linked data structure
When NOT to use (and this is most cases!):
- You think it’s universally faster than vector (it’s not)
- You don’t have pre-calculated iterators
- You need random access
- You care about cache performance (linked lists cause cache misses)
Pro tip: In modern C++, std::vector almost always outperforms std::list due to cache locality. List is rarely the right choice.
std::array — Fixed-Size Stack Array
array is a compile-time fixed-size container, like a C array but with STL benefits.
#include <array>
int main() {
std::array<int, 5> numbers = {10, 20, 30, 40, 50}; // Size fixed at compile time
// All the benefits of a vector, but fixed size
for (int num : numbers) {
std::cout << num << " ";
}
std::cout << numbers.size() << "\n"; // 5
std::cout << numbers.at(2) << "\n"; // 30, with bounds checking
// No push_back, no dynamic resizing
// numbers.push_back(60); // Compilation error
return 0;
}
When to use array:
- Size is known at compile time
- You want stack allocation (no heap allocation)
- You want C++ iterator support on a fixed-size array
- Example: storing a 3D vector, or fixed-size buffer
When NOT to use:
- Size is dynamic
- You might grow/shrink the collection
Associative Containers: Sorted Collections with Key-Value Semantics
std::map — Sorted Key-Value Pairs
map stores key-value pairs in sorted order using a balanced binary search tree (typically a red-black tree).
Mental model: A phone book where names are sorted alphabetically, and you can efficiently look up any name.
#include <map>
#include <string>
int main() {
std::map<std::string, int> ages;
// Insert (or update)
ages["Alice"] = 30; // O(log n)
ages["Bob"] = 25;
ages["Charlie"] = 35;
// Lookup
std::cout << ages["Alice"] << "\n"; // O(log n)
std::cout << ages.at("Bob") << "\n"; // O(log n), throws if not found
// Check if key exists (safe way)
if (ages.find("David") != ages.end()) {
std::cout << "David found\n";
} else {
std::cout << "David not found\n";
}
// Iteration is in sorted key order
for (const auto& [name, age] : ages) {
std::cout << name << ": " << age << "\n";
// Output: Alice: 30, Bob: 25, Charlie: 35
}
// Range queries (why sorted is useful!)
auto it_lower = ages.lower_bound("Bob");
auto it_upper = ages.upper_bound("Bob");
std::cout << "Found: " << it_lower->first << "\n";
return 0;
}
When to use map:
- Need sorted order
- Frequent lookups by key
- Need range queries (“all keys between X and Y”)
- Can tolerate O(log n) lookup time
- Example: lexicon, dictionary, configuration system
When NOT to use:
- Need O(1) average lookup (use unordered_map)
- Don’t care about sort order
- Constant-time operations are critical
std::set — Sorted Unique Elements
set is like map, but stores only keys (no values), and automatically keeps elements unique.
#include <set>
int main() {
std::set<int> primes = {2, 3, 5, 7, 11};
// Insert
primes.insert(13); // O(log n)
primes.insert(5); // Ignored - already exists, no duplicates
// Check membership
if (primes.count(7) > 0) { // O(log n)
std::cout << "7 is prime\n";
}
// Iteration in sorted order
for (int p : primes) {
std::cout << p << " "; // 2 3 5 7 11 13
}
return 0;
}
When to use set:
- Need a unique collection that stays sorted
- Membership tests (checking if something is in the collection)
- Example: deduplicating a list, managing a sorted unique set
std::multimap and std::multiset
These are like map and set, but allow duplicate keys.
#include <map>
int main() {
std::multimap<std::string, std::string> phoneBook;
// Same person can have multiple phone numbers
phoneBook.insert({"Alice", "555-1234"});
phoneBook.insert({"Alice", "555-5678"});
phoneBook.insert({"Bob", "555-9999"});
// Find all entries for "Alice"
auto range = phoneBook.equal_range("Alice");
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->first << ": " << it->second << "\n";
}
return 0;
}
When to use:
- Multiple values per key
- Example: phone books, reverse indexes, tags on posts
Unordered Containers: Hash-Based Fast Lookup
std::unordered_map — O(1) Average Lookups
unordered_map uses a hash table to achieve O(1) average lookup time, instead of O(log n) like map.
Mental model: A collection of buckets. You hash the key to find which bucket it’s in, then search that bucket.
#include <unordered_map>
#include <string>
int main() {
std::unordered_map<std::string, int> scores;
// Insert
scores["Alice"] = 100; // O(1) average
scores["Bob"] = 85;
scores["Charlie"] = 92;
// Lookup
std::cout << scores["Alice"] << "\n"; // O(1) average
std::cout << scores.at("Bob") << "\n"; // O(1) average
// Iteration order is unpredictable (not sorted)
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << "\n";
// Order is arbitrary!
}
// Count/find
if (scores.count("David")) {
std::cout << "David found\n";
}
return 0;
}
Huge caveat: “Average” case assumes good hash distribution. In worst case (all hash collisions), operations degrade to O(n). This is why you need good hash functions.
When to use:
- Need fastest possible lookups
- Don’t care about sort order
- Example: caching, memoization, fast membership testing
When NOT to use:
- Need sorted order
- Predictable iteration order matters
- Working with untrusted input where attacker could trigger hash collisions
- Debuggability matters (map’s sorted order is easier to debug)
std::unordered_set
Like set, but hash-based. O(1) average lookup, but unsorted.
std::unordered_set<std::string> seen;
seen.insert("apple");
seen.insert("banana");
if (seen.count("apple")) {
std::cout << "Already seen\n"; // O(1) average
}
Hash Collisions and When to Avoid Unordered Containers
// Example: bad hash function scenario
std::unordered_map<int, std::string> data;
// If many keys hash to the same bucket, O(1) becomes O(n)
for (int i = 0; i < 1000000; ++i) {
data[i] = "value"; // Could degrade if hash collisions are severe
}
When to avoid:
- Handling untrusted input where an attacker could craft keys to cause collisions
- Performance is critical and you can’t tolerate worst-case O(n)
- Working with types that don’t have good hash functions
Container Adaptors: Higher-Level Interfaces
Stack and Queue
stack and queue are adaptors built on top of other containers (usually deque). They provide a restricted interface.
#include <stack>
#include <queue>
int main() {
// Stack: Last-In-First-Out
std::stack<int> st;
st.push(10); // O(1)
st.push(20);
st.push(30);
std::cout << st.top() << "\n"; // 30
st.pop(); // O(1)
// Queue: First-In-First-Out
std::queue<int> q;
q.push(10); // O(1)
q.push(20);
q.push(30);
std::cout << q.front() << "\n"; // 10
q.pop(); // O(1)
return 0;
}
When to use:
- Stack: parsing, backtracking, DFS
- Queue: BFS, task scheduling
- You want to enforce the interface (prevent random access)
priority_queue
A queue where elements come out in priority order.
#include <queue>
int main() {
std::priority_queue<int> pq; // Max-heap by default
pq.push(5);
pq.push(10);
pq.push(3);
std::cout << pq.top() << "\n"; // 10
pq.pop();
// Min-heap version
std::priority_queue<int, std::vector<int>, std::greater<int>> minPQ;
minPQ.push(5);
minPQ.push(10);
minPQ.push(3);
std::cout << minPQ.top() << "\n"; // 3
return 0;
}
When to use:
- Dijkstra’s algorithm, Huffman coding, task scheduling by priority
- Any time you need “next most important” element
Performance Comparison Table
| Operation | vector | deque | list | map | unordered_map | set |
|---|---|---|---|---|---|---|
| Insert at end | O(1) amort | O(1) amort | O(1) | — | — | — |
| Insert at front | O(n) | O(1) | O(1) | — | — | — |
| Insert at middle | O(n) | O(n) | O(1)† | O(log n) | O(1) avg | O(log n) |
| Access by index | O(1) | O(1) | — | — | — | — |
| Lookup by key | — | — | — | O(log n) | O(1) avg | O(log n) |
| Remove from end | O(1) | O(1) | O(1) | — | — | — |
| Remove from front | O(n) | O(1) | O(1) | — | — | — |
| Remove from middle | O(n) | O(n) | O(1)† | O(log n) | O(1) avg | O(log n) |
| Iteration | Fast | Fast | Slower | Sorted O(n) | Unsorted O(n) | Sorted O(n) |
| Memory overhead | Low | Low | High | High | Medium | High |
| Cache friendly | Excellent | Good | Poor | Poor | Poor | Poor |
†Only if you already have the iterator to that position
Real-World Decision Guide: “Which Container Should I Use?”
Do you need key-value pairs?
→ Yes: Go to the “Key-Value” section below → No: Go to “Ordered Collection” section
Key-Value Pairs:
- Need sorted keys or range queries? → map
- Need O(1) lookup and don’t care about order? → unordered_map
- Multiple values per key? → multimap or unordered_multimap
- Only storing keys (no values)? → set or unordered_set
Ordered Collection:
- Need fast random access by index? → vector (default choice!)
- Need O(1) front and back operations? → deque
- Frequent insertions/removals in the middle, AND you have iterators? → list (rare!)
- Fixed size known at compile time? → array
- LIFO (stack) behavior? → stack
- FIFO (queue) behavior? → queue
- Priority queue? → priority_queue
Iterator Invalidation Rules: A Common Gotcha
One of the trickiest aspects of STL containers is iterator invalidation — when iterators become invalid after modifications.
std::vector<int> v = {1, 2, 3, 4, 5};
auto it = v.begin() + 2; // Points to element 3
v.push_back(6); // Might reallocate, invalidating all iterators!
std::cout << *it << "\n"; // Undefined behavior!
Why this matters:
std::vector<int> numbers = {10, 20, 30, 40, 50};
// WRONG - iterator invalidated by erase
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
if (*it == 30) {
numbers.erase(it); // Invalidates it!
// ++it is undefined behavior
}
}
// CORRECT - erase returns new iterator
for (auto it = numbers.begin(); it != numbers.end(); ) {
if (*it == 30) {
it = numbers.erase(it); // erase returns next iterator
} else {
++it;
}
}
// OR use std::remove_if + erase (cleaner in C++20)
auto new_end = std::remove_if(numbers.begin(), numbers.end(),
[](int x) { return x == 30; });
numbers.erase(new_end, numbers.end());
Quick rules:
- vector/deque: Insert/erase invalidates iterators at/after that position
- list: Only invalidates iterators to the erased element
- map/set: Only invalidates iterators to the erased element
- unordered_*: Can invalidate all iterators on rehash
Safe pattern: If you’re erasing while iterating, use the iterator returned by erase():
it = container.erase(it); // Returns iterator to next element
Conclusion: Match the Container to the Job
There’s no “best” STL container — only the best container for your specific situation. The decision tree is simple:
- Do you need key-value lookup? Use a map variant
- Do you need random access? Use vector
- Do you need O(1) front operations? Use deque
- Do you need true O(1) middle insertions? Use list (rare!)
- Default case? Use vector
Master these containers, and you’ll write C++ code that’s simultaneously efficient, readable, and correct. The STL designers spent years optimizing these — use their work to your advantage.
What’s your most-used container? Challenge yourself to use the other ones intentionally in your next project and see how they change your code’s performance.
Take Your C++ Further
If you’re looking to go deeper with C++, the C++ Better Explained Ebook is perfect for you — whether you’re a complete beginner or looking to solidify your understanding. Just $19.
👉 Get the C++ Better Explained Ebook — $19