Skip Lists and Probabilistic Linking

Trade memory capacity for faster search speeds. Learn how probabilistic express lanes create highly concurrent, self-balancing data structures.

Ryan McCombe
Published

Standard linked lists present us with a serious limitation when we want to search the collection for a specific element. They require us to traverse the list from start to end: a slow, O(N)O(N) process. If we have 100,000 elements, finding one might require 100,000 checks.

When we covered , we saw how binary search solves this problem when the collection is sorted. By checking the middle element, we instantly discard half the dataset. We then check the middle element of what remains, and we cut the problem in half again. This divide-and-conquer approach means searching a sorted array is a O(log N)O(\text{log }N) process. Finding any record in a collection of 100 takes no more than 7 steps; finding a record in a collection of 10 million takes just 24 steps.

But binary search can't be used with a typical linked list, even when it is sorted. Linked lists do not support index-based access - we can't "check the middle element".

If we want fast search speed in a node-based architecture, the standard answer is to use a binary search tree (BST), which underpins containers like std::map and std::set.

We'll cover trees later in the course, but in this lesson, we will look at a lightweight, list-based alternative. Skip lists provide the same O(log N)O(\text{log }N) search performance as binary search, with a simpler architecture and some key advantages when it comes to concurrency.

Express Lanes

The key idea that unlocks O(log N)O(\text{log }N) searching of a sorted linked list is that we need to provide our traversal algorithms with a way to skip over many nodes in a single jump. We do this by providing it with some express lanes.

Instead of each node having a single Next pointer, it has an array of Next pointers - one pointer for each lane the node is in. These lanes are usually described in terms of levels.

Level 0 is the standard, slow lane we're accustomed to. Every node gets a Level 0 pointer to the next node in the list, and if we want to traverse through every node, we use the Level 0 lane by jumping through those pointers as usual.

Level 1 is a faster lane that skips over a few nodes to speed up traversal. Level 2 is an even faster lane that skips even more nodes:

This allows us to implement the "jump to the middle, then discard half of the elements" algorithm of binary search in a slightly different way. In our 8-element example above, we can jump around the middle of the collection in a single jump using our level 2 lane. We can then jump to around the middle of what remains using our level 1 lane.

As our list gets bigger, we can add more levels to maintain this divide-and-conquer ability. We could have a level 5 lane that skips over dozens of nodes on an average jump; a level 10 lane that skips over thousands; a level 20 lane that skips over millions.

Designing the Node

Later in the lesson, we'll cover the strategy we use to set up our lanes to achieve this divide-and-conquer distribution, but let's first cover our basic Node structure and the search algorithm.

Again, we could use with here, but we'll stick to simple, globally-allocated nodes linked together using pointers to reduce the complexity of this lesson.

We will use a std::array to hold up to 16 pointers within the node, meaning we support up to 16 lanes (Level 0 to Level 15):

dsa_core/include/dsa/SkipList.h

#pragma once
#include <array>
#include <iostream>

template <typename T>
class SkipList {
private:
  static constexpr int MAX_LEVEL = 16; 

  struct Node {
    T Payload;

    // An array of pointers, one for each lane
    std::array<Node*, MAX_LEVEL> Next{}; 

    // Construct a node with zeroed-out pointers
    Node(T val) : Payload{val} {
      Next.fill(nullptr);
    }
  };

  // The entry point
  Node* Head;

  // Track the highest level currently in use across the whole list
  int CurrentMaxLevel{0};

public:
  SkipList() {
    // The Head node contains no useful payload,
    // it just anchors the start of the pointer chains.
    Head = new Node(T{});
  }

  ~SkipList() {
    // Standard linked list cleanup via Level 0
    Node* Current = Head;
    while (Current != nullptr) {
      Node* ToDelete = Current;
      Current = Current->Next[0]; 
      delete ToDelete;
    }
  }
};

To find a value, we start at the Head node on the CurrentMaxLevel. We look at the pointer in that lane. If it leads to a node smaller than our target, we jump to it. We keep jumping forward on that level until we hit a nullptr (the end of the list) or a value greater than our target.

When we overshoot, we drop our "vision" down one level and look at the pointer in the slower lane, repeating the process until we reach Level 0.

dsa_core/include/dsa/SkipList.h

// ...

template <typename T>
class SkipList {
  // ...

public:
  // ...

  bool Contains(const T& Value) const {
    Node* Current = Head;

    // Start at the top of the tower and work downwards
    for (int i = CurrentMaxLevel; i >= 0; i--) { 

      // Glide forward on the current level as long as the
      // next node's payload is less than what we are looking for.
      while (Current->Next[i] != nullptr && 
             Current->Next[i]->Payload < Value) { 

        // Execute the jump
        Current = Current->Next[i]; 
      }
    }

    // We have bottomed out at Level 0.
    // 'Current' is the largest node strictly less than our Value.
    // The node immediately to the right MUST be our target, or
    // the target does not exist in the list.
    Current = Current->Next[0]; 

    return Current != nullptr && Current->Payload == Value;
  }
};

Assigning Nodes to Levels

How do we decide which nodes get to be on Level 1, Level 2, or Level 15?

If we tried to mandate a strict mathematical rule like "every 4th node must have a Level 2 pointer", our performance would collapse on insertion and deletion. If we insert a new node at the front of the list, we would have to walk the entire rest of the list, demoting and promoting pointers to satisfy the "every 4th node" rule. This would slow our insertions and deletions to a crawl, removing the key advantage of linked lists.

If we look at our Contains() algorithm above, it doesn't care if our links are perfectly distributed. We could have five Level 3 nodes right next to each other, and then a gap of twenty nodes before the next one. The algorithm will still work.

So, instead of mandating a perfect distribution, we can rely on probability. When we insert a new node, we give it a Level 0 pointer by default. Then, we flip a coin. If it lands on Heads, we give it a Level 1 pointer and flip again. If it lands on Heads again, it gets a Level 2 pointer. The moment we roll Tails, we stop growing the tower.

This probabilistic model can create some weird distributions at small scales, but in the real world, skip lists are used for collections containing thousands or millions of elements, spanning across dozens of lanes. At that scale, the probability naturally evens out, creating a nearly perfect O(log N)O(\text{log }N) structure where:

  • 100% of nodes will have a level 0 pointer
  • Approximately 50% will have a level 1 pointer
  • Approximately 25% will have a level 2 pointer
  • Approximately 12.5% will have a level 3 pointer

This halving pattern continues until we reach the MAX_LEVEL we decided upon when designing our node. If our nodes had capacity for 16 pointers (from Level 0 to Level 15) this halving pattern would result in around 0.003% of nodes getting a Level 15 pointer.

This means our level 15 fast lane would skip around 32,000 nodes on each jump, enabling "jump to the middle" binary search performance for sorted collections up to around 64,000 elements. If we bumped our nodes up to 32 pointers, our Level 31 pointers would skip around 2 billion nodes, giving us O(log N)O(\text{log }N) searching of sorted lists containing up to 4 billion elements.

Let's implement our probabilistic level generator:

dsa_core/include/dsa/SkipList.h

#include <random> 

template <typename T>
class SkipList {
  // ...
private:
  // Random number generator setup
  std::mt19937 RNG{std::random_device{}()}; 
  std::uniform_int_distribution<int> CoinFlip{0, 1}; 

  // Generate a height using a geometric distribution (coin flips)
  int GenerateRandomLevel() {
    int Level = 0;

    // 50% chance to grow a level, capped at our MAX_LEVEL - 1
    while (CoinFlip(RNG) == 1 && Level < MAX_LEVEL - 1) { 
      Level++; 
    }

    return Level;
  }

  // ...
};

Implementing Insertion

Inserting a node into a skip list requires us to slice the new pointer tower into the existing express lanes.

When we search for the insertion spot, we have to drop down from Level N to Level 0. Every time we drop down a level, the node we are standing on is the exact node that needs its Next pointer updated to point to our newly inserted node.

To keep track of this, we create an array called Update. As we walk down the tower, we record the node we were standing on right before we dropped down.

dsa_core/include/dsa/SkipList.h

// ...

template <typename T>
class SkipList {
  // ...

public:
  // ...

  void Insert(const T& Value) {
    // An array to remember the nodes that need
    // their pointers rewired
    std::array<Node*, MAX_LEVEL> Update{}; 

    Node* Current = Head;

    // Search for the insertion point, tracking
    // our path downwards
    for (int i = CurrentMaxLevel; i >= 0; i--) {
      while (Current->Next[i] != nullptr &&
             Current->Next[i]->Payload < Value) {
        Current = Current->Next[i];
      }
      // Record the node right before we drop down
      Update[i] = Current; 
    }

    // Move to the exact insertion gap on Level 0
    Current = Current->Next[0];

    // If the value already exists, do nothing
    // (assuming a set-like structure with no duplicates)
    if (Current != nullptr && Current->Payload == Value) {
      return;
    }

    // Flip the coin to determine the new node's height
    int NewLevel = GenerateRandomLevel(); 

    // If the new node is taller than any node currently
    // in the list, we need to update the Head to anchor
    // these new express lanes.
    if (NewLevel > CurrentMaxLevel) { 
      for (int i = CurrentMaxLevel + 1; i <= NewLevel; i++) { 
        Update[i] = Head; 
      } 
      CurrentMaxLevel = NewLevel; 
    }

    // Create the node and splice it into the lanes
    Node* NewNode = new Node(Value);

    for (int i = 0; i <= NewLevel; i++) { 
      // The new node points to whatever the Update
      // node used to point to
      NewNode->Next[i] = Update[i]->Next[i]; 

      // The Update node now points to our new node
      Update[i]->Next[i] = NewNode; 
    } 
  }
};

We execute a standard search, flip a coin, and do some basic pointer reassignment. The entire insertion process operates locally. We do not look at, touch, or modify any nodes beyond the immediate neighbors we recorded in the Update array.

Implementing Deletion

Deletion is mechanically identical to insertion. We construct the Update array to find all the pointers that lead to our victim node. We then bypass the victim by making the Update nodes point to whatever the victim was pointing to, and finally, we delete the victim.

dsa_core/include/dsa/SkipList.h

// ...

template <typename T>
class SkipList {
  // ...

public:
  // ...

  void Remove(const T& Value) {
    std::array<Node*, MAX_LEVEL> Update{};
    Node* Current = Head;

    for (int i = CurrentMaxLevel; i >= 0; i--) {
      while (Current->Next[i] != nullptr &&
             Current->Next[i]->Payload < Value) {
        Current = Current->Next[i];
      }
      Update[i] = Current;
    }

    Current = Current->Next[0];

    // If the target doesn't exist, there is nothing to remove
    if (Current == nullptr || Current->Payload != Value) {
      return;
    }

    // Rewire the pointers to bridge over the dying node
    for (int i = 0; i <= CurrentMaxLevel; i++) { 
      // If this level doesn't even point to our victim,
      // we can stop
      if (Update[i]->Next[i] != Current) { 
        break; 
      }

      // Splice the victim out of this lane
      Update[i]->Next[i] = Current->Next[i]; 
    }

    delete Current;

    // If we just deleted the tallest node in the list,
    // we should shrink the CurrentMaxLevel to optimize
    // future searches.
    while (
      CurrentMaxLevel > 0 &&
      Head->Next[CurrentMaxLevel] == nullptr
    ) {
      CurrentMaxLevel--;
    }
  }
};

Benchmarking

Let's pit our custom SkipList against std::list - a flat O(N)O(N) pointer chain, and std::set - a O(log N)O(\text{log }N) binary search tree.

We will populate all three structures with 100,000 randomized integers, and then benchmark how long it takes to find a target value.

benchmarks/main.cpp

#include <benchmark/benchmark.h>
#include <list>
#include <set>
#include <algorithm>
#include <dsa/SkipList.h>

// Generate randomized data
std::vector<int> GetRandomData(int size) {
  std::vector<int> data(size);
  for (int i = 0; i < size; ++i) data[i] = i;
  std::mt19937 g(42);
  std::ranges::shuffle(data, g);
  return data;
}

static void BM_StdList_Search(benchmark::State& state) {
  std::vector<int> data = GetRandomData(100000);
  std::list<int> l;
  for (int v : data) l.push_back(v);

  for (auto _ : state) {
    // O(N) Linear pointer chasing
    auto it = std::find(l.begin(), l.end(), 99999); 
    benchmark::DoNotOptimize(it);
  }
}
BENCHMARK(BM_StdList_Search);

static void BM_StdSet_Search(benchmark::State& state) {
  std::vector<int> data = GetRandomData(100000);
  std::set<int> s;
  for (int v : data) s.insert(v);

  for (auto _ : state) {
    // O(log N) Red-Black tree search
    auto it = s.find(99999); 
    benchmark::DoNotOptimize(it);
  }
}
BENCHMARK(BM_StdSet_Search);

static void BM_SkipList_Search(benchmark::State& state) {
  std::vector<int> data = GetRandomData(100000);
  SkipList<int> sl;
  for (int v : data) sl.Insert(v);

  for (auto _ : state) {
    // O(log N) Skip list search
    bool found = sl.Contains(99999); 
    benchmark::DoNotOptimize(found);
  }
}
BENCHMARK(BM_SkipList_Search);
----------------------------
Benchmark                CPU
----------------------------
BM_StdList_Search    2888 ns
BM_StdSet_Search     13.8 ns
BM_SkipList_Search   17.3 ns

As expected, the O(N)O(N) search performance of std::list struggles at this scale.

Our custom SkipList performs comparably to the highly optimized std::set in these tests. The primary advantage a binary search tree like std::set has are its physically smaller nodes. Each node holds only 3 pointers instead of the 16 of our SkipList, resulting in better cache utilization.

The pointer overhead of a skip list is particularly egregious when the payload itself is relatively small. Each node in our SkipList<int> has only 4 bytes of core data (the int payload) surrounded by 128 bytes of pointers.

However, the skip list achieves logarithmic search performance with significantly less architectural complexity than a tree, and it has particular advantages when we need to implement concurrency.

Advanced: The Concurrency Advantage

One of the main advantages of skip lists over alternatives that provide similar performance comes when we want to implement multithreading.

As we learned in our , locking a data structure can be catastrophic for throughput. If 16 cores are constantly fighting over a std::mutex to read from our structure, 15 cores will be asleep while 1 core does the work.

If we use a binary search tree like std::set, inserting a single node might trigger a cascade of tree rotations to keep the structure balanced, which forces massive portions of the data structure to become locked.

We cover binary search trees and their balancing mechanism in more detail later in the course, but with a skip list, the balancing of the lanes is an elegant, natural side-effect of the probabilistic assignments. Major structural adjustments are never required - when we insert a node, the only memory addresses we modify are the immediate neighbors in the Update array. The rest of the 100,000 nodes are completely untouched.

This means a skip list is incredibly easy to make lock-free or highly concurrent using fine-grained locks. We can have 16 threads inserting and searching a skip list at the same time, and as long as they aren't trying to modify the same local neighborhood of pointers, they will never block each other.

Complete Code

Here is the complete implementation of our probabilistic SkipList:

Files

dsa_core
dsa_app
Select a file to view its content

Summary

In this lesson, we engineered our way out of the O(N)O(N) linear search trap.

  1. We constructed a skip list by adding an array of Next pointers to our nodes, creating algorithmic express lanes that skip vast portions of the data.
  2. We achieved perfect mathematical balancing without complex tree rotations by using probabilistic coin flipping to determine the height of our pointer towers.
  3. We explored the concurrency advantage of skip lists over Red-Black trees. By avoiding structural rotations, skip lists allow for highly localized, thread-safe memory updates, making them the architecture of choice for many high-performance concurrent systems.
Have a question about this lesson?
Answers are generated by AI models and may not be accurate