Skip Lists

Imagine building a database index. It must support fast search, insertion, and deletion. It also needs to stay simple to implement correctly under deadline pressure. Balanced trees like red-black trees or AVL trees solve this problem. But their rebalancing logic — rotations, color flips, height bookkeeping — is notoriously fiddly to get right. Skip lists solve the same problem with a strikingly different idea. Instead of enforcing balance through deterministic rules, let a coin flip decide the structure. Trust probability to keep things balanced on average. A skip list is a linked structure organized into multiple levels. Each level is a sorted linked list. Higher levels skip over more elements, acting like an express lane over the base list. Searching starts at the top level. It moves right as far as possible without overshooting. Then it drops down a level and repeats. This is much like using highway exits to approach a destination before switching to local streets. This gives expected O(log n) time for search, insertion, and deletion. It matches balanced trees in expectation. It uses only simple pointer operations with no rotations at all. Skip lists matter because randomization can replace complicated invariants with simple probabilistic guarantees. These guarantees hold with overwhelming likelihood. They are used in real production systems. Redis uses skip lists to implement its sorted-set (`ZSET`) data type. LevelDB and RocksDB rely on skip lists for their in-memory memtables. Their appeal is code simplicity combined with strong average-case performance. This comes at the cost of a small but nonzero chance of poor structure on any given run. This article builds skip lists from the ground up. It covers the level structure and search invariant. It covers the randomized level-assignment mechanism. It provides full pseudocode for search, insert, and delete. It gives a rigorous probabilistic analysis of expected height and search cost. It includes a worked trace through a concrete list. It also covers the pitfalls that trip up naive implementations. This is especially true around level generation and pointer maintenance during deletion.

Key takeaways
  • A skip list is a sorted linked list augmented with extra "express lane" levels built by random coin flips.
  • Search, insert, and delete all run in expected O(log n) time and O(n) expected space overall.
  • Each node's height is chosen randomly (geometric distribution), so no explicit rebalancing is ever needed.
  • Search proceeds top-down and right-to-left-bounded: move right while safe, else drop a level.
  • The expected number of levels is O(log n), and expected comparisons per search is O(log n) by a backwards analysis.
  • Real systems (Redis `ZSET`, LevelDB/RocksDB memtables) use skip lists because they are simple, concurrent-friendly, and fast in expectation.
The core insight

Randomized level assignment turns "keep the structure balanced" into "let a coin flip keep it balanced on average," trading deterministic rebalancing for expected O(log n) operations with simple code.

The idea

A skip list is built from a base sorted linked list containing all elements, plus a hierarchy of additional sorted linked lists layered above it. Level 0 contains every element. Level 1 contains a random subset of the elements in level 0. Level 2 contains a random subset of level 1's elements, and so on. Each element that appears in level ii is linked to its neighbors in level ii via a "forward pointer" at that level, so a single node in memory can have several outgoing pointers — one per level it participates in. The topmost levels are sparse, "skipping" over many base-level nodes in a single hop, which is exactly why the structure supports fast search. The height of a node — how many levels it appears in — is decided independently at random when the node is inserted, typically via repeated fair (or biased) coin flips: start at level 1, and with probability pp promote to the next level, continuing until a flip fails. This process gives each node a height following a geometric distribution. No global rebalancing step ever runs; the randomness of insertion order and coin flips is what keeps the level structure statistically well-shaped, in the same probabilistic spirit as a randomized priority queue implementation such as a randomized heap. The key structural invariant, maintained automatically by construction, is: for every level i>0i > 0, level ii is a sorted sublist of level i−1i-1, and level 0 contains all nn elements in sorted order. This invariant is what makes the "drop down when you can't go further right" search strategy correct — dropping a level never skips past an element, because every element on a higher level is also present, in the same order, on every level below it.

How it works

Search, insertion, and deletion all share the same core traversal pattern, often called the search fingerpath: starting from the highest level of a special header node, repeatedly move right along the current level while the next node's key is less than the target, and drop down a level whenever moving right would overshoot or hit the end. This traversal touches at most one "wrong turn" per level in expectation, which is the source of the logarithmic bound.

Search. Begin at the top-left corner (the header at the highest level). At each level, scan rightward as long as the next node's key is strictly less than the target key. When the next node's key would be ≥\geq target, drop to the next level down from the current node, and repeat. When level 0 is reached and the immediate right neighbor's key equals the target, the search succeeds; otherwise the target is absent.

Insertion. First perform a search, but remember at every level the last node visited before dropping down — these are the update pointers, one per level, indicating exactly where a new forward link must be spliced in at that level. Then flip coins to decide the new node's height hh. Create the new node with hh forward pointers, and splice it into levels 11 through hh using the remembered update pointers. If hh exceeds the current maximum level of the list, extend the header with new empty levels first.

Deletion. Perform the same search-with-update-pointers procedure. If the target is found at level 0, remove it from every level in which it appears, by relinking each update pointer to skip over the deleted node. Optionally trim trailing empty top levels from the header afterward. ``` // Node: key, and an array forward[1..height] // header: a sentinel node with forward pointers up to maxLevel, key = -infinity // maxLevel: current highest level in use // p: promotion probability (typically 0.5), maxPossibleLevel: hard cap (e.g. log_(1/p) n) function randomLevel(): lvl = 1 while random() < p and lvl < maxPossibleLevel: lvl = lvl + 1 return lvl function search(list, targetKey): x = list.header for i = list.maxLevel downto 1: while x.forward[i] != NIL and x.forward[i].key < targetKey: x = x.forward[i] // invariant: x.key < targetKey <= x.forward[i].key at this level x = x.forward[1] if x != NIL and x.key == targetKey: return x else: return NOT_FOUND function insert(list, key, value): update = array of size maxPossibleLevel, all pointing to list.header x = list.header for i = list.maxLevel downto 1: while x.forward[i] != NIL and x.forward[i].key < key: x = x.forward[i] update[i] = x x = x.forward[1] if x != NIL and x.key == key: x.value = value // key already present: update in place return newLevel = randomLevel() if newLevel > list.maxLevel: for i = list.maxLevel + 1 to newLevel: update[i] = list.header list.maxLevel = newLevel newNode = makeNode(key, value, height = newLevel) for i = 1 to newLevel: newNode.forward[i] = update[i].forward[i] update[i].forward[i] = newNode function delete(list, key): update = array of size maxPossibleLevel, all pointing to list.header x = list.header for i = list.maxLevel downto 1: while x.forward[i] != NIL and x.forward[i].key < key: x = x.forward[i] update[i] = x x = x.forward[1] if x == NIL or x.key != key: return NOT_FOUND for i = 1 to list.maxLevel: if update[i].forward[i] != x: break // x does not appear on higher levels; stop early update[i].forward[i] = x.forward[i] while list.maxLevel > 1 and list.header.forward[list.maxLevel] == NIL: list.maxLevel = list.maxLevel - 1 // trim empty top levels ```

Figure
A skip list with four levels; dashed arrows show a search path for key 50
L4 L3 L2 L1 (base) H H H H 10 20 30 40 50 60 70 90 20 40 60 90 40 90 90

Complexity & correctness

Search invariant. At the start of every iteration of the outer loop in `search`, the current pointer xx satisfies x.key<targetKeyx.key < \text{targetKey}, and xx is the rightmost node at level ii with this property among nodes visited so far. This invariant is trivially true at the header (whose key is −∞-\infty) and is preserved by the inner while-loop, which only advances while the next key is still less than the target. When the loop drops to level i−1i-1, the invariant transfers because level i−1i-1 is a superset of level ii's elements in the same sorted order — dropping down cannot skip any smaller key, since any node present at level ii is also present at level i−1i-1 in the same relative position. By induction down to level 1, when the loop terminates, xx is the last node with key less than the target, so x.forward[1]x.forward[1] is the only candidate that could equal the target. This proves correctness of search, and insertion/deletion inherit it directly since they run the identical traversal to locate splice points.

Expected height and space. Each node's height is 1+Geometric(1−p)1 + \text{Geometric}(1-p) truncated at maxPossibleLevel\text{maxPossibleLevel} (ignoring the cap for the moment), so a node reaches level ii independently with probability pi−1p^{i-1}. The expected number of nodes present at level ii is n⋅pi−1n \cdot p^{i-1}. The expected total number of forward pointers across all nodes is

∑i=1∞n pi−1=n1−p=O(n),\sum_{i=1}^{\infty} n \, p^{i-1} = \frac{n}{1-p} = O(n),

so expected space is O(n)O(n) regardless of pp (with constant factor 11−p\frac{1}{1-p}; at p=1/2p = 1/2 this is 2n2n). The expected height of the tallest node, and hence the expected number of levels actually used, is O(log⁡1/pn)O(\log_{1/p} n): the probability that any specific node reaches level kk is pk−1p^{k-1}, so by a union bound over nn nodes, the probability that some node reaches level clog⁡1/pnc \log_{1/p} n is at most n⋅pclog⁡1/pn−1=O(n1−c)n \cdot p^{c \log_{1/p} n - 1} = O(n^{1-c}), which vanishes for c>1c > 1. This is why capping `maxPossibleLevel` at roughly log⁡1/pn\log_{1/p} n costs essentially nothing in practice.

Expected search time — the backward analysis. The classical argument (due to Pugh) analyzes the search path in reverse: start at the node found at level 1 and walk backward toward the header, tracking how many levels have been climbed. At any point during this backward walk, the number of additional backward steps taken at the current level before climbing to the next level up is dominated by a geometric random variable with parameter pp (since climbing a level happens exactly when a coin flip "promotes" the current node, an event with probability pp independent of history). Since climbing occurs at most O(log⁡1/pn)O(\log_{1/p} n) times before reaching the maximum level (by the height bound above), and each level contributes an expected constant 1p\frac{1}{p} number of backward steps, the total expected path length is

O ⁣(log⁡1/pn)⋅O ⁣(1p)=O(log⁡n)O\!\left(\log_{1/p} n\right) \cdot O\!\left(\frac{1}{p}\right) = O(\log n)

for constant pp. This gives expected O(log⁡n)O(\log n) time for search, and since insertion and deletion perform one search plus O(1)O(1) expected splice work per level touched, they are also expected O(log⁡n)O(\log n).

Honest tradeoffs. These bounds are expected, not worst-case: an adversarial (or simply unlucky) sequence of coin flips can produce a degenerate list where every node has height 1, collapsing search to Θ(n)\Theta(n). This happens with exponentially small probability, but it is not impossible, unlike the strict worst-case guarantees of AVL or red-black trees. In exchange, skip lists avoid all rotation logic, support easy concurrent lock-free implementations (because insertion only needs to splice pointers locally, not rebalance globally), and make order-statistics operations (like "find the kk-th smallest element") easy to add by augmenting each forward pointer with a span count. This last technique is exactly how Redis implements the `ZRANK` command on sorted sets in O(log⁡n)O(\log n).

Worked example

Consider inserting the keys `10, 30, 20, 40, 60, 90, 70, 50` one at a time into an initially empty skip list with p=0.5p = 0.5, using the (illustrative, fixed for reproducibility) coin-flip outcomes below. "H,H,T" means: flip succeeds (promote), succeeds again, then fails, giving height 3.

Insert keyCoin flips (promote=H)Resulting heightLevels it joins
10T1L1
30T1L1
20H, H, T3L1, L2, L3
40H, H, T3L1, L2, L3
60H, T2L1, L2
90H, H, H, T4L1, L2, L3, L4
70T1L1
50T1L1

After all eight insertions, the list matches the structure shown in the figure above: level 1 has all eight keys, level 2 has {20, 40, 60, 90}, level 3 has {20, 40, 90}, and level 4 has {90}. (The figure's level 3 shows {40, 90} for compactness — either arrangement is a valid random outcome; what matters is that the invariant "each level is a sorted sublist of the one below" holds throughout.) Now trace `search(50)` on the figure's structure: 1. Level 4, at header: `forward[4]` points to 90. Is 90<5090 < 50? No. Drop to level 3, staying at the header. 2. Level 3, at header: `forward[3]` points to 40. Is 40<5040 < 50? Yes, advance to node 40. From 40, `forward[3]` points to 90. Is 90<5090 < 50? No. Drop to level 2, staying at node 40. 3. Level 2, at node 40: `forward[2]` points to 60. Is 60<5060 < 50? No. Drop to level 1, staying at node 40. 4. Level 1, at node 40: `forward[1]` points to 50. Is 50<5050 < 50? No (not strictly less). Drop is not possible (already at level 1), so the outer loop ends with x=x = node 40. 5. Check `x.forward[1]`: it is node 50, whose key equals the target. Search succeeds, returning node 50. Total comparisons: 1 (level 4) + 2 (level 3) + 1 (level 2) + 1 (level 1) = 5 comparisons to search among 8 elements — consistent with the O(log⁡n)O(\log n) bound (log⁡28=3\log_2 8 = 3, and small constants dominate at this tiny scale). Inserting a new key, say 45, would perform this same traversal, record update pointers at each level (header at L4 and L3-after-40, node 40 at L2, node 40 at L1), flip coins for a new height, and splice the new node into exactly those recorded slots.

Common pitfalls & traps

PitfallWhy it happensFix
Off-by-one in `randomLevel`Forgetting the base level (starting count at 0 instead of 1) undercounts height everywhereAlways start `lvl = 1` before any coin flip, since every node exists at level 1
Forgetting to extend header on new max levelNew node has more levels than `maxLevel`, but header lacks forward pointers thereDetect `newLevel > maxLevel`, extend header's array and set `update[i] = header` for the new levels first
Deleting without updating every levelOnly unlinking at level 1 leaves dangling pointers at higher levels pointing to freed memoryWalk update pointers top-down and unlink at every level where the node actually appears, stopping early once `update[i].forward[i] != x`

< /table> | Not capping `randomLevel` | Height is technically unbounded (geometric distributions have infinite support) | Cap at O(log⁡n)O(\log n) in practice; astronomically tall nodes are vanishingly unlikely but must not overflow arrays | | Comparing with `<=` instead of `<` in search | Causes the traversal to stop one node too early or too late, breaking the "rightmost node less than target" invariant | Use strict `<` consistently when deciding whether to advance; use equality only for the final level-1 check | | Treating expected time as worst-case in latency-sensitive code | A rare bad coin sequence degrades to Θ(n)\Theta(n), which can violate real-time guarantees | Acknowledge in SLAs that skip lists give expected, not guaranteed, bounds; use a balanced tree if hard worst-case latency is required |

Pioneers & history

Skip lists were invented as a deliberately simple alternative to balanced binary search trees, motivated by the observation that many balanced-tree implementations in production code were riddled with subtle rotation bugs. The design goal was explicit: get the same asymptotic guarantees as red-black trees or AVL trees, in expectation, using an algorithm simple enough that a competent programmer could implement it correctly on the first try, without needing to reason about rotations, rebalancing invariants, or tree-height bookkeeping.

Origins · 1 minWilliam Pugh and how this was discovered
WP
William Pugh
1960s–present · Computer scientist at the University of Maryland, inventor of skip lists

William Pugh introduced skip lists in 1989 as a randomized alternative to balanced trees, explicitly aiming for simplicity of implementation alongside strong probabilistic performance guarantees. His original paper framed the structure as a linked-list analog of a balanced tree, using independent coin flips instead of rotations to maintain approximate balance. Pugh later became well known in the programming languages and static analysis community, including work on the FindBugs tool for Java.

History
- 1989: William Pugh publishes "Skip Lists: A Probabilistic Alternative to Balanced Trees," introducing the structure and its analysis.
- 1990s: Skip lists appear in textbooks and libraries as a teaching example of randomized data structures, alongside treaps and randomized quicksort.
- 2000s: Concurrent and lock-free skip list variants are developed, exploiting the fact that insertion only requires local pointer splicing rather than global rebalancing.
- 2009: Redis adopts skip lists as the backing structure for its sorted-set (`ZSET`) type, citing implementation simplicity and good average performance.
- 2010s–present: LevelDB and its descendant RocksDB use skip lists for in-memory memtables, valued for cheap concurrent reads during writes.

Where this matters

Skip lists are a working part of production infrastructure, not merely a textbook curiosity. Redis implements its sorted-set type (`ZADD`, `ZRANGE`, `ZRANK`) with an augmented skip list that stores span counts on forward pointers, enabling both range queries and rank queries in expected O(log⁡n)O(\log n) time. LevelDB and RocksDB, the storage engines underlying many key-value stores and used inside systems like CockroachDB, Cassandra's variants, and Bitcoin Core's on-disk indexing, use skip lists for their memtables — the in-memory buffer that absorbs writes before they are flushed to sorted files on disk — because skip lists tolerate concurrent readers during a single writer's insertion without complex locking. Concurrent and lock-free skip list variants are used in some Java concurrency libraries (`ConcurrentSkipListMap` and `ConcurrentSkipListSet` in `java.util.concurrent`) precisely because splicing a pointer locally is far easier to make lock-free than rebalancing a tree. Skip lists also serve as a standard teaching bridge between Linked Lists and more advanced balanced structures, and their level-count-by-coin-flip idea recurs in other randomized structures built on similar principles to a randomized priority queue.

Ask the tutor: How would you augment a skip list's forward pointers with span counts to answer "what is the rank of key k?" in expected O(log n) time?

Why use randomization instead of a deterministic balancing rule like in AVL trees?

Randomization replaces the need for complex, error-prone rebalancing logic (rotations, height tracking, color invariants) with a simple probabilistic guarantee. Deterministic balanced trees must maintain strict invariants after every insertion or deletion, requiring careful case analysis. A skip list instead relies on the statistical fact that random coin flips, applied independently to many nodes, produce a well-shaped level hierarchy with overwhelming probability. The tradeoff is that guarantees become "expected" rather than "worst-case," but in exchange the implementation is dramatically simpler, easier to make concurrent, and empirically very fast, which is why systems like Redis chose it over a balanced tree.

What happens if the promotion probability p is set very close to 1?

As p→1p \to 1, nodes are promoted to higher levels almost every time, so the expected height of the tallest node grows large (recall the height bound scales as log⁡1/pn\log_{1/p} n, which blows up as p→1p \to 1), and the expected total space, n/(1−p)n/(1-p), also grows unboundedly. This wastes memory on excessive levels without proportionally speeding up search, since most of those levels barely thin out the node count from the level below. Conversely, very small pp produces very few levels, meaning high levels barely skip ahead, so search degrades toward linear. The value p=1/2p = 1/2 or p=1/4p = 1/4 are common practical choices balancing space overhead against search speed.

How does insertion decide exactly where to splice a new node's pointers?

Insertion first performs a search for the key, but instead of discarding the traversal, it records an "update" pointer at every level: the last node visited on that level before dropping down. These update pointers mark precisely where the new node should be spliced in on each level it will occupy, because they are the rightmost node with a key less than the new key at that level. Once the new node's random height is determined, its forward pointers are set to whatever the corresponding update node currently points to, and each update node's forward pointer is redirected to the new node — an O(1)O(1) splice per level, given the update array.

Can skip lists support operations like "find the k-th smallest element" efficiently?

Yes, with an augmentation technique: attach a "span" to each forward pointer, recording how many level-0 elements that pointer skips over. During search or insertion, spans are accumulated as the traversal moves right, giving the rank of any node reached so far. This lets rank-based queries, like finding the kk-th smallest element or computing an element's rank, run in expected O(log⁡n)O(\log n) time, exactly the technique Redis uses to implement `ZRANK` on its sorted sets. This mirrors the augmented-tree technique used for order-statistics trees, but with simpler local bookkeeping during insertion since spans only need to be updated along the search path.

Are skip lists thread-safe or usable in concurrent settings without heavy locking?

Skip lists are notably friendly to concurrent access compared to balanced trees, because insertion and deletion only require local pointer splices rather than global rebalancing operations that could touch arbitrarily many nodes. This locality makes lock-free skip list variants practical: a thread can often complete an insertion using a small number of compare-and-swap operations confined to the affected nodes' pointers. This is precisely why Java's `java.util.concurrent.ConcurrentSkipListMap` uses a skip list internally rather than a balanced tree, since implementing a correct lock-free balanced tree with rotations is substantially harder.

How does the expected O(log n) bound break down mathematically?

The bound comes from two facts working together. First, the expected height of the tallest node among nn independently randomized nodes is O(log⁡1/pn)O(\log_{1/p} n), shown by a union bound over the probability any single node reaches an extreme height. Second, a backward analysis of the search path shows that, at each level actually visited, the expected number of backward steps before climbing to the next level is a constant, 1/p1/p, because climbing corresponds to a Bernoulli success with probability pp. Multiplying the expected number of levels by the expected constant work per level gives expected O(log⁡n)O(\log n) total comparisons, for any fixed constant pp.

Why do Redis and LevelDB prefer skip lists over B-trees or hash tables for their use cases?

Redis needs sorted-set semantics — ordered iteration, range queries, and rank queries — which skip lists support naturally via their sorted levels and span augmentation, while implementation complexity stays low. LevelDB and RocksDB need an in-memory sorted structure (the memtable) that supports concurrent reads while a single writer inserts, and skip lists' local-splice insertion makes this far easier to implement correctly and efficiently than a balanced tree or B-tree with complex rebalancing. Hash tables, by contrast, do not preserve order at all, ruling them out whenever range queries or ordered iteration are required, which both Redis sorted sets and LSM-tree memtables need.

Before this