Probabilistic Express Lanes & The Backwards Search Proof
In a sorted singly linked list, even though the keys are ordered, binary search is impossible because we lack random access. Suppose we construct a hierarchy of linked lists $L_0, L_1, \dots, L_{H}$: level $L_0$ contains all $N$ elements, and each element present in level $L_k$ is independently promoted to level $L_{k+1}$ with probability $p \in (0, 1)$ (typically $p = 1/2$ or $p = 1/4$). Search starts at the highest level of the sentinel head node: at the current node $u$ and level $k$, we advance horizontally (u = u->forward[k]) as long as u->forward[k]->key < target; when the next key would overshoot target (or is nullptr), we drop down vertically to level $k - 1$.
How do we prove that the expected search cost is $O(\log N)$? Follow Pugh's elegant Backwards Analysis: trace the search path in reverse, starting from the found node at level $0$ and climbing up and left back to the sentinel head at level $H$. At any node on the reverse path at level $k$:
- Did we arrive at this node by dropping down from level $k+1$? That happens if and only if this node's tower was promoted to level $k+1$, which occurs with probability $p$ (we climb UP 1 level).
- Otherwise, with probability $1 - p$, this node's tower stopped at level $k$, so the forward search must have reached it horizontally from the left (we step LEFT 1 node at level $k$).
Let $C(K)$ be the expected number of reverse steps required to climb $K$ levels. In one step ($+1$), with probability $p$ we climb up to $K - 1$ remaining levels, and with probability $1 - p$ we move left and still have $K$ levels to climb:
$$C(K) = 1 + p \cdot C(K - 1) + (1 - p) \cdot C(K) \implies p \cdot C(K) = 1 + p \cdot C(K - 1) \implies C(K) = \frac{K}{p}$$
Since the expected number of nodes at level $L = \log_{1/p} N$ is $N \cdot p^L = 1$, setting $K = \log_{1/p} N$ yields an expected search path length of $\frac{\log_{1/p} N}{p} = O(\log N)$! Meanwhile, the number of forward pointers in a node's tower follows a Geometric($1 - p$) distribution with expected height:
$$\mathbb{E}[\text{height}] = \sum_{k=0}^{\infty} p^k = \frac{1}{1 - p}$$
For $p = 1/2$, each node stores on average $\frac{1}{1 - 1/2} = 2.0$ pointers. For $p = 1/4$ (used by Redis zskiplist and LevelDB / RocksDB MemTable), each node stores on average only $\frac{1}{1 - 1/4} = 1.333$ pointers—less pointer overhead than a binary tree ($2.0$ pointers/node)!
Skip List Parameter Trade-Offs ($p = 1/2$ vs. $p = 1/4$ vs. Balanced BSTs)
| Structure | Avg Pointers / Node | Expected Comparisons | Insert Rebalancing | Lock-Free Concurrent Insert |
|---|---|---|---|---|
| Skip List ($p = 1/2$) | $2.00$ pointers | $2 \log_2 N$ | Local pointer splice (0 rotations) | Trivial bottom-up atomic CAS |
| Skip List ($p = 1/4$, Redis / RocksDB) | $1.33$ pointers | $4 \log_4 N = 2 \log_2 N$ | Local pointer splice (0 rotations) | Trivial bottom-up atomic CAS |
| Red-Black / AVL Tree | $2.00 - 3.00$ pointers (+ color/parent) | $\approx 1.0 - 1.44 \log_2 N$ | Up to $O(\log N)$ tree rotations | Extremely hard (multi-word rotation locks) |
| B+Tree (Fanout $B = 16$) | $\approx 1.1$ per key | $\log_2 N$ (in-cache) | Node split/merge | Latch crabbing / Bw-Tree |
Visualizing Express Lanes & The update[0..level] Predecessor Splice Vector
Why RocksDB & Java ConcurrentSkipListMap Use Skip Lists Over Red-Black Trees
RocksDB MemTable"] --> B["Red-Black Tree:
Insert triggers bottom-up rotations
Mutates root/grandparent pointers
Requires coarse subtree locks"] A --> C["Lock-Free Skip List:
Node height chosen via PRNG
Link Level 0 via atomic CAS first!
Then link higher levels 1..h via CAS"]
forward[]) & Single-Allocation TowersA naive Skip List implementation defines struct Node { int key; std::vector<Node*> forward; }. That design is a cache disaster: every node incurs two separate heap allocations (one for the Node and one for the std::vector's dynamic buffer), and stepping u->forward[k] requires an extra pointer dereference to reach the vector's heap array! Production Skip Lists (such as Redis's zskiplistNode and LevelDB's SkipList::Node) allocate the Node header and its exact $h$-element Node* forward[h] array in a single contiguous block of memory of size sizeof(Node) + h * sizeof(Node*) using a C99 flexible array member (or placement new), keeping the key and its tower pointers in the same 64-byte cache line.
Traverse multi-level express towers from top level L_max down to L_0. Move right while next.key < target, otherwise drop down one level — achieving O(log N) search without tree rotations.
Invariant & State
Production Implementation: Single-Allocation Skip List with $p = 1/4$ Geometric PRNG
#include <array>
#include <cstdint>
#include <cstdlib>
#include <new>
class SkipList {
static constexpr int MAX_LEVEL = 16; // Supports up to 4^16 = 4.29B elements
struct Node {
int key;
int value;
int top_level;
Node* forward[1]; // Flexible trailing array allocated to size top_level + 1
};
Node* head_;
int cur_max_level_ = 0;
std::uint64_t rng_state_ = 0x853c49e6748fea9bULL;
static Node* allocate_node(int key, int val, int level) {
const std::size_t bytes = sizeof(Node) + static_cast<std::size_t>(level) * sizeof(Node*);
void* raw = ::operator new(bytes);
Node* node = static_cast<Node*>(raw);
node->key = key;
node->value = val;
node->top_level = level;
for (int i = 0; i <= level; ++i) node->forward[i] = nullptr;
return node;
}
// Fast xorshift64* PRNG sampling Geometric(p = 1/4) height
int random_level() noexcept {
int lvl = 0;
while (lvl < MAX_LEVEL - 1) {
rng_state_ ^= rng_state_ >> 12;
rng_state_ ^= rng_state_ << 25;
rng_state_ ^= rng_state_ >> 27;
if (((rng_state_ * 0x2545F4914F6CDD1DULL) >> 62) != 0) break; // 1/4 probability
++lvl;
}
return lvl;
}
public:
SkipList() {
head_ = allocate_node(-2147483648, 0, MAX_LEVEL - 1);
}
const int* find(int target) const noexcept {
const Node* curr = head_;
for (int i = cur_max_level_; i >= 0; --i) {
while (curr->forward[i] != nullptr && curr->forward[i]->key < target) {
curr = curr->forward[i];
}
}
curr = curr->forward[0];
if (curr != nullptr && curr->key == target) return &curr->value;
return nullptr;
}
void insert(int key, int value) {
std::array<Node*, MAX_LEVEL> update{};
Node* curr = head_;
for (int i = cur_max_level_; i >= 0; --i) {
while (curr->forward[i] != nullptr && curr->forward[i]->key < key) {
curr = curr->forward[i];
}
update[i] = curr;
}
curr = curr->forward[0];
if (curr != nullptr && curr->key == key) {
curr->value = value;
return;
}
const int new_lvl = random_level();
if (new_lvl > cur_max_level_) {
for (int i = cur_max_level_ + 1; i <= new_lvl; ++i) {
update[i] = head_;
}
cur_max_level_ = new_lvl;
}
Node* inserted = allocate_node(key, value, new_lvl);
for (int i = 0; i <= new_lvl; ++i) {
inserted->forward[i] = update[i]->forward[i];
update[i]->forward[i] = inserted;
}
}
};
MAX_LEVEL & Adversarial Seed AttacksNever allow random_level() to grow unboundedly without a hard cap MAX_LEVEL (e.g., 16 or 32), or a lucky streak of coin flips on a 10-element list can create a 25-level tower where 20 levels contain only a single node—wasting vertical steps on every query. Furthermore, if you use a fixed deterministic PRNG seed while exposing a Skip List to untrusted external keys, an adversary who knows your PRNG sequence can time insertions so that high towers are assigned to useless keys and level-0 towers are assigned to hot keys; always seed the level generator from hardware entropy (getrandom / std::random_device).