On This Page

What Is a Skip List?

A sorted linked list (a data structure where each node points to the next node in sequence) is great for insertion once you find the right spot, but searching is O(n) โ€” you have to walk node by node from the beginning. Binary search doesn't work because you can't jump to the middle of a linked list.

William Pugh invented skip lists in 1989 with a clever insight: what if we added "express lanes" to the linked list?

Picture a subway system. The local train stops at every station. The express train skips most stations, only stopping at major ones. The super-express skips even more. If you want to get from station 1 to station 47, you take the super-express to station 40, switch to the express to station 45, then ride the local for the last two stops. Much faster than taking the local the whole way.

A skip list is exactly this idea applied to linked lists. It's a stack of linked lists at increasing levels. The bottom level (level 0) contains all elements in sorted order โ€” that's your "local train." Each higher level is a sparser subset that acts as an express lane. When searching, you start at the top level and drop down when you overshoot.

A Brief History

William Pugh published "Skip Lists: A Probabilistic Alternative to Balanced Trees" in 1989 at Johns Hopkins University. His paper opened with a compelling argument: balanced trees (AVL, red-black) have excellent theoretical properties but are notoriously tricky to implement correctly. Skip lists achieve the same O(log n) expected bounds with much simpler code. The structure gained mainstream adoption when Redis chose it for sorted sets in 2009.

Why Not Just Use a Balanced BST?

You could. AVL trees (balanced binary search trees that maintain a height difference of at most 1 between subtrees) and red-black trees (balanced BSTs using a coloring scheme to ensure O(log n) height) give the same O(log n) worst-case guarantees. But they're complex to implement correctly โ€” rotations, rebalancing, color flips, double-rotations. The Wikipedia article for red-black tree deletion has 6 separate cases.

Skip lists achieve the same bounds with simpler code and use randomization instead of strict balancing rules. Each node's level is decided by coin flips at insertion time, not by complex rebalancing logic.

The trade-off: skip list guarantees are probabilistic (expected O(log n)) rather than worst-case. In theory, every coin flip could land heads and one node could have maximum height โ€” making search O(n). In practice, this is about as likely as a shuffled deck coming out perfectly sorted. It just doesn't happen.

Where Are They Used?

How It Works

Structure

Each node has a value and an array of forward pointers โ€” one for each level the node participates in. The pointer at level i points to the next node that also exists at level i.

A node's level is decided randomly at insertion time using a geometric distribution: flip a coin repeatedly. Level 0 always (all nodes are on level 0). Level 1 with probability p (typically 0.5). Level 2 with probability pยฒ. And so on. This gives an expected distribution where:

This mirrors the structure of a balanced binary tree โ€” each level has roughly half as many nodes as the level below it โ€” but it happens naturally through randomness rather than explicit balancing.

Search

Start at the header node (a sentinel node at the beginning) at the highest active level. The search follows a simple rule at each step:

  1. Look right: Check the next node on the current level.
  2. If the next value is less than the target: move right (follow the forward pointer).
  3. If the next value is greater than or equal to the target (or there is no next node): drop down one level.
  4. Repeat until you reach level 0. Check if the node you landed on contains the target.

On average, you make O(log n) comparisons โ€” each level roughly halves the remaining search space, just like binary search.

Insert

  1. Search for the position: Walk the skip list as in search, but at each level, record the last node before the insertion point. These are your "update" pointers โ€” you'll need them to splice in the new node.
  2. Generate a random level: Flip coins to decide how tall the new node will be.
  3. Splice in: At each level up to the new node's randomly chosen level, insert it by updating the forward pointers of the recorded predecessor nodes.

If the new node's level is higher than the current maximum level, extend the header's pointer array and set those new update pointers to the header itself.

Delete

Search for the node, recording update pointers at each level (same as insert). If found, remove it from each level by patching the forward pointers of its predecessors. Then shrink the max level if the top levels are now empty. Simple pointer surgery โ€” no rebalancing, no rotations, no color changes.

Range Query

One of skip lists' advantages over hash tables: finding the node for value L, then walking the level-0 forward pointers to collect everything until you pass value R. This gives O(log n + k) for a range query returning k results โ€” no different from an in-order traversal of a BST, but without the complexity of tree traversal.

Interactive Visualization

Watch how the search path drops down through levels to find elements efficiently. Insert adds nodes at random levels. The dotted lines show the search path.

Skip List Explorer

Operations & Complexity

OperationAverageWorst CaseSpace
Search O(log n) O(n)* โ€”
Insert O(log n) O(n)* โ€”
Delete O(log n) O(n)* โ€”
Range Query O(log n + k) O(n + k) โ€”
Space O(n) O(n log n) Expected O(n)

*Worst case requires astronomically unlikely random outcomes โ€” every coin flip landing heads. With high probability (specifically, with probability 1 - 1/n^c for any constant c), all operations are O(log n). The maximum level is typically capped at O(log n) to bound space.

๐Ÿ’ก Space Analysis Expected total pointers across all nodes: n + n/2 + n/4 + n/8 + ... = 2n (with p=0.5). So the expected space overhead is just 2 pointers per element โ€” roughly the same as a binary tree's left/right pointers. With p=0.25 (Redis's choice), it's even less: n + n/4 + n/16 + ... โ‰ˆ 1.33n.

Implementation

Python โ€” Full Skip List

Python
import random

class SkipNode:
    """A single node in the skip list. Contains a value and an array
    of forward pointers โ€” one per level the node participates in."""
    def __init__(self, val, level):
        self.val = val
        # forward[i] points to the next node at level i
        self.forward = [None] * (level + 1)

class SkipList:
    """Skip list with search, insert, delete, and range query.
    
    MAX_LEVEL caps the height to O(log n). P controls the probability
    of a node being promoted to a higher level. p=0.5 gives the most
    balanced structure; p=0.25 (Redis) saves memory.
    """
    MAX_LEVEL = 16   # Supports up to 2^16 = 65536 elements efficiently
    P = 0.5          # Probability of promoting to next level

    def __init__(self):
        # Header is a sentinel node โ€” it exists at all levels
        # and has value -infinity (never matches a search)
        self.header = SkipNode(-1, self.MAX_LEVEL)
        self.level = 0  # Current highest level in use
        self.size = 0

    def _random_level(self):
        """Generate a random level using geometric distribution.
        Each level has P probability of promotion.
        Expected level = 1/(1-P). With P=0.5, average level is ~1."""
        lvl = 0
        while random.random() < self.P and lvl < self.MAX_LEVEL:
            lvl += 1
        return lvl

    def search(self, target):
        """Search for a value. Returns True/False. O(log n) expected."""
        current = self.header
        # Start from highest level, work down
        for i in range(self.level, -1, -1):
            # Move right while the next node's value is less than target
            while current.forward[i] and current.forward[i].val < target:
                current = current.forward[i]
        # Now at level 0, one step away from target position
        current = current.forward[0]
        return current is not None and current.val == target

    def insert(self, val):
        """Insert a value into the skip list. O(log n) expected."""
        # update[i] = the last node at level i before the insertion point
        update = [None] * (self.MAX_LEVEL + 1)
        current = self.header

        # Search for insert position, recording predecessors at each level
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < val:
                current = current.forward[i]
            update[i] = current

        # Generate random height for the new node
        new_level = self._random_level()

        # If new node is taller than current max, extend update array
        if new_level > self.level:
            for i in range(self.level + 1, new_level + 1):
                update[i] = self.header  # Header is predecessor at new levels
            self.level = new_level

        new_node = SkipNode(val, new_level)

        # Splice the new node into each level it participates in
        for i in range(new_level + 1):
            new_node.forward[i] = update[i].forward[i]
            update[i].forward[i] = new_node

        self.size += 1

    def delete(self, val):
        """Delete a value from the skip list. Returns True if found. O(log n)."""
        update = [None] * (self.MAX_LEVEL + 1)
        current = self.header

        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < val:
                current = current.forward[i]
            update[i] = current

        target = current.forward[0]
        if target is None or target.val != val:
            return False  # Value not found

        # Remove target from each level it appears in
        for i in range(self.level + 1):
            if update[i].forward[i] != target:
                break  # target doesn't exist at this level or above
            update[i].forward[i] = target.forward[i]

        # Shrink max level if top levels are now empty
        while self.level > 0 and self.header.forward[self.level] is None:
            self.level -= 1

        self.size -= 1
        return True

    def range_query(self, lo, hi):
        """Return all values in [lo, hi] inclusive. O(log n + k)."""
        results = []
        current = self.header

        # Search for the first node >= lo
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < lo:
                current = current.forward[i]

        # Walk level 0 and collect values until we pass hi
        current = current.forward[0]
        while current and current.val <= hi:
            results.append(current.val)
            current = current.forward[0]

        return results

    def to_list(self):
        """Return all values in sorted order. O(n)."""
        result = []
        current = self.header.forward[0]
        while current:
            result.append(current.val)
            current = current.forward[0]
        return result


# Usage
sl = SkipList()
for val in [3, 6, 7, 9, 12, 19, 17, 26, 21, 25]:
    sl.insert(val)

print(sl.search(19))          # True
print(sl.search(15))          # False
print(sl.range_query(10, 20)) # [12, 17, 19]
print(sl.to_list())           # [3, 6, 7, 9, 12, 17, 19, 21, 25, 26]

sl.delete(19)
print(sl.search(19))          # False
print(sl.range_query(10, 20)) # [12, 17]

JavaScript

JavaScript
class SkipList {
  constructor(maxLevel = 16, p = 0.5) {
    this.MAX_LEVEL = maxLevel;
    this.P = p;
    this.header = { val: -Infinity, forward: new Array(maxLevel + 1).fill(null) };
    this.level = 0;
    this.size = 0;
  }

  _randomLevel() {
    let lvl = 0;
    while (Math.random() < this.P && lvl < this.MAX_LEVEL) lvl++;
    return lvl;
  }

  search(target) {
    let cur = this.header;
    for (let i = this.level; i >= 0; i--) {
      while (cur.forward[i] && cur.forward[i].val < target) cur = cur.forward[i];
    }
    cur = cur.forward[0];
    return cur !== null && cur.val === target;
  }

  insert(val) {
    const update = new Array(this.MAX_LEVEL + 1).fill(null);
    let cur = this.header;
    for (let i = this.level; i >= 0; i--) {
      while (cur.forward[i] && cur.forward[i].val < val) cur = cur.forward[i];
      update[i] = cur;
    }
    const newLevel = this._randomLevel();
    if (newLevel > this.level) {
      for (let i = this.level + 1; i <= newLevel; i++) update[i] = this.header;
      this.level = newLevel;
    }
    const node = { val, forward: new Array(newLevel + 1).fill(null) };
    for (let i = 0; i <= newLevel; i++) {
      node.forward[i] = update[i].forward[i];
      update[i].forward[i] = node;
    }
    this.size++;
  }

  delete(val) {
    const update = new Array(this.MAX_LEVEL + 1).fill(null);
    let cur = this.header;
    for (let i = this.level; i >= 0; i--) {
      while (cur.forward[i] && cur.forward[i].val < val) cur = cur.forward[i];
      update[i] = cur;
    }
    const target = cur.forward[0];
    if (!target || target.val !== val) return false;
    for (let i = 0; i <= this.level; i++) {
      if (update[i].forward[i] !== target) break;
      update[i].forward[i] = target.forward[i];
    }
    while (this.level > 0 && !this.header.forward[this.level]) this.level--;
    this.size--;
    return true;
  }

  rangeQuery(lo, hi) {
    let cur = this.header;
    for (let i = this.level; i >= 0; i--) {
      while (cur.forward[i] && cur.forward[i].val < lo) cur = cur.forward[i];
    }
    const results = [];
    cur = cur.forward[0];
    while (cur && cur.val <= hi) {
      results.push(cur.val);
      cur = cur.forward[0];
    }
    return results;
  }
}

Common Mistakes

โš ๏ธ Beginner Pitfalls with Skip Lists
  • Not recording update pointers during search phase: Both insert and delete require knowing the predecessor at every level. If you forget to track update[] during the downward search, you can't splice correctly. Always collect update pointers even if you're just doing a search-then-insert.
  • Off-by-one in level management: When a new node's level exceeds the current max level, you need to set update[i] = header for all new levels. Forgetting this means those higher-level pointers aren't connected, breaking the structure silently.
  • Using p=0.5 for memory-constrained applications: p=0.5 means half the nodes have 2+ pointers, a quarter have 3+, etc. That's about 2 pointers per node on average. If memory matters, use p=0.25 (Redis's choice) for ~1.33 pointers per node, with only slightly slower searches.
  • Not handling duplicates: The basic skip list doesn't define behavior for duplicate values. Decide upfront: allow duplicates (insert always), skip duplicates (check before inserting), or update metadata on duplicates (e.g., increment a count).
  • Forgetting to shrink the max level on delete: After deleting a tall node, the top levels might be empty (header.forward[level] is null). You need to decrement the level counter, or future searches waste time scanning empty levels.

Real-World Example: Redis-Style Sorted Set

Redis's ZSET (sorted set) stores elements with scores, supporting operations like "add an element with score," "get rank of element," and "get all elements with scores between 50 and 100." Under the hood, it's a skip list combined with a hash map. Here's a simplified version:

Python
class SortedSet:
    """Simplified Redis ZSET using a skip list + hash map.
    
    The skip list provides O(log n) sorted operations (rank, range).
    The hash map provides O(1) score lookups by member name.
    Redis uses this exact dual-structure approach.
    """

    def __init__(self):
        self.skiplist = SkipList()  # Stores (score, member) tuples
        self.scores = {}           # member โ†’ score (for O(1) lookup)

    def zadd(self, member, score):
        """Add member with score. If member exists, update its score."""
        if member in self.scores:
            # Remove old entry first (score may have changed)
            old_score = self.scores[member]
            self.skiplist.delete(old_score)
        self.scores[member] = score
        self.skiplist.insert(score)

    def zscore(self, member):
        """Get the score of a member. O(1)."""
        return self.scores.get(member)

    def zrangebyscore(self, min_score, max_score):
        """Get all members with scores in [min, max]. O(log n + k)."""
        scores_in_range = self.skiplist.range_query(min_score, max_score)
        # In a real implementation, the skip list nodes would store both
        # score and member. Here we do a reverse lookup for simplicity.
        result = []
        score_to_members = {}
        for member, score in self.scores.items():
            score_to_members.setdefault(score, []).append(member)
        for score in scores_in_range:
            for member in score_to_members.get(score, []):
                result.append((member, score))
        return result

    def zrem(self, member):
        """Remove a member. O(log n)."""
        if member not in self.scores:
            return False
        score = self.scores.pop(member)
        self.skiplist.delete(score)
        return True

    def zcard(self):
        """Number of members. O(1)."""
        return len(self.scores)


# Demo: game leaderboard
lb = SortedSet()
lb.zadd("alice", 2500)
lb.zadd("bob", 1800)
lb.zadd("charlie", 3200)
lb.zadd("dave", 2100)
lb.zadd("eve", 2800)

# Get players with scores between 2000 and 3000
mid_tier = lb.zrangebyscore(2000, 3000)
print("Mid-tier players (2000-3000):")
for member, score in mid_tier:
    print(f"  {member}: {score}")

# Update a score
lb.zadd("bob", 3500)  # Bob had a great game
print(f"\nBob's new score: {lb.zscore('bob')}")  # 3500

The real Redis ZSET is more sophisticated โ€” it stores both score and member in the skip list nodes, uses the member name as a tiebreaker for equal scores, and switches to a simpler ziplist encoding for small sets (fewer than 128 elements). But the skip list + hash map core is exactly as shown.

Interview Patterns

๐Ÿ’ก When Skip Lists Come Up in Interviews Skip lists are rarely asked as "implement one from scratch" (though LC 1206 does exactly that). They appear more as design questions: "How would you implement an ordered set that supports concurrent access?" or "Why did Redis choose skip lists over red-black trees?" Know the trade-offs, not just the code.

Pattern 1: Design an Ordered Collection

When asked to design a data structure that supports insert, delete, search, and range queries โ€” all in O(log n) โ€” skip list is a valid answer alongside BSTs. Mentioning it shows breadth of knowledge. Bonus: explain why it's simpler to implement than a red-black tree.

Pattern 2: Concurrency-Friendly Structure

Lock-free skip lists are simpler than lock-free balanced trees. The key insight: inserting at each level is independent. You can use CAS operations (Compare-And-Swap โ€” an atomic hardware instruction that updates a memory location only if it contains an expected value) per level without a global lock. Java's ConcurrentSkipListMap uses this approach.

๐Ÿ’ก Redis Design Insight Antirez chose skip lists for Redis sorted sets because: (1) they're easier to implement and debug, (2) extending them for ZRANGEBYSCORE (iterate in order within a range) is trivial โ€” just walk level 0, and (3) with p=0.25, they use about as much memory as a balanced tree. The "simpler to implement correctly" argument won.
๐Ÿ’ก Skip List vs. BST Trade-offs for System Design Skip lists: simpler code, natural concurrency, easy range queries, probabilistic guarantees. BSTs (red-black, AVL): deterministic worst-case guarantees, slightly better cache locality (nodes are denser), proven and widely available in standard libraries. In system design interviews, mentioning both and explaining when you'd choose which shows strong engineering judgment.
๐Ÿ’ก Pattern: In-Memory Ordered Index For system design problems like "design a time-series database" or "design a leaderboard," skip lists are a strong answer for the in-memory ordered index. They support: O(log n) insert/delete (scores change), O(log n + k) range queries (get top-K, get scores between X and Y), and efficient concurrent access (multiple threads updating scores simultaneously).

Practice Problems

ProblemDifficultyKey Idea
1206. Design Skiplist Hard Full skip list implementation โ€” search, insert, delete
2353. Design a Food Rating System Medium Ordered set operations โ€” can use skip list or sorted containers
703. Kth Largest Element in a Stream Easy Maintain sorted structure with insertions (heap is simpler here)
327. Count of Range Sum Hard Ordered data structure for counting elements in a range
480. Sliding Window Median Hard Insert/delete/find-median on a sorted structure