C++ / STANDARD CONTAINERS
unordered_map, hashing, and bucket behaviour
Reason about unordered_map as buckets plus a hash: size it with reserve, write correct custom hashes, and predict exactly what a rehash invalidates.
What you will learn
- Predict lookup cost from load_factor() and the busiest bucket's chain length
- Call reserve(n) to size the bucket array once instead of rehashing mid-loop
- Write a hash that mixes every field operator== compares, or lookups will miss
- Keep pointers and references across a rehash; assume iterators are dead
Understanding unordered_map, hashing, and bucket behaviour
An unordered_map is an array of buckets plus a hash function. To find a key it hashes the key once, reduces that hash to a bucket index, then walks the short chain of nodes in that one bucket comparing keys with KeyEqual. That is what average O(1) means here: the work does not scale with how many elements the map holds, only with how long the chain in the single bucket it visits is. The price is that ordering is gone entirely, so there is no sorted traversal and no way to ask for a range of keys.
load_factor() is size() divided by bucket_count(), and max_load_factor() (1.0 by default) is the ceiling the container promises to stay under. When an insert would push the load factor past that ceiling, the container allocates a bigger bucket array and re-links every node into it: an O(n) rehash that also changes iteration order and which bucket each key lives in. Because elements live in individually allocated nodes, a rehash rewires links but never copies or moves an element, which is exactly why pointers and references to elements stay valid while all iterators are invalidated. reserve(n) does that sizing up front so a fill loop rehashes at most once.
For your own key types you supply the hash, and the hard rule is that it must agree with equality: if two keys compare equal they must hash equal, otherwise they get filed in different buckets and find() reports absent for an element that is really stored. Beyond correctness, spread is what buys the constant time, because a hash that maps many distinct keys onto one value produces a single long chain and no amount of rehashing can separate keys that share a hash value. So when lookups feel slow, inspect load_factor() and bucket_size(bucket(k)) for the keys you actually query before changing anything else.
<iostream>
<string>
<unordered_map>
int main() {
std::unordered_map<std::string, int> ages;
ages.reserve(2); // room for two elements at load factor 1.0
ages["ada"] = 36;
ages["grace"] = 45;
auto buckets_before = ages.bucket_count();
int& ada = ages.at("ada"); // reference into the node holding ada's value
for (int i = 0; i < 50; ++i) {
ages["filler" + std::to_string(i)] = i; // forces at least one rehash
}
std::cout << std::boolalpha;
std::cout << "size: " << ages.size() << '\n';
std::cout << "bucket_count grew: "
<< (ages.bucket_count() > buckets_before) << '\n';
std::cout << "load_factor within limit: "
<< (ages.load_factor() <= ages.max_load_factor()) << '\n';
std::cout << "ada through the old reference: " << ada << '\n';
ada = 37;
std::cout << "ada re-read from the map: " << ages.at("ada") << '\n';
}
An unordered_map is a bucket array addressed by a hash value, so both its speed and its invalidation rules follow from how keys spread over buckets and when that array is rebuilt.
Worked examples
A hash that collapses every key into one bucket
Shows that rehashing to more buckets cannot rescue a hash function with no spread.
<cstddef>
<iostream>
<string>
<unordered_map>
struct AlwaysZero {
std::size_t operator()(const std::string&) const noexcept { return 0; }
};
int main() {
std::unordered_map<std::string, int, AlwaysZero> m;
m["red"] = 1;
m["green"] = 2;
m["blue"] = 3;
m["cyan"] = 4;
const std::size_t home = m.bucket("red");
std::size_t used = 0;
for (std::size_t b = 0; b < m.bucket_count(); ++b) {
if (m.bucket_size(b) != 0) ++used;
}
std::cout << std::boolalpha;
std::cout << "size: " << m.size() << '\n';
std::cout << "elements in red's bucket: " << m.bucket_size(home) << '\n';
std::cout << "blue shares that bucket: " << (m.bucket("blue") == home) << '\n';
std::cout << "non-empty buckets: " << used << '\n';
std::cout << "grew the bucket array anyway: " << (m.bucket_count() > 1) << '\n';
}
Example explained
Line 1AlwaysZero meets the Hash requirements, so this compiles, but it removes the only mechanism the container has for separating keys.
Line 2bucket_size(home) is 4, which means find("cyan") compares keys one at a time down a four-node chain instead of stopping after one.
Line 3bucket_count() is still above 1 because the load factor counts elements over the whole array, so inserts kept triggering growth that changed nothing.
Line 4The numeric value of home is deliberately not printed: the hash-to-index mapping is implementation-defined, only "equal keys share a bucket" is guaranteed.
Hashing a user-defined key
Specializes std::hash for a small struct so it can be used directly as an unordered_map key.
<cstddef>
<functional>
<iostream>
<string>
<unordered_map>
struct Point {
int x;
int y;
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
};
namespace std {
template <>
struct hash<Point> {
std::size_t operator()(const Point& p) const noexcept {
std::size_t h = std::hash<int>{}(p.x);
h ^= std::hash<int>{}(p.y) + 0x9e3779b9u + (h << 6) + (h >> 2);
return h;
}
};
}
int main() {
std::unordered_map<Point, std::string> grid;
grid[{1, 2}] = "start";
grid[{3, 4}] = "goal";
grid[{1, 2}] = "start again";
std::cout << "size: " << grid.size() << '\n';
std::cout << "at {1,2}: " << grid.at({1, 2}) << '\n';
std::cout << "count {1,2}: " << grid.count({1, 2}) << '\n';
std::cout << "count {9,9}: " << grid.count({9, 9}) << '\n';
}
Example explained
Line 1Specializing std::hash<Point> is what lets unordered_map<Point, std::string> compile without naming a third template argument.
Line 2The mixing step matters: a plain x ^ y would hash (1,2) and (2,1) to the same value, since 1 ^ 2 and 2 ^ 1 are both 3.
Line 3operator== is equally required, because once the hash selects a bucket the container uses std::equal_to<Point> to pick the right node inside it.
Line 4The second write to {1,2} lands on the existing node, so size stays 2 and only the mapped string is replaced.
Important notes
Bucket indices and iteration order are implementation-defined; the standard only guarantees that equal keys land in the same bucket and that load_factor() stays at or below max_load_factor().
On libstdc++ and libc++, std::hash for integers is the identity function, so integer keys with a regular stride (all multiples of 64, say) can pile into few buckets unless you mix them yourself.
Common mistakes
Treating iteration as insertion-ordered or sorted: the order is unspecified and shifts the moment an insert rehashes, so output-comparison tests pass locally and fail on another compiler or after one extra element.
Pairing a case-insensitive KeyEqual with a case-sensitive hash: "Key" and "key" compare equal but hash to different buckets, so both can end up stored and find() misses one of them.
Keeping an iterator across an insertion: if that insert crossed max_load_factor, the rehash invalidated it, and dereferencing it is undefined behaviour that only appears at certain sizes.
Try it yourself
Change, predict, then run
Fill an unordered_map<int, int> with keys 0 to 999 mapped to key * key, then loop over every bucket index and print how many buckets hold 0, 1, and 2 or more elements. Run it again with reserve(2000) called before the loop and compare the spread.
Open the C++ workspaceCheck your understanding
Your key type's hash function returns the same value for every key, and you insert 10000 elements. What happens?
- The container still rehashes to more buckets, yet every element chains into one bucket and lookup degrades to a linear scan
- Insertion throws once max_load_factor() would be exceeded, because no bucket can hold that many elements
- The container notices the collisions and falls back to comparing keys with <, giving O(log n) lookups
- Only one element is ever stored, because keys with equal hashes are treated as the same key
Show answer
The load factor is size() over bucket_count() across the whole array, so inserts keep triggering growth, but the bucket index is derived from the same hash value each time and every node ends up on one chain that find() must walk. Option 4 is tempting because hashing is how keys get grouped, but membership is decided by KeyEqual: an equal hash only means "same bucket", and unequal keys are all kept.