DSA DSA In Depth
0%
lesson hash-tablesopen-addressinglinear-probing

Collision Resolution: Separate Chaining vs Linear/Quadratic/Double Probing

7 min read Module 2.1: Hash Tables, Modern Probing & Probabilistic Sketches Updated Oct 23, 2026
📄 Cheat Sheet
MIT 6.006 Lecture 10: Open Addressing, Linear/Double Probing & Tombstone Deletions
MIT OpenCourseWare (Srini Devadas) MIT 6.006 Lecture 10: Open Addressing, Linear/Double Probing & Tombstone Deletions Watch on YouTube ↗

Srini Devadas derives the probe sequence invariants of open addressing, explains why deletions require tombstones, and contrasts primary clustering in linear probing against double hashing.

0:00 / --:--

Separate Chaining vs Open Addressing Probe Sequences

Define the load factor $\alpha = n / m$, where $n$ is the number of stored elements and $m$ is the number of bucket slots:

  • Separate Chaining ($alpha$ can exceed $1$): Each slot stores a pointer to a linked list (or small inline vector) of colliding entries. Under uniform hashing, an unsuccessful search inspects $\alpha$ nodes and a successful search inspects $1 + \alpha/2$ nodes on average. However, every node allocation incurs malloc overhead (16 bytes) + an 8-byte next pointer, and traversing a chain dereferences non-contiguous heap addresses (L3/DRAM cache misses).
  • Linear Probing ($h(k, i) = (h(k) + i) \bmod m$): Probes adjacent slots sequentially. Maximizes spatial locality and hardware stream prefetching, but suffers from primary clustering: any key hashing anywhere inside a contiguous cluster of length $L$ extends that cluster to $L + 1$ with probability $(L + 1)/m$.
  • Quadratic Probing ($h(k, i) = (h(k) + c_1 i + c_2 i^2) \bmod m$): Eliminates primary clustering by jumping quadratically, leaving only milder secondary clustering (keys with the exact same initial $h(k)$ follow the same trajectory). When $m = 2^r$ and $c_1 = c_2 = 1/2$, the triangular numbers $h(k, i) = (h(k) + i(i+1)/2) \bmod 2^r$ are guaranteed to visit every slot in $\{0, \dots, 2^r - 1\}$ exactly once!
  • Double Hashing ($h(k, i) = (h_1(k) + i \cdot h_2(k)) \bmod m$): Uses a second independent hash $h_2(k)$ coprime to $m$ (e.g., $h_2(k)$ odd when $m = 2^r$, or $h_2(k) \in [1, m-1]$ when $m$ is prime). Generates $m^2$ distinct probe sequences, eliminating both primary and secondary clustering at the cost of a new cache miss on nearly every probe step.
Collision Strategy Expected Probes (Successful) Expected Probes (Unsuccessful) Probes at $\alpha = 0.75$ (Miss) Cache Line Locality
Separate Chaining $1 + \frac{\alpha}{2}$ $1 + \alpha$ $1.75$ Poor (Pointer chasing across heap)
Linear Probing (Knuth) $\frac{1}{2}\left(1 + \frac{1}{1 - \alpha}\right)$ $\frac{1}{2}\left(1 + \frac{1}{(1 - \alpha)^2}\right)$ $8.50$ Optimal (Stride-1 within 64B line)
Quadratic / Triangular $1 - \ln(1 - \alpha) - \frac{\alpha}{2}$ $\frac{1}{1 - \alpha} - \alpha - \ln(1 - \alpha)$ $4.64$ Moderate (First 3 steps in 1–2 lines)
Double Hashing (Uniform) $\frac{1}{\alpha} \ln\left(\frac{1}{1 - \alpha}\right)$ $\frac{1}{1 - \alpha}$ $4.00$ Poor (Random jump per probe)

Formal Specification:Probe Chain Continuity & The Tombstone Invariant

Probe Chain Invariant: For every key $k$ present at slot $h(k, j)$ in an open-addressed table, every preceding probe slot $h(k, 0), h(k, 1), \dots, h(k, j-1)$ must be strictly non-empty (either OCCUPIED or TOMBSTONE).

Why Naive Deletion Corrupts Open Addressing: Suppose $k_1$ and $k_2$ both hash to slot $4$, so $k_1$ sits at slot $4$ and $k_2$ probes to slot $5$. If erase(k1) resets slot $4$ to EMPTY, a subsequent find(k2) starts at slot $4$, sees EMPTY, and falsely returns NOT_FOUND! Therefore, standard open addressing replaces deleted entries with a TOMBSTONE sentinel: find treats TOMBSTONE as occupied (keep probing), whereas insert records the first TOMBSTONE seen and reuses it once it verifies $k$ does not already exist further down the probe chain.

Visualizing Primary Clustering & Tombstone Accumulation

The SVG diagram below illustrates how a contiguous cluster of 4 elements in Linear Probing captures any hash landing in slots $[2..6]$ (probability $5/m$), and how replacing slot $3$ with a TOMBSTONE preserves the search bridge to keys shifted to slots $4$ and $5$.

Linear Probing Array (m = 8): Keys K1(h=2), K2(h=2), K3(h=3), K4(h=2) after erase(K2 at [3]) EMPTY[0] EMPTY[1] K1 (h=2)[2] TOMB[3] del K2 K3 (h=3)[4] K4 (h=2)[5] EMPTY[6] EMPTY[7] find(K4): starts at [2] → crosses TOMB at [3] → skips [4] → finds K4 at [5] Note: Effective Miss Load Factor α_eff = (n_live + n_tombstones) / m
flowchart TD Start["insert(key, val): i = 0, firstTomb = -1"] --> Probe["idx = (h(key) + (i + i*i)/2) & (m - 1)"] Probe --> State{"Slot[idx] State?"} State -- "OCCUPIED & key matches" --> Update["Overwrite Slot[idx].val = val Return (Existing Key)"] State -- "OCCUPIED & key differs" --> Next["i++"] --> Probe State -- "TOMBSTONE" --> SaveTomb["if (firstTomb == -1) firstTomb = idx i++ (Must keep checking for duplicate key!)"] --> Probe State -- "EMPTY" --> Place["target = (firstTomb != -1) ? firstTomb : idx Write (key, val) at Slot[target]"]
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: Why Tombstones Silently Degrade $O(1)$ Tables

In workloads with heavy churn (frequent insert and erase keeping live count $n$ constant), TOMBSTONE slots accumulate. Because unsuccessful lookups (find for missing keys) can only stop at a true EMPTY slot, the effective load factor for misses is $\alpha_{\text{eff}} = (n_{\text{live}} + n_{\text{tomb}}) / m$. Even if $n_{\text{live}} / m = 0.25$, once $n_{\text{tomb}} / m$ reaches $0.70$, every cache-miss lookup scans $\frac{1}{2}(1 + 1/(1 - 0.95)^2) \approx 200$ slots! Production open-addressed tables track n_live + n_tomb and trigger a same-capacity tombstone-purging rehash when n_live + n_tomb > 0.75 * m while n_live < 0.50 * m.

Production C++20 Implementation: Power-of-Two Triangular Quadratic Probing

// C++20: Open-Addressed Hash Table with Triangular Quadratic Probing & Tombstone Rehash
#include <vector>
#include <cstdint>
#include <optional>

template <typename K, typename V>
class TriangularProbeMap {
    enum class State : uint8_t { EMPTY = 0, OCCUPIED = 1, TOMBSTONE = 2 };
    struct Slot {
        K key{};
        V val{};
        State state = State::EMPTY;
    };

    std::vector<Slot> table_;
    size_t live_ = 0;
    size_t tombs_ = 0;

    static uint64_t hash64(uint64_t x) noexcept {
        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{});
        live_ = 0;
        tombs_ = 0;
        for (auto& s : old) {
            if (s.state == State::OCCUPIED) insert(std::move(s.key), std::move(s.val));
        }
    }

public:
    explicit TriangularProbeMap(size_t pow2Cap = 16) : table_(pow2Cap) {}

    bool insert(K key, V val) {
        // Trigger resize or same-size tombstone cleanup when occupied + tombstones >= 75%
        if ((live_ + tombs_) * 4 >= table_.size() * 3) {
            size_t nextCap = (live_ * 2 >= table_.size()) ? table_.size() * 2 : table_.size();
            rehash(nextCap);
        }
        const size_t mask = table_.size() - 1;
        size_t idx = hash64(static_cast<uint64_t>(key)) & mask;
        size_t firstTomb = static_cast<size_t>(-1);

        for (size_t step = 1; ; ++step) {
            Slot& s = table_[idx];
            if (s.state == State::EMPTY) {
                Slot& target = (firstTomb != static_cast<size_t>(-1))
                               ? (--tombs_, table_[firstTomb]) : s;
                target.key = std::move(key);
                target.val = std::move(val);
                target.state = State::OCCUPIED;
                ++live_;
                return true;
            }
            if (s.state == State::TOMBSTONE) {
                if (firstTomb == static_cast<size_t>(-1)) firstTomb = idx;
            } else if (s.key == key) {
                s.val = std::move(val);
                return false;
            }
            // Triangular step: idx_{i} = (idx_{i-1} + step) mod 2^r visits all 2^r slots!
            idx = (idx + step) & mask;
        }
    }

    std::optional<V> find(const K& key) const {
        const size_t mask = table_.size() - 1;
        size_t idx = hash64(static_cast<uint64_t>(key)) & mask;
        for (size_t step = 1; ; ++step) {
            const Slot& s = table_[idx];
            if (s.state == State::EMPTY) return std::nullopt;
            if (s.state == State::OCCUPIED && s.key == key) return s.val;
            idx = (idx + step) & mask;
        }
    }
};

Failure Modes & Adversarial Pitfalls

  • Inserting Immediately at the First Tombstone Without Checking Remainder of Probe Chain: If insert(k, v) stops immediately upon seeing a TOMBSTONE and writes k there without continuing until EMPTY or k, a key k that already exists later in the probe chain will be duplicated in the table! Always record firstTomb and continue probing until EMPTY or an existing match.
  • Non-Coprime Step Size in Double Hashing: If $h_2(k)$ shares a common factor $g = \gcd(h_2(k), m) > 1$ with table size $m$, the probe sequence visits only $m / g$ slots before cycling infinitely. With $m = 2^r$, always enforce h2 = (raw_h2 * 2) | 1 so $h_2$ is strictly odd.
Check yourself Question 1 of 4
Concept Check Knuth's Expected Probe Bound for Linear Probing recall

Under the uniform hashing model at load factor α = n / m < 1, what is Knuth's asymptotic expected number of probes for an UNSUCCESSFUL search (miss) in Linear Probing?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

A cache service stores at most n = 200,000 live keys in an open-addressed table of capacity m = 1,048,576 (live load factor α_live = 0.19). After millions of mixed `insert(new_key)` and `erase(old_key)` operations, p99 latency for cache-miss lookups (`find` on missing keys) spikes by 30x without `size()` ever increasing. Why, and how do production tables fix this?

Key Takeaways

Linear Probing suffers from primary clustering with expected miss cost 1/2 (1 + 1/(1-α)^2), yet outperforms Separate Chaining for α ≤ 0.75 due to 64-byte L1 cache line spatial locality.
Triangular quadratic probing h(k, i) = (h(k) + i(i+1)/2) mod 2^r visits every slot of a power-of-two table exactly once while eliminating primary clustering.
Open-addressed deletion requires TOMBSTONE sentinels so existing probe chains are not severed; insert must still scan past tombstones to prevent duplicate keys.
Rehashing thresholds must monitor (n_live + n_tombstones) / m, running a same-capacity compaction rehash when tombstones bloat unsuccessful lookup latency.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue