Lesson 5 of 12
std::map finds a key in O(log n) by comparing its way down a tree. std::unordered_map computes
where the key should be and looks there — O(1) on average.
The names are misleading. std::unordered_map is a hash table; the "unordered" part is what you
give up.
Measured
#include <chrono>
#include <iostream>
#include <map>
#include <random>
#include <string>
#include <unordered_map>
#include <vector>
template <typename F>
long long time_us(F f) {
auto t0 = std::chrono::steady_clock::now();
f();
auto t1 = std::chrono::steady_clock::now();
return std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
}
int main() {
const int N = 200000;
std::vector<int> keys(N);
std::mt19937 gen(42);
for (int i = 0; i < N; ++i) keys[i] = static_cast<int>(gen());
std::map<int, int> ordered;
std::unordered_map<int, int> hashed;
std::cout << "insert " << N << ":\n";
std::cout << " map: " << time_us([&]{ for (int k : keys) ordered[k] = k; }) << " us\n";
std::cout << " unordered_map: " << time_us([&]{ for (int k : keys) hashed[k] = k; }) << " us\n";
long long found = 0;
std::cout << "lookup " << N << ":\n";
std::cout << " map: " << time_us([&]{ for (int k : keys) found += ordered.count(k); }) << " us\n";
std::cout << " unordered_map: " << time_us([&]{ for (int k : keys) found += hashed.count(k); }) << " us\n";
std::cout << " (found " << found << ")\n";
std::cout << "\nbuckets: " << hashed.bucket_count()
<< ", load factor " << hashed.load_factor()
<< ", max load factor " << hashed.max_load_factor() << "\n";
std::cout << "\nstd::hash samples:\n";
std::hash<std::string> hs;
std::cout << " hash(\"a\") = " << hs("a") << "\n";
std::cout << " hash(\"b\") = " << hs("b") << "\n";
std::hash<int> hi;
std::cout << " hash(42) = " << hi(42) << "\n";
return 0;
}
A representative run, compiled -O2. Timings vary; the ratios are the point:
insert 200000:
map: 74456 us
unordered_map: 40610 us
lookup 200000:
map: 71748 us
unordered_map: 13769 us
(found 400000)
buckets: 351061, load factor 0.569684, max load factor 1
std::hash samples:
hash("a") = 4993892634952068459
hash("b") = 10838281452030117757
hash(42) = 42
Lookup is over 5× faster. Insert is under 2× faster, because the hash table pays for rehashing as it grows. Those ratios are typical: hashing wins clearly on lookup, less so on construction.
The last line deserves a pause. std::hash<int>(42) returns 42. libstdc++'s hash for integers
is the identity function — it does nothing at all. That is not a bug; the container's modulo does
the scattering. But it has a consequence, below.
How a hash table works
- Compute
hash(key)— astd::size_t. - Reduce it to a bucket index, roughly
hash % bucket_count. - Look in that bucket. Usually it holds one element; compare with
==to confirm.
Two keys can hash to the same bucket — a collision. libstdc++ chains them in a linked list, so a bucket with many entries degrades to a linear scan.
Load factor is size / bucket_count. When it exceeds max_load_factor (1.0 by default), the
table rehashes: allocates more buckets and redistributes everything. That is O(n), and it
invalidates every iterator.
The run above ended with 351,061 buckets for 200,000 elements — a load factor of 0.57. The table
grew several times to get there, and reserve would have avoided all of it:
// hashed.reserve(200000); // allocate enough buckets up front
// hashed.max_load_factor(0.7); // trade memory for fewer collisions
What you give up
| | map | unordered_map |
|---|---|---|
| Lookup | O(log n) | O(1) average, O(n) worst |
| Iteration order | sorted by key | unspecified |
| lower_bound / range queries | yes | no |
| Iterators after insert | stable | invalidated on rehash |
| Key requires | < | == and std::hash |
| Memory | lower | higher (spare buckets) |
Three of those rows decide real cases.
No ordering, at all. Iteration order is unspecified and can differ between runs, compilers, and library versions. Code that depends on it is broken code that happens to work today. If you print a hash map's contents in a test, sort them first.
No range queries. lower_bound cannot exist without an order. If you need "all keys between X
and Y", a hash table cannot help you at any price.
O(n) worst case. If every key lands in one bucket, lookup is a linear scan. With adversarial
input this is a denial-of-service vector — and std::hash<int> being the identity function makes it
easy: an attacker who can choose integer keys can pick multiples of your bucket count. Web servers
and parsers that hash untrusted input use a randomly seeded hash for this reason.
Hashing your own type
Two things are needed: operator== and a hash function.
#include <functional>
#include <iostream>
#include <string>
#include <unordered_set>
struct Point {
int x;
int y;
bool operator==(const Point& other) const = default; // hashing needs ==
};
// A hash function object. Combining is the part people get wrong: XOR alone
// makes hash({1,2}) equal hash({2,1}).
struct PointHash {
std::size_t operator()(const Point& p) const {
std::size_t h1 = std::hash<int>{}(p.x);
std::size_t h2 = std::hash<int>{}(p.y);
return h1 ^ (h2 << 1);
}
};
struct BadHash {
std::size_t operator()(const Point& p) const {
return std::hash<int>{}(p.x) ^ std::hash<int>{}(p.y); // symmetric: collides
}
};
int main() {
std::unordered_set<Point, PointHash> points = {{1, 2}, {3, 4}, {1, 2}};
std::cout << "size " << points.size() << "\n";
std::cout << "contains (3,4): " << std::boolalpha << points.contains({3, 4}) << "\n";
PointHash good;
BadHash bad;
std::cout << "good hash of (1,2) == (2,1): "
<< (good(Point{1,2}) == good(Point{2,1})) << "\n";
std::cout << "bad hash of (1,2) == (2,1): "
<< (bad(Point{1,2}) == bad(Point{2,1})) << "\n";
return 0;
}
size 2
contains (3,4): true
good hash of (1,2) == (2,1): false
bad hash of (1,2) == (2,1): true
BadHash XORs the two field hashes directly, so (1,2) and (2,1) collide — XOR is commutative.
On a grid of coordinates that is roughly half your points colliding in pairs, for free. The << 1
in PointHash breaks the symmetry.
Even that is only adequate. The combiner worth copying is Boost's, which mixes far better:
// template <typename T>
// void hash_combine(std::size_t& seed, const T& value) {
// seed ^= std::hash<T>{}(value) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
// }
//
// std::size_t operator()(const Point& p) const {
// std::size_t seed = 0;
// hash_combine(seed, p.x);
// hash_combine(seed, p.y);
// return seed;
// }
0x9e3779b9 is derived from the golden ratio; the shifts spread bits between rounds. You are not
expected to derive this — you are expected to use it rather than inventing your own.
Note that operator== and the hash must agree: if a == b, then hash(a) must equal
hash(b). Breaking that means the table can hold two elements it considers equal, and lookups find
the wrong one or neither. Nothing checks it.
The alternative to a separate hash type is specialising std::hash (lesson 12.4), which lets
std::unordered_set<Point> work without naming the hasher:
// namespace std {
// template <>
// struct hash<Point> {
// std::size_t operator()(const Point& p) const { ... }
// };
// }
Specialising a standard template for your own type is explicitly allowed. Doing it for a type you do not own is not.
Choosing
Default to std::unordered_map when you only ever look keys up by exact value. It is faster,
and lookup is the common case.
Use std::map when you need any of: sorted iteration, range queries, stable iterators, or a key
type that has < but no sensible hash.
Use a sorted std::vector when the data is built once and then only read. std::lower_bound on
a sorted vector beats both — contiguous memory, no per-node allocation, and O(log n) lookup. This is
a genuinely underused option, and lesson 13.12 returns to it.
Try it
Build a phone book keyed by name with both std::map and std::unordered_map. Time 100,000
lookups in each. Then add a requirement that changes the answer: print all names starting with
"M". Say which container can do it and why.
Checkpoint
unordered_map/unordered_setare hash tables: O(1) average lookup, O(n) worst case.- Measured here: lookup 5× faster than
std::map, insert under 2× faster. - Load factor is
size / bucket_count; exceedingmax_load_factortriggers a rehash. - A rehash is O(n) and invalidates all iterators.
reserveavoids repeated rehashing. - Iteration order is unspecified — never depend on it, and sort before printing in tests.
- No
lower_bound, no range queries, ever — those need an order. - A key type needs both
operator==and a hash, and the two must agree. std::hash<int>is the identity function in libstdc++ — relevant for adversarial input.- Combining hashes with plain XOR makes
(1,2)and(2,1)collide; use a proper combiner. - Default to
unordered_mapfor exact lookup,mapfor order, sortedvectorfor read-only data.
Questions about this lesson
Log in to ask a question.