DSA DSA In Depth
0%
lesson robin-hood-hashingcuckoo-hashingprobing

Variance Reduction: Robin Hood Hashing (PSL) & Cuckoo Hashing

7 min read Module 2.1: Hash Tables, Modern Probing & Probabilistic Sketches Updated Oct 23, 2026
📄 Cheat Sheet
Cuckoo Hashing & Open-Addressed Displacement Probing Explained
VividCode Cuckoo Hashing & Open-Addressed Displacement Probing Explained Watch on YouTube ↗

Erik Demaine analyzes the bipartite cuckoo graph structure, proves why load factor α < 0.5 prevents bicyclic component overflow with high probability, and bounds worst-case lookups to 2 probes.

0:00 / --:--

Robin Hood Hashing: PSL Monotonicity & Tombstone-Free Deletion

For any key $k$ currently stored at index $i$ in a linear-probed table of size $m$, define its Probe Sequence Length (PSL) (also called Displacement Ideal Distance, DIB) as its modular distance from its home bucket:

$$\text{PSL}(k, i) = (i - h(k)) \bmod m$$

During insert(k), we track curr_psl = 0 starting at idx = h(k). At each occupied slot idx, we compare curr_psl against occupant_psl. Whenever curr_psl > occupant_psl, we swap the incoming key with the occupant and continue probing with the displaced occupant. This maintains two powerful invariants:

  • Early-Exit Lookup Bound: Because keys along any contiguous cluster are ordered monotonically by their home buckets, if a lookup for target reaches a slot where search_psl > occupant_psl, target cannot exist anywhere further in the table—if it did, it would have stolen occupant's slot during insertion! Thus unsuccessful searches terminate early without scanning to an EMPTY slot.
  • Tombstone-Free Backward-Shift Deletion: Instead of leaving a TOMBSTONE when deleting slot idx, Robin Hood inspects next = (idx + 1) % m. While table[next] is occupied and has PSL > 0, it shifts table[next] backward into table[idx] (decrementing its PSL by 1) and advances idx = next. Once it encounters an EMPTY slot or a key sitting at its home bucket (PSL == 0), it marks table[idx] = EMPTY, keeping the table 100% free of tombstones forever.

Cuckoo Hashing: Bipartite Eviction Graphs

In Cuckoo Hashing, two tables $T_1, T_2$ of size $m$ (or one table of size $2m$) use independent hash functions $h_1, h_2$. Every key $x$ resides exclusively at $T_1[h_1(x)]$ or $T_2[h_2(x)]$. Lookups and deletions check only those two slots in parallel—requiring at most 2 cache line reads in the worst case.

To insert $x$, if $T_1[h_1(x)]$ is occupied by $y$, we place $x$ at $T_1[h_1(x)]$ and evict $y$ to its alternate home $T_2[h_2(y)]$, chaining evictions up to $\text{MaxLoop} = lceil 6 \log_{1/(2\alpha)} n \rceil$ steps before triggering a rehash with fresh hash seeds.

Open Addressing Scheme Expected Lookup Worst-Case Lookup (w.h.p.) Probe Variance Deletion Strategy & Max Load $\alpha$
Standard Linear Probing O(1) O(log N) $\Theta((1-\alpha)^{-3})$ Tombstones ($\alpha \le 0.75$)
Robin Hood Linear Probing O(1) O(log log N) (Random) $\Theta(\ln((1-\alpha)^{-1}))$ Backward-Shift, 0 Tombstones ($\alpha \le 0.90$)
2-Way Cuckoo ($d=2, b=1$) O(1) Strict 2 Probes $0$ (1 or 2 probes) Direct clear ($\alpha < 0.50$)
Bucketized Cuckoo ($d=2, b=4$) O(1) Strict 2 Cache Lines $0$ (2 buckets of 4) Direct clear ($\alpha \le 0.95$)

Formal Specification: Robin Hood Early-Exit & Cuckoo Graph Thresholds

Robin Hood Ordering Invariant: In any contiguous block of occupied slots, keys appear in non-decreasing order of their home bucket $h(k)$. Consequently, for two adjacent slots $i$ and $i+1 = (i+1) \bmod m$ in the same cluster, $\text{PSL}(i+1) \le \text{PSL}(i) + 1$. During find(q), if at probe step $d = (i - h(q)) \bmod m$ the slot occupant has $\text{PSL}(i) < d$, then $h(\text{occupant}) > h(q)$ along the cluster, proving $q \notin T$.

Cuckoo Graph Theorem (Pagh & Rodler): Define the Cuckoo Graph $G = (V, E)$ with $2m$ vertices (table slots) and $n$ undirected edges $e_x = (h_1(x), h_2(x))$ for each key $x$. An insertion succeeds without rehash iff every connected component of $G$ is unicyclic or a tree (contains at most one cycle, so edges can be oriented with out-degree $\le 1$). At load factor $\alpha = n / (2m) = (1 - \epsilon)/2 < 0.5$, random graph theory (Erdős–Rényi phase transition) guarantees no bicyclic component exists with probability $1 - O(1/n)$.

Visualizing Robin Hood PSL Swapping & Cuckoo Eviction Cycles

1. Robin Hood Swap: Inserting D (h=1) into [A(h=1), B(h=1), C(h=2)] Key A[1] h=1 PSL=0 Key B[2] h=1 PSL=1 D steals C![3] D(PSL=2)>C(PSL=1) Key C[4] h=2 PSL=2 2. Cuckoo Hashing Bipartite Eviction & Backward-Shift Deletion Backward-Shift Erase at [2]: Shift [3]→[2] (PSL--), [4]→[3] (PSL--) until PSL==0 or EMPTY Zero Tombstones! Cluster shrinks cleanly. Cuckoo Bipartite Lookup (Worst-Case O(1)): return T1[h1(x)] == x || T2[h2(x)] == x; 2 independent memory reads issued in parallel via MLP
flowchart LR subgraph BackwardShift["Robin Hood Tombstone-Free Erase(idx)"] A["Locate key at slot idx"] --> B["next = (idx + 1) & mask"] B --> C{"table[next] OCCUPIED AND table[next].psl > 0?"} C -- "Yes" --> D["table[idx] = table[next] table[idx].psl-- idx = next"] --> B C -- "No (EMPTY or PSL == 0)" --> E["table[idx] = EMPTY Done!"] end
Cuckoo Hashing 2-Table Kick Graph & FKS Perfect Hashing
Worst-Case O(1) Lookup · ≤ 2 Probes

Trace Pagh & Rodler Cuckoo Hashing where every key x resides exclusively at T₁[h₁(x)] or T₂[h₂(x)] (guaranteeing worst-case O(1) lookup in 2 parallel cache-line reads) and FKS two-level static perfect hashing.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Cuckoo Invariant: Every key x is at T₁[h₁(x)] OR T₂[h₂(x)]. Lookup inspects 2 independent cache lines in parallel.
Table T₁[0..3] (h₁) Table T₂[0..3] (h₂) · A (1,0) B (2,2) · · · · · Insert A(1,0) → T₁[1], Insert B(2,2) → T₁[2] (No conflict)
Lookup Locations
T₁[h₁(x)], T₂[h₂(x)]
Worst-Case Comparisons
2 (Strict O(1))
2-Table Load Threshold
α < 50% (4-way: 91%)
Step
1 / 3
[Step 1] Insert A(1,0) → T₁[1], Insert B(2,2) → T₁[2] (No conflict)

Hardware Reality: Memory-Level Parallelism (MLP) in Cuckoo Hashing vs Cache Locality

In Cuckoo Hashing, T1[h1(x)] and T2[h2(x)] have no data dependency between them! An out-of-order CPU core issues both memory loads simultaneously into its Load-Store Queue (Memory-Level Parallelism, MLP), hiding the second DRAM latency completely behind the first. Furthermore, while classic 2-way Cuckoo caps load factor at $\alpha < 0.50$, Bucketized Cuckoo Hashing (storing $b = 4$ slots per bucket so each bucket fits inside one 64-byte cache line, as used in libcuckoo) raises the phase-transition threshold from $49\%$ to **$95\%$ load factor** while still touching at most 2 cache lines per lookup!

Production C++20 Implementation: Cuckoo Hash Table with Unicyclic Eviction Loop

// C++20: 2-Table Cuckoo Hash Map with Worst-Case 2-Probe Lookup & Cycle Detection
#include <vector>
#include <cstdint>
#include <optional>
#include <utility>

template <typename V>
class CuckooHashMap {
    struct Entry {
        uint64_t key = 0;
        V val{};
        bool occupied = false;
    };

    std::vector<Entry> t1_, t2_;
    size_t count_ = 0;
    uint64_t seed1_ = 0x9e3779b97f4a7c15ULL;
    uint64_t seed2_ = 0xbf58476d1ce4e5b9ULL;
    static constexpr size_t MAX_KICKS = 64;

    size_t h1(uint64_t k) const noexcept {
        k ^= seed1_; k *= 0xff51afd7ed558ccdULL;
        return (k ^ (k >> 33)) & (t1_.size() - 1);
    }
    size_t h2(uint64_t k) const noexcept {
        k ^= seed2_; k *= 0xc4ceb9fe1a85ec53ULL;
        return (k ^ (k >> 33)) & (t2_.size() - 1);
    }

    void rehash(size_t newHalfCap) {
        auto old1 = std::move(t1_);
        auto old2 = std::move(t2_);
        t1_.assign(newHalfCap, Entry{});
        t2_.assign(newHalfCap, Entry{});
        seed1_ = seed1_ * 6364136223846793005ULL + 1442695040888963407ULL;
        seed2_ = seed2_ * 2862933555777941757ULL + 3037000493ULL;
        count_ = 0;
        for (auto& e : old1) if (e.occupied) insert(e.key, std::move(e.val));
        for (auto& e : old2) if (e.occupied) insert(e.key, std::move(e.val));
    }

public:
    explicit CuckooHashMap(size_t pow2HalfCap = 16) : t1_(pow2HalfCap), t2_(pow2HalfCap) {}

    // Strict O(1) Worst-Case Lookup: Exactly 2 independent array reads
    std::optional<V> find(uint64_t key) const noexcept {
        const Entry& e1 = t1_[h1(key)];
        const Entry& e2 = t2_[h2(key)];
        if (e1.occupied && e1.key == key) return e1.val;
        if (e2.occupied && e2.key == key) return e2.val;
        return std::nullopt;
    }

    void insert(uint64_t key, V val) {
        if (Entry& e1 = t1_[h1(key)]; e1.occupied && e1.key == key) { e1.val = std::move(val); return; }
        if (Entry& e2 = t2_[h2(key)]; e2.occupied && e2.key == key) { e2.val = std::move(val); return; }

        if ((count_ + 1) * 5 >= (t1_.size() + t2_.size()) * 2) { // Keep load factor < 0.40
            rehash(t1_.size() * 2);
        }

        Entry cur{key, std::move(val), true};
        for (size_t kick = 0; kick < MAX_KICKS; ++kick) {
            Entry& slot1 = t1_[h1(cur.key)];
            if (!slot1.occupied) { slot1 = std::move(cur); ++count_; return; }
            std::swap(cur, slot1);

            Entry& slot2 = t2_[h2(cur.key)];
            if (!slot2.occupied) { slot2 = std::move(cur); ++count_; return; }
            std::swap(cur, slot2);
        }
        // Bicyclic component detected: rehash with new seeds and re-insert displaced cur
        rehash(t1_.size() * 2);
        insert(cur.key, std::move(cur.val));
    }
};

Failure Modes & Adversarial Pitfalls

  • Shifting PSL == 0 Keys During Robin Hood Backward-Shift Deletion: If erase shifts an adjacent key whose PSL == 0 backward by 1 slot, that key moves before its home bucket $h(k)$ (giving it a wrapped PSL = m - 1) and renders it permanently unreachable by find(k)! Backward shift must stop immediately when table[next].psl == 0.
  • Single-Table Cuckoo Self-Loop ($h_1(x) == h_2(x)$): If both hash functions map into the same shared array of size $m$ and $h_1(x) = h_2(x)$ for some key $x$, a second key $y$ with $h_1(y) = h_2(y) = h_1(x)$ forms an immediate 2-element bicyclic crash. Either use two disjoint tables $T_1, T_2$ or force $h_2(x) \ne h_1(x)$.
Check yourself Question 1 of 4
Concept Check Effect of Robin Hood Hashing on Mean vs Variance of Probe Length recall

How does Robin Hood linear probing affect the expected (mean) probe length and the probe length variance compared to standard first-come-first-served linear probing (assuming no early-exit optimization)?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

In standard 2-table Cuckoo Hashing (d = 2 hash functions, bucket size b = 1), each key x corresponds to an undirected edge (h1(x), h2(x)) in the bipartite Cuckoo graph G. Under what exact graph-theoretic condition can a set of n keys be validly placed into the Cuckoo table without collision, and what load factor threshold does this impose?

Key Takeaways

Robin Hood Hashing swaps the probing key with an occupant whenever curr_psl > occupant_psl, leaving mean probe count unchanged while reducing probe length variance exponentially.
Because keys in a Robin Hood cluster are ordered by home bucket, find(q) can terminate early as soon as search_psl > occupant_psl, and erase(q) uses backward-shift deletion to eliminate tombstones.
Cuckoo Hashing stores each key x at either T1[h1(x)] or T2[h2(x)], guaranteeing strict worst-case O(1) lookups (at most 2 parallel memory reads).
Standard 2-way Cuckoo Hashing requires load factor α < 0.50 to avoid bicyclic components in the bipartite cuckoo graph, while 4-slot Bucketized Cuckoo reaches α = 0.95.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue