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
mallocoverhead (16 bytes) + an 8-bytenextpointer, 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$.
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.
Invariant & State
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 aTOMBSTONEand writeskthere without continuing untilEMPTYork, a keykthat already exists later in the probe chain will be duplicated in the table! Always recordfirstTomband continue probing untilEMPTYor 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) | 1so $h_2$ is strictly odd.