You need ordered keys and logarithmic lookup. A balanced tree will do it — after you keep the height honest with rotations. Sorted input, deletes, and concurrent writers all become rotation stories. Teams that wanted a map paid for a balancing algorithm.

A skip list is a layered linked list: level 0 holds every key; higher levels skip ahead. Search drops from express lanes to local streets. Height comes from coin flips, not from rotating a parent. Expected search, insert, and delete are O(log n) without a tree.

ADT vs implementation, expected vs worst-case, and how to read a complexity table live on the Data Structures Roadmap. This post stays on one layout: the layers, the coin, search and insert, and the JDK type that ships it.

Levels are express lanes on a sorted list

Level 0 is a sorted singly linked list of every key. Each higher level is another sorted list over a subset of those keys. A node that appears on level 2 also appears on 1 and 0 — it is the same key with extra forward pointers.

L2:  HEAD ----------------> 50 --------------------> NIL
L1:  HEAD ------> 20 -----> 50 -----> 70 ----------> NIL
L0:  HEAD -> 10 -> 20 -> 30 -> 50 -> 70 -> 90 -----> NIL

20 is an express stop: from HEAD on L1 you jump to 20 instead of walking 10. 50 is on every level — a long-haul lane. 30 lives only on L0; you only meet it after you have already narrowed the range.

That is the whole trade: extra pointers buy skips; the list stays sorted; nobody rotates a subtree. Space is expected O(n) because most nodes have one or two forwards, not a tower of height n.

A node is a key plus an array of next pointers, one per level it participates in:

final class Node {
    final int key;
    final Node[] next; // next[i] = successor on level i

    Node(int key, int height) {
        this.key = key;
        this.next = new Node[height];
    }
}

Height 1 means L0 only. Height 3 means L0, L1, and L2. The list head is a sentinel with the maximum height and no key — every search starts there.

Coin-flip height: expected log, not a rotation

When you insert a key, you do not pick a clever slot in a tree. You splice it into L0 (it belongs in sorted order), then decide how many express lanes it joins. The usual rule is a fair coin: stay on the next level with probability 1/2, else stop.

int randomHeight(int maxHeight, Random rng) {
    int h = 1;
    while (h < maxHeight && rng.nextBoolean()) {
        h++;
    }
    return h;
}

Expected height of a node is 2. Expected maximum height of the whole list is O(log n) if you cap maxHeight around log2 n plus a small constant (JDK skip maps use a fixed cap, on the order of 32 or 64). A node of height h appears on levels 0 .. h-1.

Note: The coin is why skip lists are expected O(log n), not worst-case O(log n). A pathological run of heads builds a tall sparse tower; a run of tails leaves L0 as a plain list and search walks it. With an independent coin and a height cap, that is rare. It is still not a height invariant you can prove the way AVL proves |balance| ≤ 1.

No rotation, no color bit, no “left-left case.” The random bits are the balance.

Search: right until you would pass, then drop

Start at the head on the highest occupied level. Walk right while the next key is still ≤ the target (or <, if you treat equals as “found”). When the next key would overshoot — or the next pointer is null — drop one level and keep going. On L0, either you land on the key or you know it is missing.

boolean contains(Node head, int topLevel, int key) {
    Node node = head;
    for (int level = topLevel; level >= 0; level--) {
        while (node.next[level] != null && node.next[level].key < key) {
            node = node.next[level];
        }
    }
    Node candidate = node.next[0];
    return candidate != null && candidate.key == key;
}

Search for 30 on the diagram:

L2: HEAD, next is 50 > 30 → drop
L1: HEAD → 20 (20 < 30), next is 50 > 30 → drop
L0: 20 → 30  found

Search for 40 (absent): same walk to 30 on L0; next is 50, so not present. Each drop throws away a span of keys you no longer need to scan. With geometric height, the expected number of right-steps per level is constant, so the whole walk is expected O(log n).

Insert: search for predecessors, then splice

Insert is search that remembers the last node visited at each level — those are the predecessors whose next pointers will change. Then coin-flip the new height, allocate the node, and relink.

void insert(Node head, int topLevel, int key, Random rng) {
    Node[] pred = new Node[topLevel + 1];
    Node node = head;
    for (int level = topLevel; level >= 0; level--) {
        while (node.next[level] != null && node.next[level].key < key) {
            node = node.next[level];
        }
        pred[level] = node;
    }
    if (node.next[0] != null && node.next[0].key == key) {
        return; // already present
    }
    int height = randomHeight(topLevel + 1, rng);
    Node fresh = new Node(key, height);
    for (int level = 0; level < height; level++) {
        fresh.next[level] = pred[level].next[level];
        pred[level].next[level] = fresh;
    }
}

Insert 25 with a coin that lands height 2 (L0 and L1):

predecessors: L2 stays HEAD (25 < 50, never spliced at L2)
              L1 is HEAD (next is 20; 20 < 25, then 50 > 25 → pred = 20)
              L0 is 20
splice L0: 20 → 25 → 30
splice L1: 20 → 25 → 50

Delete is the same predecessor walk, then bypass the node at every level it occupies. You do not “fix” height afterward; the remaining towers still skip. That is why skip lists stay simple under mutation: splice pointers, do not restore a global shape invariant.

OperationExpectedWorst-case (degenerate coins)
SearchO(log n)O(n)
InsertO(log n)O(n)
DeleteO(log n)O(n)
SpaceO(n)O(n) with a height cap

Read that table the hub’s way: expected is the production bill with a real coin. Worst-case is the “every insert got height 1” list. If you need the worst-case column to be O(log n) always, this is the wrong layout.

ConcurrentSkipListMap is the JDK type

Java does not ship a public SkipList class. The production type is java.util.concurrent.ConcurrentSkipListMap (and ConcurrentSkipListSet on top of it): a concurrent, sorted NavigableMap whose keys sit in a skip list.

NavigableMap<Integer, String> ranks = new ConcurrentSkipListMap<>();
ranks.put(50, "mid");
ranks.put(20, "low");
ranks.put(70, "high");

ranks.get(20);           // "low" — expected O(log n)
ranks.ceilingKey(35);    // 50
ranks.subMap(20, 70);    // 20, 50 — ordered view

Use it when several threads read and write an ordered map without wrapping TreeMap in a lock. Iterators are weakly consistent. null keys are forbidden. Cost is expected logarithmic, not hash-table constant time.

Redis sorted sets use the same idea: a hash table for key → score, plus a skip list for rank and range by score. You do not implement either from scratch for a leaderboard; you recognize the layout when the docs say “skip list.”

Note: Sequential sorted maps in the JDK are still TreeMap / TreeSet (red-black). Reach for ConcurrentSkipListMap when the job is concurrent and ordered. Do not pick it as a faster HashMap.

When not to use a skip list

Skip the skip list when the job is not “ordered keys, expected log n, no rotations”:

  • You needed unique keys and fast lookup, not order. That is a hash table — HashMap / ConcurrentHashMap. ceilingKey and subMap are how you know you wanted a skip list (or a tree). get alone is not.
  • You need deterministic worst-case O(log n). AVL (and red-black) prove height. A skip list expects log n. Hard real-time, or a latency SLO that treats a linear walk as an incident, wants a balanced tree, not a coin.
  • n is tiny and a sorted list is already there. Binary search on an ArrayList you rebuild rarely is simpler than explaining towers. Measure when the set mutates in the hot path.
  • Single-threaded sorted map, JDK default. TreeMap is the sequential type. ConcurrentSkipListMap is the concurrent one. Swapping them to “use a skip list” without a concurrency story is cargo-culting the name.

Range queries over a mutable ordered set are the job this layout is for. Approximate membership, prefix search, and next-best element are other layouts — pick them from the hub by the hot operation, not by how clever the balancing looks.

Cheat sheet

Skip list:    sorted linked list + higher levels that skip
L0:           every key, sorted
Higher:       subset; same key, extra forward pointers
Height:       coin flip (p = 1/2 per extra level); cap it
Search:       right until overshoot, drop a level; expected O(log n)
Insert:       predecessor walk, splice, coin-flip height
Delete:       predecessor walk, bypass
Not a tree:   no rotations; balance is randomness
Expected:     O(log n) search / insert / delete; not a worst-case law
JDK:          ConcurrentSkipListMap / ConcurrentSkipListSet
Also:         Redis sorted sets (skip list + hash)
Avoid:        HashMap jobs (no order); AVL when worst-case must be log n

Do:

  • Name the hot operation: ordered lookup / range as the set mutates, possibly concurrently.
  • Use ConcurrentSkipListMap when that sentence is true in Java.
  • Treat the coin as expected cost; cap height so a tower cannot become n.

Don’t:

  • Replace HashMap with a skip list because both are “fast lookup.”
  • Promise worst-case O(log n) the way AVL does.
  • Implement a skip list on Friday when the JDK already has ConcurrentSkipListMap.

Wrap-up

A skip list is a sorted linked list with express lanes. Level 0 holds every key. Higher levels skip. Search walks right, then drops. Insert splices after a predecessor walk and lets a coin pick the new node’s height. Expected cost is O(log n) without rotations.

The JDK type is ConcurrentSkipListMap. Redis sorted sets use the same layout. Reach for a hash map when you do not need order. Reach for AVL (or another balanced tree) when the worst-case column has to be logarithmic, not expected.

If the hot path is ordered keys and you would rather flip a coin than rotate a subtree, a skip list is the layout that makes that cheap. The series hub is the glossary and index when you need to match a different job to a different layout.

Next optional step in the series Tiny membership tests that are allowed to be wrong one way. Bloom Filters: Maybe-Yes, Definitely-No