Skip to content
C++ Better Explained
Go back

C++ STL Containers Explained: Choosing the Right Container for Every Situation

Edit page

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:

  1. Sequence containers: Ordered collections (vector, deque, list, array)
  2. Associative containers: Sorted key-value pairs (map, set, multimap, multiset)
  3. Unordered containers: Hash-based lookup (unordered_map, unordered_set)
  4. 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:

When NOT to use:

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:

When NOT to use:

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:

When NOT to use (and this is most cases!):

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:

When NOT to use:

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:

When NOT to use:

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:

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:

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:

When NOT to use:

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:

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:

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:

Performance Comparison Table

Operationvectordequelistmapunordered_mapset
Insert at endO(1) amortO(1) amortO(1)
Insert at frontO(n)O(1)O(1)
Insert at middleO(n)O(n)O(1)†O(log n)O(1) avgO(log n)
Access by indexO(1)O(1)
Lookup by keyO(log n)O(1) avgO(log n)
Remove from endO(1)O(1)O(1)
Remove from frontO(n)O(1)O(1)
Remove from middleO(n)O(n)O(1)†O(log n)O(1) avgO(log n)
IterationFastFastSlowerSorted O(n)Unsorted O(n)Sorted O(n)
Memory overheadLowLowHighHighMediumHigh
Cache friendlyExcellentGoodPoorPoorPoorPoor

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

Ordered Collection:

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:

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:

  1. Do you need key-value lookup? Use a map variant
  2. Do you need random access? Use vector
  3. Do you need O(1) front operations? Use deque
  4. Do you need true O(1) middle insertions? Use list (rare!)
  5. 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



Edit page
Share this post on:

Previous Post
Smart Pointers in Modern C++: unique_ptr, shared_ptr, and weak_ptr Explained
Next Post
How to Fix Undefined Reference Errors in C++ (Linker Errors Explained)