Module 10 ¡ unordered_set and unordered_map: Hashing, Collisions and the Anti-Hash Test
unordered_set and unordered_map: Jump Straight to the Right Bucket
In this lesson
- Declare a
std::unordered_setand astd::unordered_map, insert keys, ask for them withcountandfind, erase them, and store and read a value with[]. - Explain in one sentence how a lookup jumps to one bucket, and why the walk is neither sorted nor in insertion order.
- Name the cost of a lookup,
O(1)on average andO(n)at worst, and say what makes the worst case happen.
Zara runs the server of an online game, and every new player picks a username. Thousands of times a second, the server must answer one question: is this name taken? A scan of a million names costs a million comparisons per question. A set from Module 8 needs a few dozen, and an array indexed by the name cannot exist, because a name is not a number. But what if the name itself could say where to look?
A number that says where to look
A hash function turns a key into a whole number, and the same key always gives the same number while the program runs. C++ ships one for every number type and for string: std::hash. hash<int>() makes a small hashing object, and (42) hands it the key 42.
#include <functional>
#include <iostream>
#include <string>
using namespace std;
int main() {
cout << hash<int>()(42) << '\n';
cout << hash<string>()("cat") << '\n';
cout << hash<string>()("act") << '\n';
for (int key : {12, 7, 20, 9, 31}) {
cout << key << " % 5 = " << hash<int>()(key) % 5 << '\n';
}
return 0;
}
42
5726227304015210059
18297190751667054611
12 % 5 = 2
7 % 5 = 2
20 % 5 = 0
9 % 5 = 4
31 % 5 = 1
On GCC 12, an int's hash is the number itself, so 42 gives 42. A string's hash mixes every byte, so cat and act, the same three letters, get very different numbers. These numbers are GCC 12's own: the C++ standard fixes none of them, and another library may print others.
A hash container keeps an array of buckets, short lists of keys. To place a key, it takes the key's hash modulo the number of buckets, as the program's last lines do. That number is the key's bucket. To find the key later, it computes the same bucket and searches that bucket only. Two keys in one bucket, like 12 and 7 here, are a collision.
So a lookup never scans the whole table: one hash, one %, and a search of one short bucket.
Watch the table fill, collide and grow
unordered_set<int> s(5) asks for a table of five buckets. The widget replays what GCC 12 did with it, one call at a time. Every state was printed by a program on Compiler Explorer. Watch the order inside a bucket, and what happens when the sixth key arrives.
The load factor is the number of keys divided by the number of buckets. GCC 12 lets it reach 1, so five keys may fill five buckets. A sixth would push it past 1, so the table first grew to 11 buckets and moved every key to its new bucket: a rehash. GCC 12 picked 11, a prime number at least twice the old count, and lesson 05 shows the whole list.
Inside a bucket, the newest key comes first: 7 went in front of 12. find(31) compared three keys of bucket 9 and touched no other bucket. find(5) met an empty bucket and stopped without a single comparison. So a lookup costs one bucket's worth of comparisons, and the rehash keeps every bucket short.
The syntax you need on day one
std::unordered_set and std::unordered_map, and their everyday calls
#include <unordered_set>
#include <unordered_map>
unordered_set<T> s; an empty set of keys of type T
unordered_set<T> s{a, b, c}; a set made from a list; repeats are dropped
s.insert(x); add x; nothing happens if x is already there
s.erase(x); remove x, if it is there
s.count(x) 1 if x is there, 0 if not
s.find(x) an iterator to x, or s.end() if x is not there
s.size() s.empty() how many keys, and whether there are none
for (T x : s) every key once, in an order you cannot count on
unordered_map<K, V> m; keys of type K, each with a value of type V
m[k] = v; store v under k; k is added if it is missing
m[k] the value under k; a missing k is added first
m.find(k) an iterator to the pair (k, value), or m.end()
for (auto& [k, v] : m) every key with its value, in no fixed order
unordered_set<T>holds keys, each at most once.unordered_map<K, V>holds pairs, a key with its value, each key at most once. Each has its own header.insert,erase,countandfindtake a key, as on a set. You never say where a key goes: its hash decides.findreturns an iterator, the marker for one place that Module 2 used. On a map it points at a pair:it->firstis the key andit->secondthe value.m[k]always hands back a value, so a missingkis first added with 0, or an empty value for astring. Lesson 02 shows how that grows a map by accident.
So the calls are the ones a set and a map use; only the order and the cost change.
insert, and a walk in no particular order
s.insert(x) puts x in its bucket, and a range-for walks every key once. Module 8's set walked its keys sorted. This one does not.
#include <iostream>
#include <unordered_set>
using namespace std;
int main() {
unordered_set<int> s{5, 3, 9, 1, 7};
s.insert(3);
cout << "walk:";
for (int x : s) {
cout << ' ' << x;
}
cout << " (size " << s.size() << ")\n";
return 0;
}
walk: 7 1 9 3 5 (size 5)
The second 3 changed nothing, so the size stayed 5. The walk is not sorted, and it is not the order of the list either. It is GCC 12's own order, which comes from the buckets and from when each key arrived.
That order can change while the keys stay the same. This program puts five keys in, then adds nine more and erases them again. The extra keys grow the table, and the walk afterwards differs.
#include <iostream>
#include <unordered_set>
using namespace std;
void show(const unordered_set<int>& s) {
cout << s.bucket_count() << " buckets:";
for (int x : s) {
cout << ' ' << x;
}
cout << '\n';
}
int main() {
unordered_set<int> s;
for (int x : {40, 7, 21, 3, 15}) {
s.insert(x);
}
show(s);
for (int x = 50; x < 59; x++) {
s.insert(x);
}
for (int x = 50; x < 59; x++) {
s.erase(x);
}
show(s);
return 0;
}
13 buckets: 15 3 21 7 40
29 buckets: 40 7 3 15 21
The same five keys gave two different walks: the table grew from 13 buckets to 29, and the rehash rebuilt it. Erasing the nine keys did not shrink it back. So never let a program depend on the walk order of a hash container: the next rehash, or another compiler, can change it.
Is it there? count and find
s.count(x) returns 1 if x is there and 0 if not. s.find(x) returns an iterator to x, or s.end() when x is missing.
#include <iostream>
#include <unordered_set>
using namespace std;
int main() {
unordered_set<int> s{3, 9, 14, 27, 31};
cout << s.count(14) << ' ' << s.count(5) << '\n';
auto it = s.find(27);
if (it != s.end()) {
cout << "found " << *it << '\n';
}
if (s.find(5) == s.end()) {
cout << "5 is not there\n";
}
return 0;
}
1 0
found 27
5 is not there
Compare find's answer with end() before you read it with *, because reading *s.end() is undefined behaviour. So count answers a plain yes or no, and find also gives you the place.
erase by key
s.erase(x) removes x if it is there, and does nothing if it is not.
#include <iostream>
#include <unordered_set>
using namespace std;
int main() {
unordered_set<int> s{3, 9, 14, 27, 31};
s.erase(14);
s.erase(5);
cout << "size " << s.size() << ", 14 there: " << s.count(14) << '\n';
return 0;
}
size 4, 14 there: 0
erase(14) found 14 in its bucket and unhooked it. erase(5) found nothing and did nothing, with no error. So erasing a missing key is safe.
unordered_map: a value for every key
An unordered_map stores a value beside each key, like a price beside the name of an item. The key picks the bucket, and the value travels with it. A value can be anything a key should remember: a price, a level, or how many times something happened.
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> price;
price["tea"] = 20;
price["bun"] = 15;
price["tea"] = 25;
cout << "tea " << price["tea"] << ", size " << price.size() << '\n';
auto it = price.find("bun");
if (it != price.end()) {
cout << it->first << " costs " << it->second << '\n';
}
cout << "cake " << price["cake"] << ", size " << price.size() << '\n';
for (const auto& [item, cost] : price) {
cout << item << ' ' << cost << '\n';
}
return 0;
}
tea 25, size 2
bun costs 15
cake 0, size 3
cake 0
bun 15
tea 25
price["tea"] = 25 replaced the 20, because each key appears once. find("bun") gave an iterator to the pair, the key in first and the value in second. Reading price["cake"] added cake with the value 0, so the size went from 2 to 3. The last loop names each pair [item, cost], a structured binding, and visits the pairs in GCC 12's order.
So use [] to store a value, and find to ask without adding.
What a lookup costs: O(1) on average, O(n) at worst
A lookup computes one hash and one %, then compares the key with the keys of one bucket. GCC 12 rehashes before the load factor passes 1, so a bucket holds about one key on average. So a lookup compares a key or two, however many keys the table holds: O(1) on average. An insert and an erase cost the same, and a rehash now and then moves every key once.
The worst case is every key in one bucket. Then a lookup compares with all n keys, O(n), as a plain list would. That needs keys chosen against the hash, and lesson 04 builds such keys. A set from Module 8 takes about 20 to 40 steps for a million keys, whatever they are. It never gets down to one or two.
So a hash container gives up order and a firm worst case, and in return a lookup searches one short bucket.
The smallest program: five numbers with one repeat go in, and four come out, in GCC 12's order.
#include <iostream>
#include <unordered_set>
using namespace std;
int main() {
unordered_set<int> s;
for (int x : {42, 7, 19, 7, 3}) {
s.insert(x);
}
cout << s.size() << " different values:";
for (int x : s) {
cout << ' ' << x;
}
cout << '\n';
return 0;
}
4 different values: 19 7 3 42
The 7 that came twice counts once. The walk is neither sorted nor the order the numbers arrived in. Another compiler may print a third order.
Run in CompilerZara's server holds the names already taken. Each new player asks for a name. If it is taken, the server tries the name with 1, 2, 3 and so on after it, until count says the name is free.
#include <iostream>
#include <string>
#include <unordered_set>
using namespace std;
int main() {
unordered_set<string> taken{"zara", "kenji", "bob", "zara1"};
for (string want : {"maria", "zara", "bob"}) {
string name = want;
int n = 1;
while (taken.count(name) == 1) {
name = want + to_string(n);
n++;
}
taken.insert(name);
cout << want << " gets " << name << '\n';
}
cout << taken.size() << " names taken\n";
return 0;
}
maria gets maria
zara gets zara2
bob gets bob1
7 names taken
maria was free. zara and zara1 were both taken, so that player got zara2, and bob got bob1. On average each check costs one hash and one short bucket, with seven names or with a million.
Run in CompilerKenji keeps each player's level in an unordered_map<string, int>. Each time a player finishes a level, level[who]++ adds one. Before he prints a player, find checks that the player exists, so a missing name is never added.
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> level{{"zara", 4}, {"kenji", 2}, {"amara", 7}};
for (string who : {"kenji", "zara", "kenji"}) {
level[who]++;
}
for (string who : {"bob", "kenji"}) {
auto it = level.find(who);
if (it == level.end()) {
cout << who << " has not played yet\n";
} else {
cout << who << " is on level " << it->second << '\n';
}
}
for (const auto& [name, lv] : level) {
cout << name << ": level " << lv << '\n';
}
return 0;
}
bob has not played yet
kenji is on level 4
amara: level 7
kenji: level 4
zara: level 5
Kenji finished two levels and Zara one, so 2 became 4 and 4 became 5. Bob was never added, because find only asks. The last loop printed the three players in GCC 12's order, not the order they joined.
Where this is used
- CPython's dict. Every Python dictionary is a hash table. CPython's
Objects/dictobject.ctakes the low bits of a key's hash as the first slot to try. On a collision it tries other slots of the same table, with no list per bucket. It grows the table once it is two-thirds full. - Java's
HashMap. Its documentation promises constant-timegetandputif the hash spreads the keys over the buckets, and it promises no order. Once it holds more than 0.75 entries per bucket, its default load factor, it is rehashed into about twice the buckets. - Redis. Every key of a Redis database lives in hash tables from Redis's own
src/dict.c, which keeps a list per bucket. When a table must grow, Redis moves its keys over one bucket at a time, during ordinary lookups and updates. So the work is spread over many commands.
Common mistakes
1. Forgetting the header.
#include <iostream>
using namespace std;
int main() {
unordered_set<int> s{3, 1, 2};
cout << s.size() << '\n';
return 0;
}
An error at the runner's flags: error: 'unordered_set' was not declared in this scope, then GCC 12's own fix: note: 'std::unordered_set' is defined in header '<unordered_set>'; did you forget to '#include <unordered_set>'?. A map without its header gets the same pair: error: 'unordered_map' was not declared in this scope, and a note naming <unordered_map>. You will forget it because the container usually joins a program late, far from the includes.
2. Expecting alphabetical order.
unordered_set<string> names{"maria", "bob", "zara", "amara"};
for (const string& name : names) {
cout << name << '\n';
}
No message at the runner's flags, and GCC 12 prints amara, zara, bob and maria: not alphabetical, and not the list's order either. A hash container keeps no order at all. For alphabetical order, use a set from Module 8, or copy the keys into a vector and sort it. You will expect order because Module 8's set gave it for free.
3. Asking for a key by position.
unordered_set<int> s{10, 20, 30};
cout << s[0] << '\n';
An error at the runner's flags: error: no match for 'operator[]' (operand types are 'std::unordered_set<int>' and 'int'). A hash set has no positions, so there is no first key to ask for. Walk it with a range-for, or keep a vector beside it if you need positions. You will try it because m[k] works on its sibling, the map.
Zara moderates the chat of the school coding club. She keeps a list of banned words, and some words are on it more than once. In every message, each banned word must show as ***, and every other word stays as it was. Only whole words count: if spam is banned, spammer is still a different word.
Input. A line with n, then n banned words, one per line. Then a line with m, then one line with the m words of the message, separated by single spaces.
Output. One line: the m words of the message in their order, separated by single spaces, with every banned word printed as ***.
Constraints. 1 <= n <= 30000 and 1 <= m <= 50000. Every word is 1 to 10 lowercase English letters.
Sample. Input 3, then spam, scam and fake, then 8 and free spam is spammer talk not fake news gives free *** is spammer talk not *** news. spammer starts with a banned word, but it is not one.
#include <iostream>
#include <string>
#include <unordered_set>
using namespace std;
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n = 0;
cin >> n;
unordered_set<string> banned; // every banned word
for (int i = 0; i < n; i++) {
string word;
cin >> word;
// Add word to banned.
}
int m = 0;
cin >> m;
for (int i = 0; i < m; i++) {
string word;
cin >> word;
// Print word, or *** if it is banned. Put one space between two
// words, and end the line after the last one.
}
return 0;
}
Graded as banned-words, a free problem in this module's problem set. The hidden tests include a one-word message, a message in which every word is banned, and a word listed three times. They also include words that start with a banned word, and 30000 banned words with 50000 message words.
Kenji runs the club's game night. After every game he writes one line: a player's name and the points that player won. At the end of the night, the player with the most points in total takes the trophy. If players tie, it goes to the name that comes first in alphabetical order.
Input. A line with n, then n lines, each with a name and the points of one game.
Output. One line: the winner's name and total, separated by a space.
Constraints. 1 <= n <= 100000. A name is 1 to 10 lowercase English letters, and one game gives 0 to 1000 points.
Sample. Input 6, then zara 30, bob 50, kenji 20, zara 25, bob 5 and amara 40 gives bob 55. Bob and Zara both reach 55, and bob comes first in alphabetical order.
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
unordered_map<string, int> total; // each player's points so far
for (int i = 0; i < n; i++) {
string name;
int points;
cin >> name >> points;
// Add points to name's total.
}
// Walk total and keep the winner: more points wins, and a tie goes to
// the name that comes first in alphabetical order. Print the winner's
// name and total.
return 0;
}
Not graded on its own. Add up with total[name] += points, then walk the map once. Module 9's stamp count took its ties in alphabetical order from a map's walk. This walk visits the names in no useful order, so settle a tie by comparing the names themselves.
Zara's number game starts from a whole number n. Each step replaces the number by the sum of the squares of its digits: 19 becomes 1 + 81 = 82, then 68, 100 and 1. Some numbers reach 1, and the game ends there; others never do. Zara wants to know which, and how many steps it takes.
Input. One line with n.
Output. One line: the number of steps it takes to reach 1, or the word loops if the game never reaches 1.
Constraints. 1 <= n <= 1000000000. A game that starts at 1 takes 0 steps.
Sample. Input 19 gives 4: the game goes 19, 82, 68, 100, 1. Input 4 gives loops, because eight steps bring the game back to 4.
#include <iostream>
#include <unordered_set>
using namespace std;
int main() {
int n;
cin >> n;
unordered_set<int> seen; // every number the game has reached so far
// Play the game from n: each step replaces the number by the sum of the
// squares of its digits. Print how many steps it takes to reach 1, or
// loops if the game never gets there.
return 0;
}
Not graded on its own. The idea the lesson only pointed at: a number that comes back means the game goes round forever, so remember every number in an unordered_set.
Common doubts
Why does my program print the keys in another order on another computer?
The order comes from the hash and the bucket count, and another standard library hashes differently. On GCC 12 the same program prints the same order on every run, but nothing promises that anywhere else. So treat the order as unknown.
Is an
unordered_setalways faster than aset?On average a lookup costs one hash and a comparison or two, against about 20 to 40 comparisons in a set of a million keys. But a hash container can hit its worst case, keeps no order, and cannot give you the next key. Lesson 04 measures both and says when to pick each.
Can an
unordered_sethold the same key twice?No. The second insert finds the key in its bucket and changes nothing. An
unordered_multisetkeeps repeats, and lesson 02 shows it.Do I have to choose the number of buckets?
No. An empty table starts with one bucket on GCC 12 and grows by itself as keys arrive. A count such as
s(5)is only a starting point, and lesson 02 showsreserve, which makes room for a known number of keys.
Key takeaways
- A hash function turns a key into a number, and that number modulo the bucket count picks the one bucket a lookup searches.
insert,erase,countandfindtake a key; on a map,m[k] = vstores a value, and readingm[k]adds a missing key.- When the load factor would pass 1, GCC 12 grows the table and moves every key: a rehash.
- The walk order is neither sorted nor the order of the inserts, and a rehash can change it.
- A lookup costs
O(1)on average andO(n)at worst, when all the keys share one bucket. - Go deeper: Under the Hood, chained buckets, prime counts and the anti-hash test (Pro).
Next, lesson 02 goes through every operation of the four hash containers, one by one, each with its cost.
End of lesson 1
Mark it done, and your progress moves with you.
Next: Every unordered_set and unordered_map Operation, One by One, With Its Cost