Learn C++ STL

Lesson 3 of 6 ¡ What the STL Is, and Why It Changes How You Write C++

Module 0 ¡ What the STL Is, and Why It Changes How You Write C++

The Containers at a Glance: What Each One Is Good At

FreeReading

In this lesson

  • List the containers this track teaches, in their three families.
  • Name the one question each container answers fastest.
  • Read a cost table written in O(1), O(log n) and O(n), without yet knowing how any container works inside.

Kenji has read Lesson 1 and has one question: "Which container is the fastest?" Amara, who writes the cleanest code on the team, answers with a question of her own: "Fastest at what?"

A container that adds to the end in one step may need a million steps to find a value. A container that finds a value in twenty steps cannot tell you quickly what is at position 5. This lesson is Amara's answer: every container, the question it is built for, and one table of costs.

You will not learn how any of them works inside yet. That is what Modules 2 to 10 are for. Here you get the whole shelf at once, so each later module fills in a picture you already hold.

Three families of containers

The containers come in three families, sorted by how they arrange their elements.

  • Sequence containers keep elements in the order you put them in: vector, string, array, deque and list.
  • Container adaptors wrap a sequence container and let you touch only one or two ends: stack, queue and priority_queue.
  • Associative containers arrange elements by their value, so you can find one quickly. They are set and map, their multi forms that allow repeats, and their unordered forms that use hashing instead of order.

To make the differences easy to see, every example below puts the same three numbers in, in the same order: 30, then 10, then 20. Watch what order they come back out in. That order alone tells you a lot about each container.

Declaring any container

std::vector<int> marks;
std::map<std::string, int> price;
  • std::vector, std::map: the container's name, from its own header (<vector>, <map>).
  • <int>: the type of element it holds, written in angle brackets.
  • <std::string, int>: a map holds pairs, so it needs two types, the key and the value.
  • marks, price: the name you choose. A new container starts empty.

Sequence containers: in the order you put them

These five keep your elements in a row. They differ in where adding is cheap and whether the size can change.

Example 1: vector, a growing array
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v;
    v.push_back(30);
    v.push_back(10);
    v.push_back(20);
    std::cout << "vector:";
    for (int x : v) std::cout << ' ' << x;
    std::cout << " | v[1] = " << v[1] << '\n';
}
vector: 30 10 20 | v[1] = 10

Its question: "what is at position i?", answered in one step, plus cheap adding at the end. It is the default container, and the one to reach for first.

Run in Compiler
Example 2: string, a vector of characters
#include <iostream>
#include <string>

int main()
{
    std::string s = "cat";
    s += "fish";
    s.push_back('!');
    std::cout << "string: " << s << " | size " << s.size() << " | s[0] = " << s[0] << '\n';
}
string: catfish! | size 8 | s[0] = c

Its question: the vector's, for text. It also knows text jobs such as joining with += and searching for a word. No '\0' to manage, and no buffer that can overflow.

Run in Compiler
Example 3: array, a fixed size that knows itself
#include <array>
#include <iostream>

int main()
{
    std::array<int, 3> a = {30, 10, 20};
    std::cout << "array:";
    for (int x : a) std::cout << ' ' << x;
    std::cout << " | size is always " << a.size() << '\n';
}
array: 30 10 20 | size is always 3

Its question: position i, for a count you know when you write the program. It is a C array that knows its own size, with no growing at all.

Run in Compiler
Example 4: deque, cheap at both ends
#include <deque>
#include <iostream>

int main()
{
    std::deque<int> d;
    d.push_back(30);
    d.push_front(10);
    d.push_back(20);
    std::cout << "deque:";
    for (int x : d) std::cout << ' ' << x;
    std::cout << " | d[0] = " << d[0] << '\n';
}
deque: 10 30 20 | d[0] = 10

Its question: "add or remove at either end", in one step each, and still position i. A deque (say "deck", a double-ended queue) is what a vector would be if adding at the front were cheap too.

Run in Compiler
Example 5: list, cheap in the middle
#include <iostream>
#include <list>

int main()
{
    std::list<int> l = {30, 20};
    auto it = l.begin();
    ++it;
    l.insert(it, 10);
    std::cout << "list:";
    for (int x : l) std::cout << ' ' << x;
    std::cout << '\n';
}
list: 30 10 20

Its question: "insert or remove right here", where you already stand, without moving any other element. It cannot answer "what is at position i?" quickly: it has no l[1] at all. It is the linked list you wrote by hand in C.

Run in Compiler

Adaptors: one door in, one door out

An adaptor takes a sequence container and hides most of it. You can only add and remove at the ends it allows. That is not a weakness: when your problem only ever needs the newest or the oldest item, the adaptor makes every other mistake impossible.

Example 6: stack, last in, first out
#include <iostream>
#include <stack>

int main()
{
    std::stack<int> s;
    s.push(30);
    s.push(10);
    s.push(20);
    std::cout << "stack pops:";
    while (!s.empty()) {
        std::cout << ' ' << s.top();
        s.pop();
    }
    std::cout << '\n';
}
stack pops: 20 10 30

Its question: "what was added last?". Like a pile of plates, the last one on is the first one off. Undo buttons and the C call stack you met in the C track work this way.

Run in Compiler
Example 7: queue, first in, first out
#include <iostream>
#include <queue>

int main()
{
    std::queue<int> q;
    q.push(30);
    q.push(10);
    q.push(20);
    std::cout << "queue pops:";
    while (!q.empty()) {
        std::cout << ' ' << q.front();
        q.pop();
    }
    std::cout << '\n';
}
queue pops: 30 10 20

Its question: "what was added first?". It is a line at a shop counter: whoever arrived first is served first.

Run in Compiler
Example 8: priority_queue, the biggest first
#include <iostream>
#include <queue>

int main()
{
    std::priority_queue<int> pq;
    pq.push(30);
    pq.push(10);
    pq.push(20);
    std::cout << "priority_queue pops:";
    while (!pq.empty()) {
        std::cout << ' ' << pq.top();
        pq.pop();
    }
    std::cout << '\n';
}
priority_queue pops: 30 20 10

Its question: "what is the biggest right now?", even while new elements keep arriving. It is an emergency room: the most urgent case goes next, whoever came first.

Run in Compiler

Associative containers: found by value

These do not keep your order at all. They arrange elements so that "is this value here?" is fast. The ordered ones (set, map) keep elements sorted; the unordered ones give up the order to be faster still on average.

Example 9: set and multiset, sorted and searchable
#include <iostream>
#include <set>

int main()
{
    std::set<int> s = {30, 10, 20, 10};
    std::multiset<int> m = {30, 10, 20, 10};
    std::cout << "set:";
    for (int x : s) std::cout << ' ' << x;
    std::cout << " | multiset:";
    for (int x : m) std::cout << ' ' << x;
    std::cout << " | is 20 in the set? " << s.count(20) << '\n';
}
set: 10 20 30 | multiset: 10 10 20 30 | is 20 in the set? 1

Its question: "is x here?", fast, with everything kept sorted. A set keeps one copy of each value, so the second 10 was dropped. A multiset keeps repeats.

Run in Compiler
Example 10: map, a value for every key
#include <iostream>
#include <map>
#include <string>

int main()
{
    std::map<std::string, int> price;
    price["tea"] = 30;
    price["bun"] = 10;
    price["egg"] = 20;
    std::cout << "map:";
    for (const auto& p : price) std::cout << ' ' << p.first << '=' << p.second;
    std::cout << " | egg costs " << price["egg"] << '\n';
}
map: bun=10 egg=20 tea=30 | egg costs 20

Its question: "what goes with this key?". A map is a set of keys, each carrying a value, kept sorted by key: bun comes out first because it is first alphabetically. A multimap allows one key to appear more than once.

Run in Compiler
Example 11: unordered_set and unordered_map, no order, fastest on average
#include <iostream>
#include <string>
#include <unordered_map>
#include <unordered_set>

int main()
{
    std::unordered_set<int> seen = {30, 10, 20};
    std::unordered_map<std::string, int> stock = {{"tea", 30}, {"bun", 10}};
    std::cout << "unordered_set:";
    for (int x : seen) std::cout << ' ' << x;
    std::cout << " | is 10 seen? " << seen.count(10);
    std::cout << " | buns in stock: " << stock["bun"] << '\n';
}
unordered_set: 20 10 30 | is 10 seen? 1 | buns in stock: 10

Its question: the set's and the map's, faster on average, when you never need the elements in order. The order they print in comes from hashing, not from you or from sorting. Another compiler's library may print a different order, so never rely on it.

Run in Compiler

The cost table

Costs in this track are written in big-O notation, where n is the number of elements in the container. You only need three of them for now, explained informally.

  • O(1), constant: the cost does not grow with n. One step with ten elements, one step with ten million.
  • O(log n), logarithmic: the cost grows slowly. Doubling n adds about one step, so a million elements need about twenty.
  • O(n), linear: the cost grows with n. Ten times the elements, ten times the work.
O(1), O(log n) and O(n) for n from 1 to 1,000,000 1 10 100 1,000 10,000 100,000 1,000,000 n, the number of elements (each mark is 10 times the last) 1 100 10,000 1,000,000 steps (each mark is 100 times the last) O(n): 1,000,000 steps O(log n): about 20 steps O(1): 1 step
Figure 1. The three costs from 1 to 1,000,000 elements, both axes stretched by tens so all three fit. At a million elements, O(log n) is about 20 steps and O(n) is a million.

Now the table. Read a row as "this container, asked this question, costs this much". A dash means the container does not offer that operation at all. In the last three rows, "add" has no end or front. An element goes where its value says, so the add cost sits in the first column. Amortised means "on average over many calls"; Lesson 5 explains it.

ContainerAdd at endAdd at frontAdd in middleFind a valueElement iWalk in sorted order
vector, stringO(1) amortisedO(n)O(n)O(n)O(1)no
array---O(n)O(1)no
dequeO(1)O(1)O(n)O(n)O(1)no
listO(1)O(1)O(1) where you standO(n)O(n)no
stackO(1), top only----no
queueO(1)----no
priority_queueO(log n), to where it belongs--the biggest only, O(1)-no
set, map and multi formsO(log n), into sorted place--O(log n)-yes, O(n) for all
unordered_set, unordered_mapO(1) on average--O(1) on average-no

Two lines in the table deserve a second look. The list adds in the middle in O(1), but only once you are standing there; walking to the spot is O(n). And "on average" for the unordered containers is a real condition: on bad data their O(1) becomes O(n). Module 10 shows you that data.

So "which is fastest?" has no answer, and "fastest at finding by key, in sorted order?" has one: map. That is Amara's whole point.

Where this is used

  • Linux's CFS scheduler. From kernel 2.6.23 to 6.5, the scheduler kept runnable tasks in a red-black tree ordered by how much CPU time each had used. That is the balanced tree GCC's library builds std::set and std::map on.
  • Redis sorted sets. The usual way to build a game leaderboard. They keep members ordered by score and also find one member by name fast. Those are the two questions a map answers, and Redis uses a skip list and a hash table for them.
  • Hunspell. The spell checker used by LibreOffice and Firefox loads its dictionary into a hash table. "Is this word in the dictionary?" is the unordered_set question, asked once per word you type.
  • Emacs and VS Code. A text editor inserts in the middle of its text constantly, the one thing a plain vector is slow at. Emacs keeps a gap buffer and VS Code a piece tree, both built to make that insert cheap.

Common mistakes

1. Indexing a container that has no positions.

std::stack<int> s;
s.push(1);
std::cout << s[0] << "\n";

GCC 12 says error: no match for 'operator[]' (operand types are 'std::stack<int>' and 'int'). A stack only shows its top, with s.top(). The same message appears for a list, a set and a queue. If you need position i, you need a vector, deque or array.

2. Expecting an unordered container to come out sorted.

Example 11 put in 30, 10, 20 and printed 20 10 30. That order is neither yours nor sorted, and it can change when the container grows. If your output must be in order, use set or map, or copy the elements into a vector and sort it.

3. Choosing by speed alone.

Kenji builds a leaderboard with unordered_map because it is "the fastest". Then he must print the top ten, in order, after every game. The fast lookups win him nothing, and he sorts the whole board each time. Start from the question, as the table does, and the container picks itself.

Brain teaser

Five jobs. Pick the container for each, and name the question from this lesson that it answers.

  1. The Undo button of a text editor.
  2. Print jobs waiting for one printer.
  3. An emergency room deciding who is seen next.
  4. Checking whether a username is already taken, among ten million, with no need for any order.
  5. A dictionary that prints every word from A to Z with its meaning.

For each job, ask which item it needs next: the newest, the oldest, the biggest, one by name, or all of them in order.

Exercise 1Easy

Look at the outputs of Examples 1, 6, 7, 8 and 9. All five containers were given 30, 10, 20 in that order. For each one, write one sentence on why the numbers came out in the order they did.

Check yourself. You should have five different reasons: the order you added, newest first, oldest first, biggest first, and sorted.

Exercise 2Medium

Open Example 10 in the Playground. Add a fourth item, price["apple"] = 50;, after the egg line. Before you run it, write down the line you expect.

Rules. Then change std::map to std::unordered_map and add #include <unordered_map>. Run it again and compare the order.

Check yourself. With the map, apple=50 comes first. With the unordered map the order is whatever the hashing gives, and it is not alphabetical.

Exercise 3Hard

A web browser has a Back button and a Forward button. Visiting a new page clears the Forward history. Choose the container, or containers, you would use, and explain your choice in one paragraph using the table.

Rules. Say what happens to each container on Back, on Forward and on visiting a new page.

Check yourself. One good answer is two stacks: Back pops from one and pushes onto the other, and a new page clears the Forward stack. A deque with a position also works. Either way, every operation is O(1).

Common doubts

  • Do I have to memorise this table?

    No. By the end of Module 10 you will know each row because you understand how each container is built. For now, keep the page open and use it to choose.

  • Why are stack and queue called adaptors and not containers?

    Because they hold nothing themselves. A stack is a deque underneath, by default, with every operation except the top hidden away. Module 6 shows you how to choose what is underneath.

  • If unordered_map is O(1) and map is O(log n), why ever use map?

    Because a map keeps its keys sorted, and some jobs need that order. Also, O(log n) for a million keys is about twenty steps, which is very fast already. And an unordered map's O(1) is only an average, as the table says.

  • Is std::string really a container?

    It behaves like one. It holds characters in a row, has begin() and end(), and every algorithm works on it. It was designed separately and joined the STL family later, which is why it has extra text functions a vector does not.

Key takeaways

  • Sequence containers keep your order; adaptors allow only one or two ends; associative containers arrange by value.
  • Each container is built to answer one question fast, and no container is fastest at everything.
  • O(1) does not grow with n, O(log n) grows slowly, O(n) grows with n.
  • Ordered containers (set, map) keep elements sorted; unordered ones are faster on average and keep no order you can rely on.
  • Choose the question first, then the container.

Next you will see the thread that ties all these containers to the algorithms: the iterator, moving.

End of lesson 3

Mark it done, and your progress moves with you.

Next: Iterators and Algorithms: How the Pieces Connect