Cairn
data structures · open addressing, one table, one hash function, no swap/eviction rule · O(1) average get/put, O(n) worst case

back to Hash-Based

Linear Probing

Both of this site's other open-addressing pages name this scheme without ever building it: Robin Hood Hashing's own Complexity section says its worst case is "still O(n)... just as it can for plain linear probing or chaining," and its demo's "Robin Hood swap" checkbox literally toggles this exact algorithm on and off. Linear probing is that baseline: one table, one hash function, and — unlike Robin Hood hashing's swap rule or cuckoo hashing's second table — no rule at all governing where a key ends up beyond "walk forward one slot at a time from your computed home until you find a match or an empty slot." No lists, no eviction, no second array. It's the scheme every other page in this category either builds on top of or displaces entirely.

That simplicity has a real cost besides worse probe-length variance: delete can't just null out a slot. A get that hasn't found its key yet stops the moment it hits an empty slot — that's the only signal it has that the key isn't in the table. Null out a slot that used to sit in the middle of some other key's probe path, and every key past that point becomes unreachable, even though it's still sitting right there in the array. This page's two Pitfalls are both consequences of that one fact, and its fix (a tombstone — a marker distinct from both "occupied" and "truly empty") trades the first bug for a different one.

Try it

Put a key/value pair, get one back, or delete it. The sample loads three keys — ram, pig, cat — into an 8-slot table in that order, chosen because all three hash to slot 6: ram takes slot 6 itself, pig probes one step to slot 7, and cat probes past both and wraps around to slot 0. Uncheck tombstone delete, delete ram, then try getting pig or cat — both are still sitting in the table, and both come back "not found," because the now-empty slot 6 stops the probe walk before it ever reaches them. Check the box, reload the sample, and repeat the same delete: both keys are still findable, because slot 6 now holds a tombstone instead of a true empty, and a tombstone doesn't stop the walk.

The second control, reuse tombstones on insert, targets a different bug. Click Run tombstone-fill demo with tombstone delete on: it inserts and immediately deletes eight keys that each hash to a different one of the table's eight slots, leaving every slot a tombstone and the table reporting zero live entries — then tries to insert one more key. With reuse off, that insert throws "table full" on a table that is, by its own live count, completely empty. With reuse on, it succeeds by claiming the first tombstone the new key's probe walk crosses.

Loaded a sample table (8 slots). Delete "ram" with tombstone delete unchecked, then get "pig" or "cat" to see a key the table still holds reported as missing.

Core operations

Reference implementation

class LinearProbingHashTable {
  static TOMBSTONE = Symbol('tombstone');
  #size;
  #table;
  #count = 0;

  constructor(size = 8) {
    this.#size = size;
    this.#table = new Array(size).fill(null);
  }

  #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) {
    let i = this.#hash(key);
    for (let step = 0; step < this.#size; step++) {
      const slot = this.#table[i];
      if (slot === null) return undefined;
      if (slot !== LinearProbingHashTable.TOMBSTONE && slot.key === key) return slot.value;
      i = (i + 1) % this.#size;
    }
    return undefined;
  }

  put(key, value, reuseTombstones = true) {
    let i = this.#hash(key);
    let firstTombstone = -1;
    for (let step = 0; step < this.#size; step++) {
      const slot = this.#table[i];
      if (slot === null) {
        if (reuseTombstones && firstTombstone !== -1) this.#table[firstTombstone] = { key, value };
        else this.#table[i] = { key, value };
        this.#count++;
        this.#maybeResize(reuseTombstones);
        return;
      }
      if (slot === LinearProbingHashTable.TOMBSTONE) {
        if (reuseTombstones && firstTombstone === -1) firstTombstone = i;
      } else if (slot.key === key) {
        slot.value = value;
        return;
      }
      i = (i + 1) % this.#size;
    }
    if (reuseTombstones && firstTombstone !== -1) {
      this.#table[firstTombstone] = { key, value };
      this.#count++;
      return;
    }
    throw new Error('table full');
  }

  delete(key, useTombstone = true) {
    let i = this.#hash(key);
    for (let step = 0; step < this.#size; step++) {
      const slot = this.#table[i];
      if (slot === null) return false;
      if (slot !== LinearProbingHashTable.TOMBSTONE && slot.key === key) {
        this.#table[i] = useTombstone ? LinearProbingHashTable.TOMBSTONE : null;
        this.#count--;
        return true;
      }
      i = (i + 1) % this.#size;
    }
    return false;
  }

  #maybeResize(reuseTombstones) {
    if (this.#count / this.#size <= 0.75) return;
    const old = this.#table.filter(s => s && s !== LinearProbingHashTable.TOMBSTONE);
    this.#size *= 2;
    this.#table = new Array(this.#size).fill(null);
    this.#count = 0;
    for (const slot of old) this.put(slot.key, slot.value, reuseTombstones);
  }
}

The resize check in #maybeResize only ever looks at #count — live entries — never at how many slots are tombstoned. That's deliberate, not an oversight: it matches the site's other Hash-Based entries' identical 0.75-on-live-count policy, and it's also exactly what makes the second Pitfall below possible. Verified against a plain-Map reference model over 4,000 randomized trials (300-800 interleaved put/get/delete calls each, key spaces from 5 to 20 across table sizes that force both frequent collisions and repeated resizes), checking every get/delete result against the model after every call — see /tmp/lp/verify.js, scratch, not committed. Separately confirmed the page's own three-key wraparound walkthrough and the eight-key tombstone-fill sequence by hand against this exact class, both described in the sections below.

Pitfalls

Deleting without a tombstone strands every key further along the same probe run, not just the one deleted. Loading ram, pig, cat into an empty 8-slot table (in that order) lands them at slots 6, 7, and 0 — cat wraps all the way around because slots 6 and 7 are already taken by the time it arrives. Delete ram by simply nulling slot 6 and leaving everything else in place: get("pig") starts at slot 6 (pig's own home too), finds it empty, and reports "not found" immediately — never even reaching slot 7, where pig still sits. get("cat") fails the same way, for the same reason. Both keys are still in the table; the walk just never gets far enough to see them. Marking the deleted slot with a tombstone instead fixes both at once, because a tombstone lets the walk continue instead of stopping — confirmed by running the exact shipped delete/get pair with useTombstone forced to false and then to true against the identical three-key table: two real false negatives with tombstones off, zero with them on.

An insert that doesn't reuse tombstones can report "table full" on a table with zero live entries. Eight keys — hog, hen, doe, cow, dog, koi, gnu, fox — were chosen because each hashes to a different one of an 8-slot table's eight homes. Insert and immediately delete each one in turn (tombstone delete on): every delete brings the live count back down to 0, so it never gets anywhere near the 0.75 resize threshold, but each cycle leaves one more slot permanently tombstoned. After all eight, the live count is 0 and every one of the eight slots holds a tombstone — no slot is truly empty anywhere in the table. Inserting a ninth key, cod (home slot 0, already a tombstone), walks the entire table without ever finding a null slot to stop at. An insert that only places new entries into a truly empty slot has nowhere to go and throws "table full," even though #count reports 0. Confirmed against the exact shipped put: with reuseTombstones forced to false, the ninth insert throws every time this exact sequence runs; forcing it back to true lets the walk claim the first tombstone it crosses (slot 0 itself, in this case) and the insert succeeds instead. The bug isn't the tombstone count going unwatched by the resize check — it's specifically that put never considered a tombstoned slot as available space to begin with.

Where linear probing shows up

Complexity

Time: get, put, and delete are all O(1) average, the same headline every Hash-Based entry on this site shares. Worst case is O(n), same as the chaining hash table — but unlike Robin Hood hashing, nothing here bounds how unevenly that cost falls across keys; the three-key demo above already puts one key (cat) two full slots further from home than another (ram) with nothing but insertion order to blame. Space: O(n), one table, same 0.75-live-load-factor-then-double policy as the site's other single-table entries — with the caveat the second Pitfall demonstrates directly: a table's physical fullness (live entries plus tombstones) can run well ahead of what its own live-count-based resize check ever sees.

There is no scenario, for the exact put/get/delete contract this page and Robin Hood Hashing both implement, where plain linear probing is the better choice: Robin Hood hashing's own Pitfalls section already showed that its swap rule doesn't change the total probe-step cost across a batch of inserts, only how that cost is distributed — so adding the rule never makes any single key worse off, for the same one table, one hash function, and the same load factor. That's why Choosing a Hash Table Collision Strategy sets this page aside from its three-way comparison rather than adding it as a fourth branch: the honest answer to "when should I use plain linear probing over Robin Hood hashing" is never, for this particular contract. Its value is in being the thing the other two either measure themselves against or are built directly on top of.