CodeRigor

FREE LESSON

Hash tables: open addressing

Build a flat hash map: mix the hash, probe linearly in a power-of-two array, keep the load factor bounded, and delete without breaking probe chains.

std::unordered_map is a chained hash table: an array of buckets, each pointing to a linked list of heap-allocated nodes. That design gives stable references, but it costs an allocation per insert and a pointer chase per lookup. Most high-performance hash tables (Abseil's flat_hash_map, Boost's unordered_flat_map, Rust's HashMap) use open addressing instead: the entries live in one flat array, and a collision is resolved by trying other slots of the same array.

This lesson builds one, and the interesting parts aren't the happy path. They are the three things that quietly turn O(1) into O(n): a hash whose low bits don't vary, a load factor that's allowed to creep towards 1, and deletion.

1. Slots, home slots, and the mask

Keep the slot count a power of two, cap = 2^b. Then a hash value picks a slot with one AND: hash & (cap - 1) keeps the low b bits. That slot is the key's home.

The home slot is the hash's low bits
With cap = 8 the mask keeps 3 bits. 13 and 21 differ only above bit 2, so they share home slot 5.
The home slot is the hash's low bits
With cap = 8 the mask keeps 3 bits. 13 and 21 differ only above bit 2, so they share home slot 5.
Inserting 13, 21, 6 with linear probing (cap 8)
21's home slot 5 is taken, so it probes on to 6; then 6 finds its home taken by 21 and lands in 7.
Inserting 13, 21, 6 with linear probing (cap 8)
21's home slot 5 is taken, so it probes on to 6; then 6 finds its home taken by 21 and lands in 7.

std::hash<int> in libstdc++ is the identity function, so the home slot of an integer is just its low bits. That's fine for keys 0, 1, 2, … and terrible for keys that share low bits — addresses, IDs that are multiples of a page size, timestamps rounded to milliseconds:

C++
#include <cstdint>
#include <print>
#include <set>

// splitmix64's finalizer: every input bit affects every output bit.
std::uint64_t mix(std::uint64_t x) {
    x ^= x >> 30;
    x *= 0xbf58476d1ce4e5b9ULL;
    x ^= x >> 27;
    x *= 0x94d049bb133111ebULL;
    x ^= x >> 31;
    return x;
}

int main() {
    const std::uint64_t mask = 1024 - 1;      // 1024 slots
    std::set<std::uint64_t> raw, mixed;
    for (std::uint64_t i = 0; i < 1000; ++i) {
        std::uint64_t key = i * 4096;         // e.g. page-aligned addresses
        raw.insert(key & mask);
        mixed.insert(mix(key) & mask);
    }
    std::println("distinct home slots, raw hash:   {}", raw.size());
    std::println("distinct home slots, mixed hash: {}", mixed.size());
}
Page-aligned keys in a 16-slot table: how many land in each slot
Keys 0, 4096, …, 28672 all end in twelve zero bits, so key & 15 is 0 for all eight. After mix they spread out (two pairs still share a slot — ordinary chance).
Page-aligned keys in a 16-slot table: how many land in each slot
Keys 0, 4096, …, 28672 all end in twelve zero bits, so key & 15 is 0 for all eight. After mix they spread out (two pairs still share a slot — ordinary chance).

With the raw hash, a thousand keys share one home slot. A table can't fix a hash that throws information away, but it can refuse to trust the low bits: mix the hash, then mask. A finalizer like the one above costs a few cycles and makes the table robust to lazy hash functions.

2. Linear probing

On a collision, try the next slot, then the next (wrapping around), until you find the key or an empty slot. That's linear probing. Its great virtue is locality: a probe sequence walks consecutive memory, usually within a cache line or two.

Worked example — a minimal set of ints:

C++
#include <cstddef>
#include <optional>
#include <print>
#include <vector>

class IntSet {
public:
    explicit IntSet(std::size_t cap) : slots_(cap) {}   // cap: a power of two

    bool insert(int key) {
        std::size_t i = home(key);
        while (slots_[i]) {                     // walk the cluster
            if (*slots_[i] == key) return false;
            i = (i + 1) & mask();               // wrap around
        }
        slots_[i] = key;                        // first empty slot
        return true;
    }

    bool contains(int key) const {
        for (std::size_t i = home(key); slots_[i]; i = (i + 1) & mask())
            if (*slots_[i] == key) return true;
        return false;                           // empty slot: not here
    }

private:
    std::vector<std::optional<int>> slots_;
    std::size_t mask() const { return slots_.size() - 1; }
    std::size_t home(int key) const { return static_cast<std::size_t>(key) & mask(); }
};

int main() {
    IntSet s(8);
    for (int k : {13, 21, 6, 29}) s.insert(k);   // 13, 21, 29 all have home 5
    std::println("{} {} {}", s.contains(21), s.contains(29), s.contains(37));
}

contains stops at the first empty slot. That's only correct because of an invariant worth stating precisely:

Every entry is reachable from its home slot without crossing an empty slot.

Insert maintains it trivially (it fills the first empty slot on the path). Deletion is where it gets broken — §4.

(This toy set has no growth; it would loop forever once full. A real table never lets that happen: §3.)

3. Load factor and growth

The load factor α = size / capacity. With linear probing and a good hash, the expected number of probes (Knuth) is about:

α successful lookup ½(1 + 1/(1−α)) miss ½(1 + 1/(1−α)²)
0.50 1.5 2.5
0.75 2.5 8.5
0.875 4.5 32.5
0.95 10.5 200.5
Expected probes with linear probing
Knuth's estimates with a good hash. Successful lookups stay cheap; misses (and inserts) blow up as α → 1 — which is why tables grow at ¾ or ⅞.
Expected probes with linear probing
Knuth's estimates with a good hash. Successful lookups stay cheap; misses (and inserts) blow up as α → 1 — which is why tables grow at ¾ or ⅞.

So keep α bounded — ¾ is a common choice for linear probing; SIMD-probed tables go to ⅞. When an insert would cross the limit, double the capacity and re-insert every entry at its new home (the mask changed, so homes changed). Doubling makes growth amortized O(1) per insert, for exactly the same reason as std::vector: the total rehashing work is a geometric series.

Growing by a constant (+64 slots) instead means rehashing every 48 or so inserts — O(n²) for n inserts.

4. Deletion without breaking chains

The obvious erase — find the slot, empty it — is wrong:

C++
#include <cstddef>
#include <optional>
#include <print>
#include <vector>

int main() {
    // Homes: 13 → 5, 21 → 5, 29 → 5. Linear probing put them in slots 5, 6, 7.
    std::vector<std::optional<int>> slots(8);
    slots[5] = 13;
    slots[6] = 21;
    slots[7] = 29;

    slots[6].reset();   // naive erase of 21

    // Look up 29: start at its home (5), stop at the first empty slot.
    bool found = false;
    for (std::size_t i = 5; slots[i]; i = (i + 1) & 7)
        if (*slots[i] == 29) found = true;
    std::println("29 found after erasing 21? {}", found);
}

The hole at slot 6 cut 29 off from its home. Two standard fixes:

Tombstones. Mark the slot deleted instead of empty. Lookups skip tombstones; inserts may reuse them. Simple, but tombstones occupy slots: they lengthen probe sequences until a rehash removes them, so they must count towards the load factor (or be cleaned up some other way).

Backward-shift deletion. Empty the slot, then repair the cluster: walk forward from the hole; any entry whose probe path passes through the hole moves back into it, and its old slot becomes the new hole. Stop at the first empty slot. No tombstones, ever.

Erasing 21 from the cluster 13, 21, 29 (all home 5)
Left: the naive erase cuts 29 off from its home — contains(29) stops at the empty slot. Middle and right: the two fixes.
Erasing 21 from the cluster 13, 21, 29 (all home 5)
Left: the naive erase cuts 29 off from its home — contains(29) stops at the empty slot. Middle and right: the two fixes.

When does the entry at j (home k) pass through the hole at i? When i lies cyclically in [k, j) — equivalently, when the distance from k to j is at least the distance from i to j.

Faded example — your turn

Fill in the condition, using & mask() for cyclic distances:

C++
slots_[i].reset();
for (std::size_t j = (i + 1) & mask(); slots_[j]; j = (j + 1) & mask()) {
    std::size_t k = home(slots_[j]->first);
    if (/* the hole i lies on the probe path from k to j */) {
        slots_[i] = std::move(slots_[j]);
        slots_[j].reset();
        i = j;
    }
}
Answer

((j - k) & mask()) >= ((j - i) & mask()). Unsigned subtraction wraps, and & mask() turns it into the cyclic distance. If the entry's home is at or before the hole (along the cycle), its probe path crosses the hole, so it moves back; otherwise it stays, because moving it would put it before its home.

5. Other probe sequences

Quadratic probing visits home + 1, home + 3, home + 6, … (triangular numbers). With a power-of-two capacity this still visits every slot exactly once, and it breaks up the long runs ("primary clustering") that linear probing forms — at the price of cache locality:

C++
#include <cstddef>
#include <print>
#include <vector>

int main() {
    const std::size_t cap = 16, mask = cap - 1;
    std::vector<bool> seen(cap);
    std::size_t i = 3, visited = 0;
    for (std::size_t step = 0; step < cap; ++step) {
        if (!seen[i]) ++visited;
        seen[i] = true;
        i = (i + step + 1) & mask;    // offsets 1, 2, 3, … from the previous slot
    }
    std::println("distinct slots visited: {} of {}", visited, cap);
}
Quadratic probing from home 3 in a 16-slot table
The number in each slot is when it's visited: offsets 1, 2, 3, … from the previous slot. Every slot is visited exactly once, and the steps jump out of a crowded run quickly.
Quadratic probing from home 3 in a 16-slot table
The number in each slot is when it's visited: offsets 1, 2, 3, … from the previous slot. Every slot is visited exactly once, and the steps jump out of a crowded run quickly.

Robin Hood hashing (a preview — not needed here) is linear probing where an inserting entry that is further from its home than the occupant takes the slot and continues inserting the displaced one. It equalises probe lengths, lets misses stop early, and pairs naturally with backward-shift deletion.

6. Chaining, for contrast

std::unordered_map must give stable references: a pointer to an element stays valid across rehashes. That pretty much forces one heap node per entry:

C++
#include <print>
#include <unordered_map>

int main() {
    std::unordered_map<int, int> m;
    m[1] = 10;
    int* p = &m[1];
    for (int i = 2; i < 1000; ++i) m[i] = i;   // many rehashes
    std::println("still {} via a pointer taken before {} rehashes",
                 *p, m.bucket_count() > 8 ? "several" : "no");
    std::println("max_load_factor: {}", m.max_load_factor());
}
std::unordered_map: buckets of separately allocated nodes
Each entry is its own heap node, so its address never changes on rehash — but every lookup chases at least one pointer.
std::unordered_map: buckets of separately allocated nodes
Each entry is its own heap node, so its address never changes on rehash — but every lookup chases at least one pointer.

The default max_load_factor is 1.0: chains let α exceed what open addressing could tolerate. Iterators are invalidated by a rehash, but pointers and references to elements are not.

A flat table gives that guarantee up — find's pointer is invalidated by the next insert that grows the table, or by an erase that shifts entries — in exchange for no per-entry allocation, better locality, and typically 2–4× faster lookups.

7. Adversarial inputs

Average O(1) assumes keys behave randomly with respect to the hash. An attacker who knows your hash can choose keys that all collide and turn every operation into O(n) ("hash flooding"). Defences: mix with a per-process random seed, or a keyed hash such as SipHash for untrusted input. Mixing with a fixed function, as here, defends against accidental patterns, not adversaries.

Summary

  • Flat array of slots, power-of-two capacity, home = mix(hash) & (cap - 1).
  • Linear probing: walk forward from home until the key or an empty slot.
  • Invariant: every entry reachable from its home without crossing an empty slot.
  • Keep α ≤ ¾ (or ⅞) and grow by doubling; rehash re-inserts everything.
  • Delete with backward shift (or tombstones that count towards the load).
  • Flat tables trade pointer stability for speed; std::unordered_map chains.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Probing sequences, deletion with tombstones and the uniform-hashing analysis.

The Swiss-table design from first principles: open addressing, control bytes, SIMD group probing.

Benchmarks and design of fast open-addressing tables, including Robin Hood hashing.

Linear probing worked through slot by slot; the series continues with removals.

Why you can't simply empty a slot, and how tombstones keep probe chains intact.