DSA
›Skip List
Active

Skip List

Skip List - The simple probabilistic data structure
Source Code
Minh-Tri Le
Minh-Tri Le
Sep 2026 · 7 min read

I have a passion for storage engine design and I usually like to clone my own version like Redis, LevelDB, ... These projects require knowledge about awesome data structures and Skip List is not an exception. That's the reason why I built my own Skip List in C++ here (Of course implemented without AI, but not the unit tests, LMAO, please give it a star!) and I want to write a blog to share what I've learned. Let's read and increase your knowledge!

01Introduction

The problem is: "How can you perform a O(log(n)) search, insert and delete without tree rotation (which can be faster but more complex as well)"? Skip List was created to support this case by William Pugh in 1989. You can reference this paper: Skip Lists: A Probabilistic Alternative to Balanced Trees (the linked PDF is the CACM version, published June 1990).

It is now commonly used in well-known applications:

•
Redis (ZSET)
•
LevelDB/RocksDB MemTable
•
...

02Why sorted linked list isn't enough?

If the objective is just a simple search data structure, only a sorted linked list is enough, right? But there are several drawbacks that need to be considered carefully.

•
Sorted array: O(log(n)) search by binary search, but O(n) insert and delete, because everything after the insertion point has to shift
•
Sorted linked list: O(1) splice once you are already at the right node, but O(n) to get there - no random access means no binary search
•
Balanced BSTs(AVL, Red-Black): O(log(n)) everything, but the complex code makes it painful and hard to implement a lock-free mechanism
•
Motivation: Can we keep the sorted linked list, but add "shortcuts"

That's the scenario "Skip List" was created for.

03The core idea: Express Lanes

Let's think of Skip List basically as an express lane visualized below, where local trains stop at every station (lane with level 0) and express trains skip stations (level 1+).

Searching and Inserting = ride the express as far as you can and drop down a level, repeatedly.

Skip List analogy

04Structure definition

•
Node: key and an array next[0...level] of next pointers - so a node sitting at level L owns L + 1 lanes
•
Header/sentinel node with max level pointers
•
MAX_LEVEL - indicates the max level, which is the maximum number of express lanes; Redis uses 32, the real C++ caps at 17, because at P = 1/4 seventeen lanes already reach ~4^16 (~4 billion) elements and a taller header buys nothing past that
•
P - probability of promoting a node to the next level (0.5 or 0.25 typically, meaning the next level contains two or four times fewer elements) => Trade-off: P smaller => fewer lanes, less memory, but less efficient search/insert

pseudocode

// Convention for every block below: a level is a 0-based lane INDEX, never a // lane count, and every "for i = a to b" range is INCLUSIVE of b. Mixing the // two is the single easiest way to break a skip list. struct Node { K key V value int level // this node's top lane index Node* next[] // lanes 0..level, so length = level + 1 } struct SkipList { Node* header int level // index of the highest lane currently in use float P int MAX_LEVEL }

See the real C++ Node in skip_list_impl.h and the SkipList surface in skiplist.h - they live in separate files, because the public class is a pimpl holding nothing but a unique_ptr to a private Impl. It's like concept of private implementation

That next[] in the pseudocode is literal, and it is the one place the C++ stops looking like C++. A node is a single allocation: the code asks for sizeof(Node) + sizeof(Node*) × level bytes and placement-news the value into the front of it, so the value, the height and every lane pointer share one block and there is exactly one operator new per element. Level is a std::uint8_t for the same reason - it sits in every node, and a lane index never exceeds MAX_LEVEL - 1, so for a small T a wider counter would cost more per element than the value does.

05Operations (with pseudocode)

5.1Random level

The whole idea of this data structure is how to define a sufficient level-randomisation algorithm for all cases. So, how do we determine the level of the inserted node? In most cases, we use a simple probability function utilizing P and MAX_LEVEL to determine it. Pseudocode below:

pseudocode

PROMOTE_THRESHOLD = P × 2^32 // precomputed; for P = 1/4 this is 2^30 function fastRand(n): return (n × 1664525 + 1013904223) mod 2^32 // Returns a 0-based top INDEX, not a lane count. Returning 0 is the common // case - it happens with probability 1 - P, and it means "lane 0 only". function randomLevel(rnd): // rnd passed by reference, carries state level = 0 rnd = fastRand(rnd) while rnd < PROMOTE_THRESHOLD and level < MAX_LEVEL - 1: level = level + 1 rnd = fastRand(rnd) return level

One line the pseudocode doesn't show is how rnd is seeded, and it matters more than it looks. The O(log(n)) guarantee is probabilistic, so it rests on whoever controls the keys not knowing the coin flips - and an LCG's state is exactly its last output, so two observed levels recover the whole sequence. Seed it per process from something non-guessable (std::random_device, getrandom(2)), never from a constant.

See the real C++ random level definition in util.h

5.2Insert

Skip List insert

General idea:

•
Emphasize the update[] array - the predecessors of inserting position
•
No rotation, no rebalancing. That's the whole point

pseudocode

insert(key, value): update[0..MAX_LEVEL-1] = header // never leave a predecessor unset x = header for i = level down to 0: while x.next[i] != null and x.next[i].key < key: x = x.next[i] update[i] = x // once per level, AFTER the while x = x.next[0] if x != null and x.key == key: x.value = value; return lvl = randomLevel() // top lane index of the new node if lvl > level: for i = level+1 to lvl: update[i] = header level = lvl newNode = Node(key, value, lvl) // next[] has lvl + 1 lanes for i = 0 to lvl: // inclusive - lane lvl is a real lane newNode.next[i] = update[i].next[i] update[i].next[i] = newNode

Another line the pseudocode doesn't show is that for i = level down to 0 cannot be written that way once Level is unsigned: level >= 0 is always true, and the decrement past 0 wraps to 255 rather than ending the loop.

See the real C++ SkipList insert definition in skip_list_impl.h. The update[] walk itself is factored out into descend(), since delete needs the identical loop - one copy, so the two operations cannot drift apart in the one place a skip list is easiest to get wrong.

5.3Delete

Perform a loop like Inserting and delete the pointer after updating the pointers array:

pseudocode

delete(key): build update[] exactly as in insert x = x.next[0] if x == null or x.key != key: return false top = min(x.level, level) for i = 0 to top: if update[i].next[i] != x: break update[i].next[i] = x.next[i] free(x) while level > 0 and header.next[level] == null: level -= 1 return true

That free is safe under a single writer with no concurrent readers, and the real C++ now does precisely that - one Node::destroy after the unlink loop, which runs the destructor and hands the block back. A lock-free skip list has to defer reclamation (hazard pointers, epoch-based reclamation, RCU) rather than freeing inline, because a reader may still be sitting on x while you unlink it.

See the real C++ SkipList delete definition in skip_list_impl.h

5.4Search

Skip List search

Walk through an example trace step by step in the GIF above

pseudocode

x = header for i = level down to 0: while x.next[i] != null and x.next[i].key < key: x = x.next[i] x = x.next[0] if x != null and x.key == key: return x.value return NOT_FOUND

See the real C++ SkipList search definition in skip_list_impl.h

The descent above is lowerBound - the first element not ordered before the value - and find is that plus a single check: the candidate is the value exactly when the value is not ordered before it either, so a membership test reads find(v) != end(). And the walk is free: lane 0 is still a plain sorted linked list, so an iterator is a node pointer and ++ is link(0).

06Complexity analysis

OperationsAverageWorst case
SearchO(log(n))O(n)
InsertO(log(n))O(n)
DeleteO(log(n))O(n)
SpaceO(n) (expected)O(n x MAX_LEVEL)

Why O(log(n))?

•
Each level keeps a P fraction of the elements of the one below it. With P = 1/4, a million nodes becomes 250k, then 60k, and then 15k ... Dividing by a constant (typically 4) until you reach the result in O(log(n)) steps
•
For space, the expected number of pointers per node is 1/(1-P) = 1.33 at P = 1/4, which is where the O(n) expected space comes from - and that is measured, not just derived: counting live heap bytes in the C++ gives 18.67 bytes and one allocation per element, which is a 16-byte node for an int (value, height and first lane, rounded up for alignment) plus 8 bytes for each of those 0.33 extra lanes. The worst case is O(n x MAX_LEVEL), not O(n x log(n)): MAX_LEVEL is a fixed cap, so no node can own more lanes than that no matter how the flips land

Why O(n) in worst case?

•
It requires every coin flip (randomLevel function) to perform the same way - millions of nodes in a row all landing on lane 0, with no express lane above them, so search has to walk the list one node at a time. That's such an unlikely event that you can treat it as theory rather than a risk: the probability is (1 - P)^n, and for only a thousand nodes at P = 1/4 that is about 10^-125. You would have to build the list roughly 10^125 times to watch it happen once
N

Subscribe to my newsletter

Get notified when I publish new posts on my blog

© 2026 Minh-Tri Le. All rights reserved.