Data Structures/Skip Lists & Probabilistic Structures

Lesson 4.94,154 words

Skip Lists & Probabilistic Structures

Balanced trees achieve O(logn)O(\log n) with rotations and invariants; randomization gives the same bound far more simply. A skip list is a layered linked list whose express lanes are chosen by coin flips, giving expected O(logn)O(\log n) search and insert with no rebalancing.

╌╌╌╌

A balanced search tree earns its guarantee with machinery: height invariants, rotations, and fix-up cases you have to get exactly right. This lesson takes a different route to the same bound: randomization. Instead of forcing balance with invariants, we expect it from coin flips, and the resulting structures are far simpler to implement. This lesson covers two. The skip list is a sorted dictionary that matches a balanced tree's bounds with nothing but linked lists and a random number generator. The Bloom filter gives up exactness entirely: it answers set membership in a few bits per element, accepting a controlled rate of false positives in exchange for tiny space.

Skip lists: express lanes by coin flip

Start with an ordinary sorted linked list. Search is because you must walk node by node; there is no way to skip ahead. To speed this up, add express lanes: a sparse second list linking every other node, then a sparser third linking every fourth, and so on. A search rides the top express lane until it would overshoot, drops down a level, and repeats. With lanes each twice as sparse as the one below, a search takes steps — the same idea as a balanced tree, built from lists.

The catch is keeping those lanes perfectly spaced under insertion and deletion, which would cost as much as rebalancing a tree. The skip list avoids this: instead of maintaining exact spacing, it assigns each node a random height by flipping a coin.1 Every new node enters level . Then flip a fair coin: heads promotes the node into level ; another heads promotes it into level ; the tower stops growing at the first tails. A node therefore reaches level with probability : every node sits in level , about half rise to level , a quarter to level , an eighth to level . On average the lanes are spaced just like the deterministic version, but no rebalancing is ever needed.

A skip list over the keys . Level holds every key; higher levels hold a random subset of the level below. Searching for rides the top lane from to ; the next key, , would overshoot, so the search drops a level. Level overshoots at again, so it drops to level and steps right to .

Search. Begin at the head of the top level. Move right while the next key is the target; when the next key would overshoot, drop down one level and continue. You reach level at the target (if present) or its predecessor. Each rightward step and each drop is .

Algorithm 1:Search(L,k)\textsc{Search}(L, k) — locate key kk in skip list LL
  1. 1
    xhead(L)x \gets head(L)
    top-level sentinel
  2. 2
    for ihi \gets h down to 11 do
    walk levels top to bottom
  3. 3
    while nexti(x)nilnext_i(x) \ne \text{nil} and key(nexti(x))kkey(next_i(x)) \le k do
  4. 4
    xnexti(x)x \gets next_i(x)
    ride this lane right while it stays k\le k
  5. 5
    return xx if key(x)=kkey(x) = k else not found

Trace on the list above. Start at on level . The next key, , is : advance to . The next key on level is : drop to level 's copy of . On level the next key is again : drop to level . There the next key is : advance, and : found after two rightward moves and two drops, where the plain list would have walked four nodes. Searching for an absent key, say , follows the identical path but stops at on level (since ) and reports not-found. The search lands on the predecessor of the missing key — the node an insertion needs.

Insert: search, flip, splice

Insertion is a search with bookkeeping, followed by coin flips, followed by pointer surgery. Three phases:

  1. Search and remember. Walk the usual search path for the new key , but at each level record the last node visited before dropping: the update vector . Node is 's predecessor on level : the node whose level- pointer must be redirected if the new tower reaches level .
  2. Flip for a height. Set and flip until tails, incrementing on each heads.
  3. Splice. For each level : the new node's level- pointer takes over 's old target, and 's level- pointer is redirected to the new node. Two assignments per level. If exceeds the current height, the new levels start at the head sentinel.
Algorithm 2:Insert(L,k)\textsc{Insert}(L, k) — insert key kk with a random height
  1. 1
    xhead(L)x \gets head(L)
  2. 2
    for ihi \gets h down to 11 do
  3. 3
    while nexti(x)nilnext_i(x) \ne \text{nil} and key(nexti(x))<kkey(next_i(x)) < k do
  4. 4
    xnexti(x)x \gets next_i(x)
  5. 5
    updateixupdate_i \gets x
    predecessor of kk on level ii
  6. 6
    1\ell \gets 1
  7. 7
    while CoinFlip()=heads\textsc{CoinFlip}() = \text{heads} do +1\ell \gets \ell + 1
    geometric height
  8. 8
    create node zz with key kk and height \ell
  9. 9
    for i1i \gets 1 to \ell do
    splice, bottom level up
  10. 10
    nexti(z)nexti(updatei)next_i(z) \gets next_i(update_i)
  11. 11
    nexti(updatei)znext_i(update_i) \gets z

Run on the running example. The search path is the one already traced for : on level , advance , see , record , drop; on level , see , record , drop; on level , see , record . The update vector is the tower of at every level; the new key falls in the gap between and .

Phase one of : the search path (blue) overshoots at on levels and and at on level , so the last node visited on every level is (filled). The update vector is ; the new node will be spliced into the gap just right of this tower.

Suppose the flips come up heads, then tails: the tower stops at , so joins levels and only. The splice is four pointer assignments. On level : 's pointer takes 's old target , and now points to . On level : 's pointer takes the level- copy of 's old target , and that copy now points to . Level is untouched because the tower never reached it. Nothing else in the list moves.

Phase three of : the coin came up heads, then tails, so the new tower (green) has height . On each of levels and , takes over its predecessor's old pointer and the predecessor points to — four assignments in all. Level is untouched; no other node moves.

Delete is the mirror image, with no coins at all. Search for the key with the same bookkeeping, so is the node before the victim on each level; then, for every level the victim occupies, redirect past it to the victim's own level- successor. Deleting from the list above reverses the four assignments of the insert and restores the original picture exactly. Neither operation rebalances anything; the random heights keep the structure well-spaced in expectation, whatever the order of insertions and deletions.

Heights are geometric

The height assigned to a node is plus the number of consecutive heads, so

a geometric distribution. Two consequences follow immediately. First, space: the number of pointers a node carries equals its height, and

so a skip list stores about pointers in expectation, the same order as the child pointers of a binary tree. The express lanes cost only one extra pointer per node on average.

Sixteen towers with the ideal geometric profile: every node reaches level , half reach level , a quarter level , and so on — each promotion survives a fresh coin flip with probability . The expected tower height, and so the expected number of pointers per node, is exactly .

Second, height of the whole list, which is the maximum of independent geometrics. That maximum concentrates tightly around , and the next two results make the guarantee precise.

Why the expected height and search cost are

The whole guarantee rests on two facts about the coin flips.

So search, insert, and delete are all expected , matching a balanced tree — but the code is a few dozen lines with no rotation cases, and the bound holds in expectation over the random heights, independent of the input order.2 The result is a balanced-tree guarantee from a coin and a linked list.3 Skip lists also parallelize and support concurrent updates more gracefully than rotation-based trees, which is why several production key-value stores use them.

skip_list.pypython
from collections.abc import Iterator
from random import Random
from typing import Generic, Optional, TypeVar

from comparable import Comparable

Key = TypeVar("Key", bound=Comparable)
Value = TypeVar("Value")

class SkipListNode(Generic[Key, Value]):
  """
    One tower in the skip list: a key, its associated value, and a list of\n
    forward links — one per level the node was promoted to. `forward[i]` is\n
    the next node on level `i + 1`, or None at the end of that lane.\n
    The head sentinel reuses this node with no key and no value.\n
  """

  def __init__(self, key: Optional[Key], value: Optional[Value], height: int) -> None:
    self.key: Optional[Key] = key
    self.value: Optional[Value] = value
    self.forward: list[Optional[SkipListNode[Key, Value]]] = [None for _ in range(height)]

  def __repr__(self) -> str:
    return f"SkipListNode({self.key!r} -> {self.value!r}, h={len(self.forward)})"

class SkipList(Generic[Key, Value]):
  """
    An ordered map from comparable keys to values.\n
    Keys must support `<`; duplicate inserts overwrite the stored value.\n
  """

  def __init__(self, max_height: int = 16, seed: Optional[int] = None) -> None:
    """
      Build an empty skip list. `max_height` caps the tower height (16 levels\n
      comfortably index millions of keys); `seed` pins the coin flips for\n
      reproducible structure in tests.\n
    """
    self._max_height: int = max_height
    self._random: Random = Random(seed)

    # the head sentinel owns one forward link per possible level.
    self._head: SkipListNode[Key, Value] = SkipListNode(None, None, max_height)

    # current number of populated levels and number of stored keys.
    self._height: int = 1
    self._size: int = 0

  def __len__(self) -> int:
    return self._size

  def _random_height(self) -> int:
    """
      Flip a fair coin to choose a new tower's height: start at level 1 and\n
      keep rising while heads come up, capped at `max_height`.\n
    """
    height: int = 1
    while height < self._max_height and self._random.random() < 0.5:
      height += 1
    return height

  def _predecessors(
    self, key: Key
  ) -> list[SkipListNode[Key, Value]]:
    """
      The `update` vector: for each level, the last node whose key is strictly\n
      less than `key`. Splicing and unlinking both happen at these nodes.\n
    """
    update: list[SkipListNode[Key, Value]] = [self._head for _ in range(self._max_height)]
    current: SkipListNode[Key, Value] = self._head

    # walk levels top to bottom, riding each lane right while it stays < key.
    for level in range(self._height - 1, -1, -1):
      next_node = current.forward[level]
      # every non-head node carries a real key (only the head sentinel is None,
      # and `current.forward` never points back at the head).
      while next_node is not None and next_node.key is not None and next_node.key < key:
        current = next_node
        next_node = current.forward[level]
      update[level] = current
    return update

  def search(self, key: Key) -> Optional[Value]:
    """
      The value stored under `key`, or None if `key` is absent.\n
      Expected O(log n) comparisons.\n
    """
    # ride each lane right while it stays < key, then drop a level.
    current: SkipListNode[Key, Value] = self._head
    for level in range(self._height - 1, -1, -1):
      next_node = current.forward[level]
      # only the head sentinel has a None key, and it is never a forward target.
      while next_node is not None and next_node.key is not None and next_node.key < key:
        current = next_node
        next_node = current.forward[level]

    # one step on level 1 lands on the candidate (or its successor).
    candidate = current.forward[0]
    if candidate is not None and candidate.key == key:
      return candidate.value
    return None

  def __contains__(self, key: Key) -> bool:
    return self.search(key) is not None

  def insert(self, key: Key, value: Value) -> None:
    """
      Store `value` under `key`, overwriting any existing entry.\n
      A fresh key gets a random tower height; an existing key keeps its tower\n
      and only updates the value. Expected O(log n).\n
    """
    update: list[SkipListNode[Key, Value]] = self._predecessors(key)

    # if the key already exists it sits right after its level-1 predecessor.
    existing = update[0].forward[0]
    if existing is not None and existing.key == key:
      existing.value = value
      return

    height: int = self._random_height()

    # any new levels start with the head as their predecessor.
    if height > self._height:
      for level in range(self._height, height):
        update[level] = self._head
      self._height = height

    new_node: SkipListNode[Key, Value] = SkipListNode(key, value, height)

    # pointer surgery: splice the tower in at each remembered predecessor.
    for level in range(height):
      new_node.forward[level] = update[level].forward[level]
      update[level].forward[level] = new_node
    self._size += 1

  def delete(self, key: Key) -> bool:
    """
      Remove `key` and its value. Returns False if the key was absent.\n
      Unlinks the tower from every level it occupies. Expected O(log n).\n
    """
    # the target, if present, sits right after its level-1 predecessor.
    update: list[SkipListNode[Key, Value]] = self._predecessors(key)
    target = update[0].forward[0]
    if target is None or target.key != key:
      return False

    # detach the tower wherever a predecessor still points at it.
    for level in range(self._height):
      if update[level].forward[level] is target:
        update[level].forward[level] = target.forward[level]

    # drop now-empty top levels so the height stays tight.
    while self._height > 1 and self._head.forward[self._height - 1] is None:
      self._height -= 1
    self._size -= 1
    return True

  def __iter__(self) -> Iterator[Key]:
    """
      Yield every stored key in ascending order — a level-1 walk.\n
    """
    current = self._head.forward[0]
    while current is not None:
      # past the head, every node stores a real key.
      key = current.key
      assert key is not None
      yield key
      current = current.forward[0]

  def items(self) -> Iterator[tuple[Key, Value]]:
    """
      Yield every `(key, value)` pair in ascending key order.\n
    """
    current = self._head.forward[0]
    while current is not None:
      # past the head, both key and value are populated.
      key = current.key
      value = current.value
      assert key is not None and value is not None
      yield key, value
      current = current.forward[0]
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

Bloom filters: membership in a handful of bits

A skip list still stores every key. Sometimes that is too expensive: with a billion URLs, the only question may be whether a given URL has been seen before, and storing the URLs themselves is out of the question. A Bloom filter answers that membership question in a tiny, fixed amount of space, by giving up the ability to answer it exactly.

The structure is a bit array , initially all , plus independent hash functions , each mapping a key to a position in .

  • Insert : set the bits to .
  • Query : report present only if all bits are ; otherwise report absent.
A Bloom filter with hashes over a -bit array. Inserting sets the three bits , , . Querying probes bits , , and finds two of them still , so is definitely absent; a query whose three bits all happen to be would report present, possibly a false positive.

The two answers are not symmetric. If even one of 's bits is , then was never inserted (inserting would have set it), so a absent answer is always correct — a Bloom filter has no false negatives. But a present answer can be wrong: the bits for some never-inserted might all have been set to by other elements' insertions, a false positive.

This is precisely the soundness / completeness framing from the foundations. Read as a test for is not in the set, the filter is sound: whenever it says absent it is right. Read as a test for is in the set, it is incomplete: present may be a false alarm. A Bloom filter is a definitely-not-present oracle: a negative answer is a proof, a positive answer is only a strong hint.

The false-positive rate

How often is present wrong? Inserting elements evaluates hashes, each choosing a position uniformly in . A fixed bit survives as only if every one of those throws misses it:

using for large . So after the insertions a fraction of the array is set. A false positive needs all of a query's probes to land on set bits, and treating the probes as independent hits on that fraction gives the false-positive probability

Two effects compete in the formula. More hashes make each query harder to pass by accident (the exponent grows), but they also fill the array faster (the base grows). Somewhere in between is a best .

Concrete numbers. Suppose you track URLs with bits, a MB array and bits per element. The optimum is ; take . Then , each bit ends up set with probability , almost exactly half as the theorem predicts, and

about false alarms per queries of absent keys. Doubling the budget to bits per element drives the rate to , under one in ten thousand. A hash set storing the URLs themselves would need hundreds of bits per element before a single pointer is counted.

False-positive rate as varies, at a fixed budget of bits per element. One hash fills the array slowly but lets queries pass with a single lucky bit: . More hashes demand more coincidences per query yet set more bits; the two effects balance at , where . The bowl is shallow: from to is within a whisker of optimal.
A false positive. Earlier insertions have set bits (shaded). A query for — never inserted — happens to hash to bits , all of which are already , so the filter wrongly reports present. A genuine absent answer would have needed at least one of those bits to be .

The practical reading: the rate falls exponentially in the bits-per-element. About bits per element with hashes gives a false-positive rate; bits and gives — orders of magnitude less than the dozens of bytes per element a hash set storing the keys would need.

Bloom filters are everywhere a cheap, conservative pre-check pays off: a database skips a disk lookup when the filter says a key is absent; a CDN avoids caching a one-hit URL; a spell-checker rejects obvious non-words. In each case the filter's no-false-negative guarantee is what makes it safe — a definitely absent answer can be trusted to short-circuit the expensive exact check, and a maybe present answer simply falls through to that check.

bloom_filter.pypython
from __future__ import annotations

from hashlib import blake2b
from math import ceil, exp, log
from typing import Generic, Hashable, TypeVar

Element = TypeVar("Element", bound=Hashable)

def optimal_size(expected_count: int, false_positive_rate: float) -> int:
  """
    The bit-array size `m` that holds `expected_count` elements at the target\n
    false-positive rate: m = -n * ln(p) / (ln 2)^2, rounded up.\n
  """
  if expected_count <= 0:
    raise ValueError("expected_count must be positive")
  if not 0.0 < false_positive_rate < 1.0:
    raise ValueError("false_positive_rate must be in (0, 1)")

  # m = -n ln(p) / (ln 2)^2, never below one bit.
  size: float = -expected_count * log(false_positive_rate) / (log(2) ** 2)
  return max(1, ceil(size))

def optimal_hash_count(size: int, expected_count: int) -> int:
  """
    The hash count `k` that minimizes the false-positive rate for a given\n
    array size and load: k = (m / n) * ln 2, rounded to the nearest positive\n
    integer.\n
  """
  if expected_count <= 0:
    raise ValueError("expected_count must be positive")

  # k = (m / n) ln 2, at least one hash.
  count: int = round((size / expected_count) * log(2))
  return max(1, count)

class BloomFilter(Generic[Element]):
  """
    A space-efficient probabilistic set with no false negatives.\n
    Built either from explicit (`size`, `hash_count`) or, via\n
    `from_capacity`, from a target capacity and false-positive rate.\n
  """

  def __init__(self, size: int, hash_count: int) -> None:
    """
      A filter over a `size`-bit array probed by `hash_count` hashes.\n
    """
    if size <= 0:
      raise ValueError("size must be positive")
    if hash_count <= 0:
      raise ValueError("hash_count must be positive")

    # back the bit array with enough bytes to cover `size` bits.
    self._size: int = size
    self._hash_count: int = hash_count
    self._bits: bytearray = bytearray((size + 7) // 8)
    self._count: int = 0

  @classmethod
  def from_capacity(
    cls, expected_count: int, false_positive_rate: float = 0.01
  ) -> BloomFilter[Element]:
    """
      Size a filter for `expected_count` elements at `false_positive_rate`,\n
      choosing the array size and hash count that minimize space.\n
    """
    # pick the space-minimizing array size and hash count for the target rate.
    size: int = optimal_size(expected_count, false_positive_rate)
    hash_count: int = optimal_hash_count(size, expected_count)
    return cls(size, hash_count)

  @property
  def size(self) -> int:
    """
      The number of bits in the underlying array.\n
    """
    return self._size

  @property
  def hash_count(self) -> int:
    """
      The number of hash probes per key.\n
    """
    return self._hash_count

  def __len__(self) -> int:
    return self._count

  def _positions(self, element: Element) -> list[int]:
    """
      The `hash_count` bit indices an element maps to. Two independent base\n
      hashes are combined as `base_one + index * base_two` (Kirsch-Mitzenmacher\n
      double hashing), which behaves like `hash_count` independent hashes.\n
    """
    # split one 128-bit digest into two base hashes (second forced odd).
    digest: bytes = blake2b(
      repr(element).encode("utf-8"), digest_size=16
    ).digest()
    base_one: int = int.from_bytes(digest[:8], "big")
    base_two: int = int.from_bytes(digest[8:], "big") | 1

    # combine them into k indices, Kirsch-Mitzenmacher style.
    return [
      (base_one + index * base_two) % self._size
      for index in range(self._hash_count)
    ]

  def add(self, element: Element) -> None:
    """
      Insert `element`, setting each of its `hash_count` bits to 1.\n
    """
    # set each selected bit, then bump the inserted count.
    for position in self._positions(element):
      self._bits[position >> 3] |= 1 << (position & 7)
    self._count += 1

  def __contains__(self, element: Element) -> bool:
    """
      Whether `element` is possibly present. False means definitely absent;\n
      True means present or a false positive.\n
    """
    # any clear bit proves absence; all set means possibly present.
    for position in self._positions(element):
      if not (self._bits[position >> 3] >> (position & 7)) & 1:
        return False
    return True

  def estimated_false_positive_rate(self) -> float:
    """
      The expected false-positive rate at the current load, from the inserted\n
      count `n`, array size `m`, and hash count `k`:\n
      p ~ (1 - e^(-k n / m))^k.\n
    """
    # p ~ (1 - e^(-k n / m))^k.
    exponent: float = -self._hash_count * self._count / self._size
    return (1.0 - exp(exponent)) ** self._hash_count

Where the coin flips run

Both structures are core pieces of widely-used systems, and each has refined descendants.

Skip lists in production. Redis stores its sorted sets (ZSET) as a skip list paired with a hash table, precisely because a skip list gives ordered operations and range queries with far simpler concurrent code than a balanced tree: there are no rotations to coordinate, so lock-free and fine-grained-locking skip lists are practical, which is why concurrent maps (Java's ConcurrentSkipListMap) favor them. The MemSQL/LevelDB-style in-memory memtable is often a skip list for the same reason.

Bloom filters and the LSM tree. The dominant use of Bloom filters today is inside log-structured merge trees (LevelDB, RocksDB, Cassandra): each on-disk table carries a Bloom filter, so a read that would otherwise probe many tables skips the ones whose filter says absent, turning most negative lookups into a few in-memory bit tests. This is the skip a disk lookup case at industrial scale.

Filters past Bloom. The no-deletion limitation and Bloom's cache-unfriendly scattered probes drove better designs. A counting Bloom filter restores deletion; a cuckoo filter (Fan et al., 2014) stores small fingerprints in a cuckoo-hash table, supporting deletion and better locality at the same false- positive rate; a quotient filter is a cache-friendly, mergeable alternative. And for the related question how many distinct items, the same probabilistic spirit gives HyperLogLog, taken up in Data-Stream Algorithms.4

Takeaways

  • Randomization reaches a balanced tree's bounds without invariants or rotations — expected balance from coin flips, far simpler to implement.
  • A skip list layers sorted linked lists; each element rises to the next level with probability . Search rides express lanes top-down, dropping a level on overshoot.
  • Insert is a search that records the update vector (the predecessor on each level), a run of coin flips for the height, then two pointer assignments per level. Delete reverses the splice. No rebalancing, ever.
  • Tower heights are geometric: expected height , so about pointers in total: one extra pointer per node over a plain list.
  • The height is with high probability, and search/insert/delete are all expected , independent of input order.
  • A Bloom filter is a bit array plus hashes. Insert sets bits; query reports present only if all are set.
  • It has no false negatives — a sound definitely-not-present test — but is incomplete as a presence test, with false-positive rate , minimized at .
  • The rate drops exponentially in bits-per-element; the cost is that you cannot delete (without a counting variant).

Footnotes

  1. Skiena, §3.x — Randomized Data Structures: skip lists as a coin-flip alternative to balanced trees with expected operations.
  2. Erickson, Ch. — Randomized Algorithms: analysis of randomized structures, including expectation over internal coin flips rather than over inputs.
  3. CLRS, App. C — Counting and Probability: the union-bound and geometric-variable arguments behind the expected height and search cost.
  4. Pugh, Skip lists: a probabilistic alternative to balanced trees (1990); Fan, Andersen, Kaminsky & Mitzenmacher, Cuckoo filter: practically better than Bloom (2014); the LSM-tree Bloom-filter pattern is standard in LevelDB/RocksDB.
Practice

╌╌ END ╌╌