Skiplists 101 - Understanding and Implementing Skiplists in Your Projects
Status: Ready Audience: Developers Illustrations: No Created time: January 3, 2025 12:42 PM Last edited time: January 12, 2025 11:05 AM
The World of Skiplists
The skiplist is a data structure that simplifies efficient data organization. This comprehensive guide explains how skiplists match the speed of balanced trees with a simpler design.
I became fascinated with skiplists while implementing a database storage engine. Even with a trivial implementation, this turned out to be a significant project. While I plan to write about that experience someday, I wanted to first share two elegant solutions for different workload challenges: the skiplist and the B+-tree. Most tutorials I found either simply paraphrased the original sources or lacked the depth needed for true understanding.
The skiplist is the simpler data structure to implement and it also has extenrive applications in the databases that I work with the most, so I’m covering that one first. We’ll probably need several articles later to cover B+-trees well.
To understand the basic operations, we'll use the analogy of finding a restaurant in a city with buildings of varying heights. After exploring this tasty analogy, we'll examine William Pugh's original implementation. You'll also discover why systems like Redis and LevelDB choose skiplists over B+ trees—another common database structure—for certain in-memory operations.
This guide covers skiplists from fundamentals to real-world usage, making it valuable for anyone interested in data structures, but most especially software developers looking to understand database internals.
At the end of the article you’ll find a helpful list of questions for you to test your understanding of this awesome data structure and ensure you’re ready to use it when the time comes.
What Is a Skiplist?
A skiplist is a randomized data structure that enables fast insertion, deletion, and search operations—averaging O(log n) time—without complex balancing. It consists of multiple stacked linked lists, where each higher level becomes sparser than the one below, serving as "express lanes."
• Level 0: The entire set of elements in a sorted linked list.
• Level 1+: Subsets of elements from previous levels, creating faster "skip" paths at higher levels.
Through random coin flips, elements get promoted to higher levels, naturally creating a balanced structure on average.
When introducing the skiplist in the original paper, the author wrote it as two words: skip list. Since then, papers have typically used one word: skiplist. We'll use the more modern spelling throughout the article.
Common Applications of Skiplists
The skiplist has been shown to be equivalent to a B-tree, so it can generally be used as a drop-in replacement. However, it’s better suited to high write throughput scenarios as it avoids the frequent rebalancing operations that slow down inserts in B-trees.
After its introduction, the skiplist became nearly as popular as the B-tree for data processing applications. Let’s take a look at some of the common scenarios where skiplists are used.
- Databases
- Redis uses skiplists for sorted sets (ZSET), enabling fast insertion, deletion, and range queries
- LevelDB, RocksDB, and some other databases use skiplists as in-memory data structures in their storage engines
- Language Libraries
- Used in concurrent environments for sorted data due to simpler lock-free implementations
- Priority & Ranked Queues
- Efficient for rank-based operations when augmented with node counts
- Real-Time Leaderboards
- Used in gaming systems for real-time score tracking and rank retrieval
- Search and Indexing
- Serves as a fast in-memory structure for indexing and caching
When you want to support high write throughput and range queries, a skiplist makes a great candidate.
Consider B-trees when IO is more important.
Dining on Delicious Deserts with Skiplists
Imagine each item in a list represented by a building on a street, with buildings rising to different heights. This row of buildings is like our skiplist. We'll explore how to traverse this structure using our imaginary jetpack.
Picture a restaurant on this street that makes the most amazing churros.
![image.png](assets/image 1.png)
Just like real streets, there are more short buildings than tall ones.
To find this mythical restaurant, you begin by jetpacking over buildings with lower addresses, quickly moving toward your tasty destination. Your jetpack lets you glide effortlessly across the tallest buildings.
When you spot a building taller than your destination's address, you descend one level where buildings are closer together. You continue skipping from rooftop to rooftop at this new height. Each time you encounter a building past the restaurant's address, you drop down again and check the next building's address at that lower level.
If you land exactly at your destination's address, you can drop straight down to the dining spot and place your order—by elevator, stairs, or jetpack, your choice.
You might need to walk past a few buildings at ground level, but you have a 50% chance of reaching your destination before touching the ground. The odds of walking past more than five buildings are just 1 in 100, and passing more than 10 buildings is a mere 1 in 3,000.
This is how search works in skiplists. Its efficiency comes from skipping numerous buildings by starting at higher levels.
![image.png](assets/image 2.png)
The same process for finding locations guides how we add (insert) and remove (delete) buildings—we first locate the exact spot, then simply update the connections.
Next we're going to get into the architecture of the skiplist data structure. And then we'll step through procedures for the basic operations: search, insert, and delete.
Understanding the Architecture
At the bottom (level 0), you have every element in a simple linked list. This is the same basic linked list you learned about in data structures—nothing fancy here.
![image.png](assets/image 3.png)
At higher levels, you'll find just one or two "sentinel" nodes. Think of your nodes as buildings that randomly grow to different heights. This randomness is crucial to the guarantees provided by the original skiplist, as described by William Pugh.
The structure consists of two main parts: a data layer at the bottom and index layers above it. Queries start at the index layers, either narrowing down the search range or finding the element directly, before ultimately reaching the data layer where the actual values are stored.
![image.png](assets/image 4.png)
The Core Principles
- Layered Linked Lists: Instead of one linked list, we have multiple (level 0, level 1, etc.).
- Randomization: Coin flips decide how many levels a new node occupies.
- Fast Search: Searching starts at the topmost (sparsest) list, moving right until you overshoot, then dropping down.
The Data Structure
A skiplist implementation requires a header structure that contains essential metadata:
struct Skiplist {
head *Node
level int
size int
}
The level field tracks the current maximum level in the skiplist, determined by the highest node. This level can increase when nodes are inserted (through random coin flips) or decrease when the last node at the highest level is deleted.
The size field counts the total elements in the skiplist, providing useful metadata for capacity monitoring and growth-based decisions.
The core of the structure lies in the Node definition:
struct Node {
entry *Entry
forward List[*Node]
level int
}
Let's examine the Node structure's components:
- *entry Entry: Stores the node's key-value pair, containing the actual data being organized.
- forward List[*Node]: An array of pointers where
forward[i]
links to the next node at leveli
. The node's level determines this array's size. - level int: Specifies how many levels this node spans, set randomly during insertion.
During insertion, we initialize a new node's forward array with pointers gathered during the search phase. These pointers maintain the skiplist structure across all levels where the node exists.
For deletion, we update the forward pointers of preceding nodes to bypass the deleted node at each level, preventing any dangling references.
The multi-level structure, managed through the forward array, enables efficient searching. Higher-level pointers serve as "express lanes," letting us skip many nodes during searches—similar to moving across the tops of taller buildings.
Finally, here's the straightforward Entry definition:
struct Entry {
key List[byte]
value List[byte]
}
The entry simply holds the indexing key and its associated value.
A Note About Probability
It’s true that you don’t need to know why the randomness of skiplists works to use them, but it helps build intuition for how the data structure works. With the compounding of a coin toss (assuming probability is 0.5), we are approximating a geometric distribution.
The likelihood of any node rising to some level is $p^{k-1}(1 - p)$ where $k$ is the level and $p$ is the probability. The expected number of levels for any node is $\frac{1}{1-p}$, which is $2$ for a probability of $0.5$. We can go farther showing that we expect 1/2 of nodes in level 1, 1/4 of nodes in level 2, 1/8 of nodes in level 3, and so on. This reveals the tree structure, which we’ll learn later makes the skiplist equivalent to B-trees.
In the end, the worst case performance of operations on a skiplist is $O(n)$, which is a far cry from the $O(\log n)$ opererations of a B+tree. However, this scenario means that no nodes appear in higher levels. The likelihood of this scenario is vanishingly small.
Let’s Talk Operations (With Original Pseudo-Code)
Let's walk through search, insertion, and deletion, each with pseudo-code adapted from William Pugh's original paper. This will give you a clear view of how these operations are formally defined. We'll start with search since both insert and delete operations build upon its logic.
The pseudo-code uses notation slightly modified from Pugh's original paper, though it stays true to his implementation. The code is straightforward and detailed enough to make translation to any programming language a simple task.
1. Searching
High-level idea
- Start at the highest level (e.g., level = listLevel).
- Move right while the next node's key is less than your search value.
- When you can't move right (to avoid overshooting), drop down one level.
- Repeat until you reach level 0 or find the exact node.
Beyond our earlier jetpack analogy, think of this progressively refined search like traveling from DC to a restaurant in Manhattan. First, an express train rockets you to Union Station with minimal stops. Then a local train, stopping every few blocks, gets you near your destination. An Uber, stopping at traffic lights, brings you within a block. Finally, you walk door-to-door to find your spot.
And there you are at the dining spot with those tempting churros. Quite a journey for dessert—they must be spectacular!
For a more formal perspective, imagine a decision tree where each level adds detail to the one above. Whether to continue at the current level or drop down depends on if the next node's value exceeds your search target.
![image.png](assets/image 5.png)
Here's the Pseudo-code (Adapted from Pugh's Paper):
function SEARCH(list, searchKey):
node := list.header // Start at "header" (sentinel) at top level
// loop invariant: x.key < searchKey
for i := list.level downto 1 do:
while node.forward[i].key < searchKey do:
node := node.forward[i]
// assert: node.key < searchKey <= node.forward[i].key
node := node.forward[i]
if node.key == searchKey then
return node.value
else
return NULL
Let's break down how this search works in the code.
- First,
list.level
keeps track of the current highest level in the skiplist, which is where our search begins. - As we search,
node.forward[i]
acts like a pointer system, showing us the next node at each level i. Think of it as our roadmap through the structure. - The search process is elegant - we move right at each level until we would overshoot our target key, at which point we move down a level.
- Finally, after navigating through the higher levels efficiently, we make one last check at the bottom level (level 0) to ensure we've found our target.
Now that we've covered searching, we'll build on this pattern with inserting and deleting. Let's explore inserts, where the skiplist truly shines.
2. Inserting
Now that we understand searching in a skiplist, let's explore adding new elements. Insertion builds on our search method with a key addition: as we search, we track potential pointer update locations for the new node. This tracking is crucial since we'll need to reconnect the skiplist at multiple levels.
Think of insertion as finding a spot in line while remembering everyone who needs to know about the newcomer at each "express lane" level. While this may sound complex, skiplist insertion is surprisingly elegant in practice.
High-level idea
- Search for the new element's position using our search method, but track the nodes where we stop at each level (these become our "update pointers").
- Insert the new node at level 0.
- Flip a coin to decide whether to promote the node to level 1. If heads, link it at level 1 using the saved update pointers.
- Continue flipping coins and promoting until you get tails or reach MAX_LEVEL.
To determine a new key's level, we generate a random level value. This approach creates a probability distribution where higher levels become increasingly rare. We use 0.5 as our probability—both for the simplicity of describing it as coin flips and because this was the value in the original paper. The paper also discusses how to select different probabilities and analyzes their impact on time and space complexity.
The skiplist's structure depends solely on the number of elements and the random number generator. Here's the code for selecting a level:
function RANDOM_LEVEL():
lvl := 1
// rand() is a uniformly distributed random number in [0..1)
while rand() < PROBABILITY and lvl < MAX_LEVEL do
lvl := lvl + 1
return lvl
With our level selection method in place, let's examine the insertion process. The INSERT function combines our searching strategy with random level selection to add new nodes. It maintains the skiplist's structure by carefully updating pointers at each level.
function INSERT(list, searchKey, newValue):
update[1..MAX_LEVEL] // Array of pointers to track where to insert at each level
node := list.header
// 1. Search through levels top-down to find position of 'searchKey'
for i := list.level downto 1 do:
while node.forward[i].key < searchKey do:
node := node.forward[i]
// assert: node.key < searchKey <= node.forward[i].key
update[i] := node
// 2. Move to level 1
node := node.forward[1]
// 3. If 'key' not found, we insert a new node
if node.key == searchKey then
node.value := newValue
else:
// 3a. Randomly generate newLevel
newLevel := RANDOM_LEVEL()
if newLevel > list.level then
for i := list.level+1 to newLevel do:
update[i] := list.header
list.level := newLevel
// 3b. Create new node
newNode := new Node(newLevel, searchKey, newValue)
// 3c. Initialize forward references and insert
for i := 1 to newLevel do:
newNode.forward[i] := update[i].forward[i]
update[i].forward[i] := newNode
Let's break down the key points of this insertion code:
update[i]
stores the node at level i that should point to the new node once inserted.RANDOM_LEVEL()
flips coins (or uses some randomness) to decide the level of the new node.- If
newLevel
exceeds the current list.level, we update the global list.level.
Next, we'll explore deletions, which follow a similar pointer-tracking process across all levels.
3. Deleting
![image.png](assets/image 6.png)
After mastering insertion, deletion follows a similar but reversed pattern. Rather than adding connections at various levels, we carefully remove them while maintaining the skiplist's structure. The process requires attention to avoid dangling pointers or gaps in our express lanes. Let's examine how it works.
High-level idea
- Just like insertion, we perform a top-down search, tracking nodes at each level using
update[]
. - Once we find the target node (if it exists), we remove its references at every level.
- If we removed something at the highest level and that level becomes empty, we decrease
list.level
.
Pseudo-code (Adapted from Pugh's Paper)
function DELETE(list, searchKey):
update[1..MAX_LEVEL]
node := list.header
// 1. Find node and keep track of update positions
for i := list.level downto 1 do:
while node.forward[i].key < searchKey do:
node := node.forward[i]
update[i] := node
// 2. Potential candidate
node := node.forward[1]
// 3. If we found the key, remove it from each level
if node.key == searchKey:
for i := 1 to list.level do:
if update[i].forward[i] != node then break
update[i].forward[i] := node.forward[i]
free(node)
// 4. Adjust list.level if highest level is now empty
while (list.level > 1) and (list.header.forward[list.level] == NIL) do:
list.level := list.level - 1
- Once again, the
update[]
array is essential for rewiring pointers after locating the node. - We remove references to the deleted node at all levels. When the topmost level becomes empty, we decrement
list.level
.
Memory Requirements
With multiple levels, a skiplist has a constant-factor overhead compared to a single linked list. When the probability of promotion is 1/2, each element appears in about 2 levels on average. This means the total space is about 2n for n items, which remains O(n). However, the extra pointer arrays in each node create more overhead than a plain single linked list.
Time Complexity Shenanigans
Because the height of a skiplist is $O(\log n)$ on average (due to random coin flips), operations—search, insert, and delete—will typically run in $O(\log n)$. While the worst-case time is $O(n)$ if you get extremely unlucky coin tosses (i.e., every node promotes to the max level), that’s astronomically unlikely. Life’s short, so we’ll take those odds, when other tradeoffs of the skiplist make sense.
Why Use Skiplists?
- Simplicity Over Balanced Trees: No complex rotations like in AVL or Red-Black Trees; we just rely on random promotion.
- Concurrency: In multi-threaded contexts, implementing a lock-free or lock-based skiplist is often easier than concurrency in self-balancing trees.
- Adaptability: We can tune the promotion probability (e.g., 1/4, 1/2) to control average height.
- Speed of writes: skiplists don’t have to rebalance nodes in insertions. The structure is automatically balanced through randomness.
- It’s Fun: Come on, flipping coins to build data structures? It sounds like a game that’s going to be a hit with gamblers and data structure afficionados alike.
![image.png](assets/image 7.png)
Skiplists vs. B+ Trees
It’s been shown that skiplists are functionally and structurally equivalent to B-trees. Structural equivalence means that adjacent items in one structure will also be adjacent in the other. And functional equivalence means that that the worst-case cost functions are in the same big-O order of complexity.
Let’s look at some of the tradeoffs that inform when we should use skiplists and when we should use B-trees.
- Use Cases & Storage
- B+ Tree: Typically used for on-disk data (like in databases/filesystems) to minimize disk seeks.
- skiplist: More common in memory, or where concurrency is crucial without heavy blocking-based management.
- Balancing & Complexity
- B+ Tree: Carefully balanced via splitting and merging nodes.
- Skiplist: Automatically (probabilistically) balanced via coin flips.
- Memory
- B+ Tree: Each node can have multiple children (often stored as contiguous blocks).
- Skiplist: Multiple forward pointers per node, each representing a level.
- Performance
- B+ Tree: O(log n) but optimized for disk I/O.
- Skiplist: O(log n) in memory, with simpler balancing.
- Concurrency
- B+ Tree: Locking can be more complex because of node splits/merges.
- Skiplist: Lock-free or lock-based concurrency is straightforward to implement.
Putting It All Together
A skiplist maintains multiple levels of linked lists, with nodes potentially appearing across multiple levels. Random coin flips determine each node's "height," naturally balancing the structure and providing O(log n) search, insert, and delete times—all with much simpler implementation than balanced trees.
For disk-stored data where you need to minimize page reads, a B+ tree is your best bet. But for in-memory operations, simple implementation, or concurrency-friendly needs, a skiplist shines.
Final Thoughts
Skiplists elegantly combine randomness with linked list simplicity to create a worthy alternative to trees. And hey, they make for great party conversation—just flip a coin to pick your next victim!
So go forth, armed with:
- The skiplist concept (stacked linked lists).
- Random coin toss insertion logic.
- Pseudo-code from the original paper, radiating academic brilliance.
May your coin flips be favorable, and may you never face the $O(n)$ walk of shame again.
Summary
- Definition: A skiplist is a probabilistic data structure built on layered, increasingly sparse linked lists to achieve average O(log n) time for key operations.
- Key Idea: Use random coin flips to “promote” nodes, balancing the structure without tricky rotations.
- Performance: On average, search/insert/delete are $O(logN)$.
- Memory Requirements: $O(n)$ space, but with a larger constant factor than a single linked list.
- Applications: Redis sorted sets, real-time leaderboards, in-memory indexing, and concurrency-friendly data structures.
- skiplist vs. B+ Tree: B+ trees are great for disk-based indexing; skiplists excel in memory with simpler concurrency.
![image.png](assets/image 8.png)
Additional Reading
There’s much more to skiplists, especially the many specializations, that we didn’t cover. These are the sources that I found most helpful in my journey to understanding them.
- Skip Lists: A Probabilistic Alternative to Balanced Trees by William Pugh (Audust, 1989) - This is the original paper introducing the skiplist.
- The Ubiquitous Skiplist: A Survey of What Cannot be Skipped About the Skiplist and its Applications in Big Data Systems by Venkata Sai Pavan Kumar Vadrevu, Lu Xing, Walid G. Aref (March, 2024) - This survey explores the variations of skiplists and how they can be adapted to fit specific use cases.
- Skip list on Wikipedia
- Skiplist on Geeks for Geeks
Review Questions
These are review questions that I used to retain the key facts of skiplists.
Questions
- What is the primary advantage of using a skiplist over a balanced tree structure like AVL or Red-Black trees?
- How is the height of a node determined in a skiplist?
- What is the average time complexity for search, insert, and delete operations in a skiplist?
- Why are skiplists particularly well-suited for concurrent implementations?
- What is the space complexity of a skiplist, and how does it compare to a regular linked list?
- What role does the update[] array serve in skiplist operations?
- In what scenarios would you choose a B+ tree over a skiplist?
- What is the worst-case time complexity of skiplist operations, and why is it rarely a concern?
- How does a skiplist maintain its balance?
- What happens to
list.level
during delete operations?
Answers
- skiplists offer simpler implementation without complex rotations, relying instead on random promotion for balance.
- The height is determined through random coin flips (or similar randomization), where each level has a probability (often 1/2) of promoting to the next level.
- The average time complexity is $O(\log n)$ for all three operations.
- skiplists are easier to implement in concurrent scenarios because they don't require complex rebalancing operations, making both lock-free and lock-based implementations simpler than with balanced trees.
- A skiplist has $O(n)$ space complexity but with a larger constant factor - typically about $2n$ for n items when using 1/2 promotion probability.
- The update[] array keeps track of the nodes at each level that need to be modified during insert and delete operations, storing the last node examined at each level.
- B+ trees are preferred for disk-based storage and when optimizing for minimal disk I/O, such as in databases and file systems.
- The worst-case is $O(n)$, but it's astronomically unlikely due to requiring extremely unfavorable random coin flips where every node promotes to the maximum level.
- Balance is maintained probabilistically through random promotion of nodes to higher levels, without explicit rebalancing operations.
- If nodes are deleted from the highest level and that level becomes empty, list.level is decremented to maintain an efficient structure.