Skip List

A skip list is a probabilistic ordered structure: a base linked list with multiple "express lane" forwarding pointers above it. Each node is randomly promoted to higher levels, giving O(log n) expected search/insert/delete. Used in Redis sorted sets, LevelDB, and Java's ConcurrentSkipListMap.

Complexity

Average Worst Space
AccessSearchInsertionDeletion AccessSearchInsertionDeletion Worst
Θ(log n) Θ(log n) Θ(log n) Θ(log n) O(n) O(n) O(n) O(n) O(n log n)

How it works

Insert: flip a coin to choose a level (geometric distribution — half of nodes at level 0, a quarter at level 1, etc.). Search: start at the top level, walk forward until the next key is too large, drop down a level, repeat. Each level halves the work, giving expected O(log n).

Python implementation

import random

MAX_LEVEL = 16
P = 0.5


class _Node:
    __slots__ = ("key", "forward")
    def __init__(self, key, level):
        self.key = key
        self.forward = [None] * (level + 1)


class SkipList:
    def __init__(self):
        self.head = _Node(None, MAX_LEVEL)
        self.level = 0

    def _random_level(self):
        lv = 0
        while random.random() < P and lv < MAX_LEVEL:
            lv += 1
        return lv

    def search(self, key):                          # O(log n) expected
        x = self.head
        for i in range(self.level, -1, -1):
            while x.forward[i] and x.forward[i].key < key:
                x = x.forward[i]
        x = x.forward[0]
        return x is not None and x.key == key

    def insert(self, key):                          # O(log n) expected
        update = [self.head] * (MAX_LEVEL + 1)
        x = self.head
        for i in range(self.level, -1, -1):
            while x.forward[i] and x.forward[i].key < key:
                x = x.forward[i]
            update[i] = x
        lv = self._random_level()
        if lv > self.level:
            self.level = lv
        n = _Node(key, lv)
        for i in range(lv + 1):
            n.forward[i] = update[i].forward[i]
            update[i].forward[i] = n


sl = SkipList()
for k in [3, 6, 1, 8, 2, 9, 4]:
    sl.insert(k)
print(sl.search(8))    # True
print(sl.search(7))    # False

Trade-offs

Step-by-Step Search Trace

Search for 8: Start High, Move Right, Drop Down level 3HEND level 2H4END level 1H2469END level 0H1245689END Illustrative levels for keys 1, 2, 4, 5, 6, 8, 9 (blank = node not promoted to that level). Yellow = nodes visited: H to 4 on level 2, 4 to 6 on level 1 (next is 9 > 8, drop), 6 to 8 on level 0. Found after 4 forward moves.

Level 0 is an ordinary sorted linked list containing every key. Each higher level is a sparser sorted list containing a random subset of the level below; on average half of the nodes appear on level 1, a quarter on level 2, and so on. A search starts at the head on the highest level and moves right while the next key is smaller than the target. When the next key is too large or the list ends, it drops one level and continues. Because each level skips about twice as far as the one below, the expected number of moves per level is constant, and there are about log2(n) levels, giving O(log n) expected search.

The analysis is easiest backwards: retracing the search path from the found node to the head, each step either goes up a level (if the node was promoted, probability 1/2) or left. The expected number of left moves before going up is therefore at most 2 per level, so the expected path length is at most about 2 log2(n). The worst case is O(n), when every node happens to land on level 0, but the probability of a significantly longer-than-expected search shrinks exponentially with n.

Insertion and Deletion

Insert: Find Predecessors, Flip Coins, Splice Search pathrecord update[i] Random levelP(level >= k) = 1/2^k New nodelevel + 1 pointers Spliceper level 0..lv update[i] is the last node at level i whose key is smaller than the new key; the new node is linked after it on each of its levels.

Insertion reuses the search: during the descent the algorithm remembers, for every level, the last node it visited before dropping (the update array). It then draws a random level using repeated coin flips, creates a node with that many forward pointers and splices it after update[i] on each level. No rotations or rebalancing are needed; balance is maintained in expectation by randomness alone. Deletion is symmetrical: find the predecessors, unlink the node on every level where it appears, and lower the list's level if the top levels become empty.

def delete(self, key):
    update = [self.head] * (MAX_LEVEL + 1)
    x = self.head
    for i in range(self.level, -1, -1):
        while x.forward[i] and x.forward[i].key < key:
            x = x.forward[i]
        update[i] = x
    x = x.forward[0]
    if x is None or x.key != key:
        return False
    for i in range(len(x.forward)):
        if update[i].forward[i] is x:
            update[i].forward[i] = x.forward[i]
    while self.level > 0 and self.head.forward[self.level] is None:
        self.level -= 1
    return True

def range_query(self, lo, hi):
    """All keys in [lo, hi]: O(log n + k)."""
    x = self.head
    for i in range(self.level, -1, -1):
        while x.forward[i] and x.forward[i].key < lo:
            x = x.forward[i]
    x = x.forward[0]
    while x and x.key <= hi:
        yield x.key
        x = x.forward[0]

SkipList.delete = delete
SkipList.range_query = range_query
print(list(sl.range_query(2, 6)))   # [2, 3, 4, 6]
print(sl.delete(4), sl.search(4))   # True False

Range queries are where skip lists shine compared with hash tables: after an O(log n) descent to the first key in range, the bottom level is a sorted linked list that can be walked in order. This is exactly how Redis serves ZRANGEBYSCORE on sorted sets.

When to Use It

StructureOrderedSearchConcurrencyImplementation
Skip listyesO(log n) expectedlock-free variants are practicalshort
Red-black / AVL treeyesO(log n) worstrotations touch several nodeslonger
Hash tablenoO(1) averagestriped locksshort
B-treeyesO(log n)latch couplinglong, disk-oriented

Choose a skip list for in-memory ordered maps where simplicity or concurrent access matters more than guaranteed worst-case bounds. Insertions only modify the predecessors on each level, which keeps contention local; this is why Java chose a skip list for ConcurrentSkipListMap and why LevelDB and RocksDB use one for their in-memory write buffer (memtable).

Pitfalls

← Back to Algorithms