DSA DSA In Depth
0%
build build-labrobin-hoodhash-map

Build Lab: Open-Addressed Robin Hood Hash Table with Tombstone-Free Backward Shift Deletion

7 min read Module 2.1: Hash Tables, Modern Probing & Probabilistic Sketches Updated Oct 23, 2026
📄 Cheat Sheet
Hash Table Open Addressing & Linear Probing Invariants
WilliamFiset Hash Table Open Addressing & Linear Probing Invariants Watch on YouTube ↗

Demonstrates how probe distance tracking and dynamic table resizing combine to provide cache-resident O(1) dictionary operations.

0:00 / --:--

Architecture & Invariant Specifications

Our implementation guarantees four structural invariants across every insert, find, erase, and rehash operation:

  1. Power-of-Two Capacity ($m = 2^r$) with SplitMix64 Finalizer: Every key is mixed through a 64-bit SplitMix64 avalanche permutation so both high and low bits have full entropy, and bucket indexing uses a single bitwise AND idx = hash & (m - 1).
  2. 1-Based Distance-from-Initial-Bucket (dib = psl + 1): Instead of storing both a bool occupied flag and a uint32_t psl counter, we store a single integer dib (Distance from Initial Bucket): dib == 0 denotes an EMPTY slot, while dib >= 1 denotes an occupied slot with $\text{PSL} = \text{dib} - 1$.
  3. Robin Hood Swap Invariant (curr_dib > slot.dib): During insert, if an occupied slot has the same key, we update its value in-place; otherwise if curr_dib > slot.dib, the incoming entry steals the slot via std::swap and continues probing with the displaced entry.
  4. Backward-Shift Deletion (erase): When deleting at idx, we inspect next = (idx + 1) & mask. While table[next].dib > 1 (meaning the occupant at next is occupied AND displaced from its home bucket, i.e., $\text{PSL} > 0$), we shift table[next] backward to table[idx] with dib - 1 and advance idx = next. Finally, we clear table[idx].dib = 0.
Engine Method Amortized / Expected Time Worst-Case Cluster Scan Auxiliary Memory Tombstone Overhead
insert(key, val) O(1) O(log log N) (Random) O(1) ($O(N)$ on resize) $0$ Tombstones
find(key) (Hit & Miss) O(1) Early-exit when dib > slot.dib O(1) $0$ Tombstones
erase(key) (Backward Shift) O(1) Shifts until dib <= 1 O(1) $0$ Tombstones (Self-healing)
maxPsl() / Telemetry O(m) Single linear pass O(1) Tracks variance compression

Formal Proof: Correctness of Backward-Shift Deletion Without Tombstones

Suppose slot $i$ is deleted. Let $C = [i+1, i+2, \dots, i+L]$ (modulo $m$) be the maximal contiguous run of slots immediately following $i$ whose occupants satisfy $\text{dib} > 1$ (equivalently $\text{PSL} \ge 1$). For every key $k \in C$, its home bucket $h(k)$ lies at or before $i$ along the cluster, so $k$ originally probed across slot $i$. Shifting every key in $C$ backward by $1$ slot and decrementing its dib by $1$ moves each key $1$ step closer to its home bucket $h(k)$, preserving the Robin Hood ordering invariant $\text{dib}[j+1] \le \text{dib}[j] + 1$ and leaving slot $i + L$ safely EMPTY (dib = 0) because slot $i + L + 1$ either is EMPTY or holds a key at its exact home bucket (dib == 1).

Visualizing End-to-End Insert, Early-Exit Find & Backward-Shift Erase

Before erase(B at [2]): DIB = PSL + 1 (DIB=0 means EMPTY, DIB=1 means Home Bucket) [1] Key A (h=1)DIB=1 (PSL=0) [2] Key B (h=1)DIB=2 (Erase!) [3] Key D (h=1)DIB=3 (PSL=2) [4] Key C (h=2)DIB=3 (PSL=2) [5] Key E (h=5)DIB=1 (Home!) After erase(B): [3]→[2] (DIB 3→2), [4]→[3] (DIB 3→2), STOP at [5] (DIB==1), clear [4]! [1] Key A (h=1)DIB=1 (PSL=0) [2] Key D (h=1)DIB=2 (PSL=1) [3] Key C (h=2)DIB=2 (PSL=1) [4] EMPTYDIB=0 [5] Key E (h=5)DIB=1 (Unmoved)
flowchart TD Find["find(key): idx = h(key) & mask, dib = 1"] --> Check{"table[idx].dib < dib ?"} Check -- "Yes (0 or Richer Occupant)" --> Miss["Early-Exit: Return std::nullopt"] Check -- "No" --> Eq{"table[idx].hash == h AND table[idx].key == key ?"} Eq -- "Yes" --> Hit["Return table[idx].val"] Eq -- "No" --> Step["idx = (idx + 1) & mask ++dib"] --> Check
Open Addressing: Robin Hood PSL & SwissTable 16-Way SIMD Probe
Expected O(1) · SIMD H2

Compare Robin Hood Hashing (stealing slots from rich keys with lower Probe Sequence Length PSL to crush variance) and Google SwissTable / Rust hashbrown 16-byte H2 metadata SIMD bitmask probing.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Robin Hood Invariant: ∀ slot i, PSL variance minimized; placed A at [1] with PSL=0
🧭 Live Hash Ring & Bucket Probe Telemetry — Open Addressing: Robin Hood PSL & SwissTable 16-Way SIMD Probe Probe Collision/Swap Hit/Placed
· A · · · · · · B[0] · B[1] A (h=1,PSL=0) B[2] · B[3] · B[4] · B[5] · B[6] · B[7] ·
[0]·
[1]Ah=1,PSL=0
[2]·
[3]·
[4]·
[5]·
[6]·
[7]·
Inserting
A (home=1)
Slot Index
[1]
Placed PSL
0
Max PSL in Table
0
[Step 1] Slot [1] was empty -> placed key A (home=1, PSL=0).

Hardware Reality: Cached Hash Comparison Before Full Key Equality

When keys are strings (std::string) or composite structs, calling slot.key == key dereferences a heap pointer and runs memcmp. By caching the 32-bit or 64-bit hash inside the slot (or comparing slot.dib == dib first, since two keys at the same slot index idx have the exact same home bucket $(idx - (\text{dib}-1)) \bmod m$ if and only if their dib values are equal!), we reject $> 99\%$ of non-matching slots in registers without ever touching the string heap buffer.

Complete Reference Implementation: RobinHoodMap<K, V> in C++20

// C++20: Complete Open-Addressed Robin Hood Hash Table with Tombstone-Free Backward Shift
#include <vector>
#include <cstdint>
#include <optional>
#include <utility>
#include <algorithm>

template <typename K, typename V>
class RobinHoodMap {
    struct Slot {
        K key{};
        V val{};
        uint32_t dib = 0; // 0 = EMPTY, >= 1 means PSL = dib - 1
    };

    std::vector<Slot> table_;
    size_t size_ = 0;

    static uint64_t hashKey(const K& k) noexcept {
        uint64_t x = static_cast<uint64_t>(std::hash<K>{}(k));
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return x ^ (x >> 31);
    }

    void rehash(size_t newCap) {
        std::vector<Slot> old = std::move(table_);
        table_.assign(newCap, Slot{});
        size_ = 0;
        for (auto& s : old) {
            if (s.dib > 0) insert(std::move(s.key), std::move(s.val));
        }
    }

public:
    explicit RobinHoodMap(size_t initialPow2Cap = 16) : table_(initialPow2Cap) {}

    size_t size() const noexcept { return size_; }

    bool insert(K key, V val) {
        // Resize at 85% load factor (17/20)
        if ((size_ + 1) * 20 >= table_.size() * 17) {
            rehash(table_.size() * 2);
        }
        const size_t mask = table_.size() - 1;
        size_t idx = hashKey(key) & mask;
        uint32_t curDib = 1;

        while (true) {
            Slot& s = table_[idx];
            if (s.dib == 0) {
                s.key = std::move(key);
                s.val = std::move(val);
                s.dib = curDib;
                ++size_;
                return true;
            }
            if (s.dib == curDib && s.key == key) {
                s.val = std::move(val); // Update existing key in-place
                return false;
            }
            // Robin Hood Rule: steal slot if incoming key has larger DIB than occupant
            if (curDib > s.dib) {
                std::swap(key, s.key);
                std::swap(val, s.val);
                std::swap(curDib, s.dib);
            }
            idx = (idx + 1) & mask;
            ++curDib;
        }
    }

    std::optional<V> find(const K& key) const {
        const size_t mask = table_.size() - 1;
        size_t idx = hashKey(key) & mask;
        uint32_t curDib = 1;

        while (true) {
            const Slot& s = table_[idx];
            // Early-exit invariant: if slot is empty OR occupant is richer, key cannot exist
            if (s.dib < curDib) return std::nullopt;
            if (s.dib == curDib && s.key == key) return s.val;
            idx = (idx + 1) & mask;
            ++curDib;
        }
    }

    bool erase(const K& key) {
        const size_t mask = table_.size() - 1;
        size_t idx = hashKey(key) & mask;
        uint32_t curDib = 1;

        while (true) {
            Slot& s = table_[idx];
            if (s.dib < curDib) return false; // Key not found
            if (s.dib == curDib && s.key == key) break; // Located target at idx
            idx = (idx + 1) & mask;
            ++curDib;
        }

        // Tombstone-free backward shift deletion
        size_t next = (idx + 1) & mask;
        while (table_[next].dib > 1) {
            table_[idx] = std::move(table_[next]);
            --table_[idx].dib;
            idx = next;
            next = (next + 1) & mask;
        }
        table_[idx] = Slot{};
        --size_;
        return true;
    }

    uint32_t maxPsl() const noexcept {
        uint32_t maxD = 1;
        for (const auto& s : table_) maxD = std::max(maxD, s.dib);
        return maxD - 1;
    }
};

Failure Modes & Adversarial Pitfalls

  • Checking s.key == key After Swapping Before Checking Duplicate Key: Notice that when s.dib == curDib, both keys share the exact same home bucket (idx - (curDib - 1)). If s.dib < curDib, the incoming key could never be a duplicate of s.key (nor of any key later in the cluster, since later keys have strictly larger home buckets). Thus checking if (s.dib == curDib && s.key == key) before the if (curDib > s.dib) swap guarantees no duplicate key is ever inserted!
  • Using Unmixed Identity Hashes with Power-of-Two Masking: Always pass std::hash<K> through a 64-bit finalizer (SplitMix64) so lower bits used by & (m - 1) reflect all 64 bits of the key.
Check yourself Question 1 of 4
Concept Check Swapping All State During a Robin Hood Eviction recall

In your `RobinHoodMap::insert` loop, the incoming candidate has `dist = 3` and encounters an occupied slot at `idx` whose resident element has `slots_[idx].psl = 1`.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How does this representation impact struct padding, cache-line density, and probing loops for `RobinHoodMap`?

Key Takeaways

Encoding DIB = PSL + 1 (with DIB = 0 for empty slots) unifies occupancy and probe distance in a single integer field.
Two entries at index idx share the same home bucket if and only if their DIB values are equal, enabling a zero-cost integer pre-check (s.dib == curDib) before full key comparison.
Early-exit lookup stops immediately whenever s.dib < curDib, bounding unsuccessful search probes tightly around the mean.
Backward-shift deletion shifts subsequent entries with DIB > 1 left by one slot and decrements their DIB, maintaining 0 tombstones across infinite insert/delete churn.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue