Cairn
data structures · open addressing, one table, bounded neighborhood via a hop-info bitmap · O(1) worst-case get/delete

back to Hash-Based

Hopscotch Hashing

The site's Robin Hood hashing page keeps one table and one hash function, and trades away a hard worst-case bound on get for a low-variance one: probe lengths cluster tightly near the average, but a pathological input can still build one long chain. Its cuckoo hashing neighbor buys a genuine worst-case bound — get checks exactly two fixed slots, always — but needs two tables and two independent hash functions to do it. Hopscotch hashing asks whether a hard bound is possible with only one table and one hash function, and answers yes: every key is guaranteed to live within a fixed neighborhood of its own home slot — the next H slots starting there, H a small constant chosen when the table is built. A hop-info bitmap, one per home slot, records exactly which of those H offsets are occupied by an entry belonging to that home. get never probes forward hoping to get lucky; it reads the home's own H-bit bitmap and checks only the offsets it says are occupied — a real O(1) worst case, using one table.

The trick is entirely on the put side. Inserting a key finds the nearest empty slot by ordinary forward linear probing from its home, exactly like plain linear probing would — but if that empty slot lands further than H-1 away, it's too far to use directly. Hopscotch hashing doesn't give up or fall back to scanning; it hops the empty slot closer: it looks at the H-1 slots immediately behind the empty one, finds an entry there whose own home is close enough that moving it into the empty slot would still land it inside its own neighborhood, and moves it — vacating a slot nearer to the original key's home. Repeat until the empty slot is finally within reach, then place the key and set one bit. No entry ever ends up outside its own home's neighborhood, which is exactly what makes the bitmap-only get above safe to trust.

Try it

Put a key/value pair, get one back, or delete it. The log traces every slot the operation touches. The sample loads five keys — cat, pig, fox, ram, lark — into an 8-slot table with a neighborhood size of 4, in that order, chosen because it forces a real hop: cat and pig both hash home to slot 6, so pig takes slot 7. fox hashes home to slot 7 and, finding it taken, wraps around to slot 0. ram hashes home to slot 6 again and lands at slot 1 (three slots forward, still within reach). By the time lark arrives — home slot 6 once more — slots 6, 7, 0, and 1 are all occupied, so the nearest empty slot is 2, a distance of 4 from home 6: too far for a neighborhood of size 4. Hopscotch hashing doesn't stop there: it notices fox, sitting at slot 0, belongs to home 7 and is close enough to slot 2 to move there without leaving home 7's own neighborhood, hops it over, and lark takes the now-vacant slot 0. Click "Reload sample" and watch the log narrate exactly that hop. Then get fox — the log shows the bitmap-bounded lookup checking a single offset and finding it immediately, even though fox now sits two slots away from where it first landed. Finally, uncheck Hop-back displacement and reload: lark gets placed at the far slot anyway, with no bit set to mark it — get lark and watch it come back "not found" even though it's sitting right there in the table.

Loaded a sample table (8 slots, neighborhood 4). Try getting "fox" to see a bounded, bitmap-only lookup.

Core operations

Reference implementation

class HopscotchHashTable {
  #size;
  #H;
  #table;
  #hop;
  #count = 0;

  constructor(size = 8, H = 4) {
    this.#size = size;
    this.#H = H;
    this.#table = new Array(size).fill(null);
    this.#hop = new Array(size).fill(0);
  }

  #hash(key, size = this.#size) {
    let h = 0;
    const s = String(key);
    for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
    return h % size;
  }

  get(key) {
    const home = this.#hash(key);
    for (let k = 0; k < this.#H; k++) {
      if (!((this.#hop[home] >> k) & 1)) continue;
      const idx = (home + k) % this.#size;
      const slot = this.#table[idx];
      if (slot && slot.key === key) return slot.value;
    }
    return undefined;
  }

  has(key) { return this.get(key) !== undefined; }

  put(key, value) {
    const home = this.#hash(key);
    for (let k = 0; k < this.#H; k++) {
      if (!((this.#hop[home] >> k) & 1)) continue;
      const idx = (home + k) % this.#size;
      if (this.#table[idx] && this.#table[idx].key === key) { this.#table[idx].value = value; return; }
    }
    while (!this.#insertNew(key, value, this.#hash(key))) {
      this.#resize();
    }
    if (this.#count / this.#size > 0.75) this.#resize();
  }

  // Places a brand-new key. Returns false if no legal slot exists at this
  // table size — caller must grow and retry.
  #insertNew(key, value, home) {
    let free = -1;
    for (let step = 0; step < this.#size; step++) {
      const idx = (home + step) % this.#size;
      if (!this.#table[idx]) { free = idx; break; }
    }
    if (free === -1) return false;

    let dist = (free - home + this.#size) % this.#size;
    while (dist >= this.#H) {
      let moved = false;
      for (let d = this.#H - 1; d >= 1 && !moved; d--) {
        const base = (free - d + this.#size) % this.#size;
        for (let o = 0; o < d; o++) {
          if (!((this.#hop[base] >> o) & 1)) continue;
          const y = (base + o) % this.#size;
          this.#table[free] = this.#table[y];
          this.#table[y] = null;
          this.#hop[base] &= ~(1 << o);
          this.#hop[base] |= (1 << d);
          free = y;
          moved = true;
          break;
        }
      }
      if (!moved) return false;
      dist = (free - home + this.#size) % this.#size;
    }

    this.#table[free] = { key, value };
    this.#hop[home] |= (1 << dist);
    this.#count++;
    return true;
  }

  #resize() {
    const old = this.#table.filter(Boolean);
    let size = this.#size;
    let placed;
    do {
      size *= 2;
      this.#size = size;
      this.#table = new Array(size).fill(null);
      this.#hop = new Array(size).fill(0);
      this.#count = 0;
      placed = true;
      for (const slot of old) {
        if (!this.#insertNew(slot.key, slot.value, this.#hash(slot.key))) { placed = false; break; }
      }
    } while (!placed);
  }

  delete(key) {
    const home = this.#hash(key);
    for (let k = 0; k < this.#H; k++) {
      if (!((this.#hop[home] >> k) & 1)) continue;
      const idx = (home + k) % this.#size;
      if (this.#table[idx] && this.#table[idx].key === key) {
        this.#table[idx] = null;
        this.#hop[home] &= ~(1 << k);
        this.#count--;
        return true;
      }
    }
    return false;
  }
}

Every index arithmetic expression wraps with (... + this.#size) % this.#size, not plain % — the wraparound the first Pitfall below covers. Verified against a plain- Map reference model over 4,000 randomized trials (300-800 interleaved put/get/delete calls each, key spaces of 5-20 words, table size 8 with H=4, forcing frequent collisions, hops, and resizes), checking every get/delete result and the live entry count against the model after every single call, plus a full sweep of every model key at the end of each trial — see /tmp/hopscotch_ref.js, scratch, not committed. This caught a real bug before it shipped: the first draft's put called #resize() once after a failed #insertNew and tried again exactly once, without checking whether that second attempt succeeded either — under heavy same-home clustering a single doubling isn't always enough, and the key being inserted was silently dropped the moment it wasn't, showing up in the harness as the live count falling one short of the model's. The fix replaces the one-shot retry with the while loop above, which keeps doubling until #insertNew actually succeeds. Separately confirmed the page's own five-key walkthrough by hand against this exact class: cat/pig/fox/ram land directly, and lark's insert hops fox from slot 0 to slot 2 before landing in the vacated slot 0 — byte for byte what the demo above shows.

Pitfalls

Skipping the hop-back dance doesn't corrupt anything loudly — it just makes a key that's physically present permanently invisible to a correct, bitmap-bounded get. Inserting cat, pig, fox, ram, lark into an empty 8-slot table with the hop-back rule off — placing each key at the first empty slot linear probing finds, with no window check — lands every key exactly where the rule-on version does, except lark: instead of triggering a hop, it's placed directly at slot 2, a distance of 4 from home slot 6, one slot outside the 4-wide neighborhood. Because that distance doesn't fit in the 4-bit hop map, no bit gets set for it. Calling the real, unmodified get("lark") against this exact table checks home 6's bitmap, finds no bit that reaches slot 2, and reports "not found" — while lark is sitting in the table the whole time, one slot past the boundary get is willing to look at. Checked by running both the correct and the hop-back-disabled insert sequence against the same reference implementation and diffing the two resulting tables directly, not by reasoning about it.

Dropping the + this.#size from a wraparound subtraction doesn't crash — it quietly turns a negative array index into a phantom slot that's never occupied, and the table pays for it with unnecessary resizes. JavaScript's % keeps the sign of its left operand, so (free - d) % this.#size without the + this.#size correction returns a small negative number whenever d exceeds free — exactly the case the wraparound in this page's own walkthrough hits, since lark's insert needs a candidate base behind slot 2 in a table indexed 0-7. Running that exact broken subtraction against the same five-key sequence: this.#hop[-1] reads undefined, which bitwise-AND coerces to 0, so every candidate check in the wraparound region silently reports "no bit set here" even where a real, legal hop exists. #insertNew then correctly returns false — no crash, no wrong answer, just a spurious failure — and put's retry loop doubles the table and tries again. Rerunning the identical sequence against this broken version resizes the table from 8 slots to 16 to fit just five entries (31% load factor) where the correct version needs no resize at all. The bug never touches a value or drops a key; it just makes the table give up on a hop that was there all along.

A full neighborhood can force a resize far below the usual 0.75 load factor, and that's correct, not a tuning failure. Inserting hog, cod, kiwi, crab — four keys that all hash home to slot 0 — fills every offset 0-3 of home 0's neighborhood exactly, one key per offset. A fifth same-home key, snake, finds the nearest empty slot at distance 4 and needs a hop — but every candidate base in reach (slots 1, 2, 3) has never held an entry of its own, so their hop bitmaps are all zero: there is nothing anywhere in range that snake could legally displace. Running this exact five-key sequence against the reference implementation resizes the table from 8 slots to 16 the moment snake is inserted — with only 5 of the original 8 slots filled, a 62.5% load factor, well under the 0.75 threshold that would normally trigger a resize. This isn't a bug: no amount of clever hopping can rescue an insert when every neighbor in reach is a stranger to the neighborhood, and giving up early to resize is the correct response, not a sign H=4 was tuned wrong for this specific pathological input.

Where hopscotch hashing shows up

Complexity

Time: get and delete are O(1) worst case, not just average — a real distinction from Robin Hood hashing's low-variance-but- unbounded guarantee, matching cuckoo hashing's hard bound while needing only one table and one hash function. The bound comes from H being a small constant fixed at construction time, not from anything about the keys stored. put is O(1) amortized: most inserts land directly or after a short hop chain of at most H-1 moves, and resizing (triggered by either the usual 0.75 load factor or a full neighborhood with no legal hop, see Pitfalls) amortizes to O(1) per insert over a run, the same way it does for the site's other open-addressing tables. Space: O(n), one table plus one small bitmap per slot — no second table's worth of overhead the way cuckoo hashing carries. The real cost of the hard bound isn't memory, it's insert-side complexity and a resize trigger cuckoo hashing and Robin Hood hashing don't share: a neighborhood can run out of room well before the table itself does, which is why choosing H is a genuine tuning knob — small enough to keep each lookup's bitmap read cheap, large enough that same-home clustering doesn't force resizes far ahead of schedule.

This is one of four collision-resolution strategies the site covers for the same put/get/delete contract — the site's chaining hash table, cuckoo hashing, and Robin Hood hashing are the other three. See Choosing a Hash Table Collision Strategy for the full four-way comparison.