Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Skip List Insert & Search
A skip list gives you O(log n) search and insert with nothing but linked lists and a coin flip — the probabilistic structure behind Redis sorted sets and LevelDB’s memtable. Express lanes on top let you skip over most nodes. Here the node heights are given, so it’s fully deterministic. Solve it in Python or TypeScript, with hidden tests.
The problem
Implement a skip list supporting insert(value, height) and search(value). A skip list is a set of sorted linked lists stacked in levels: level 0 holds every node; each higher level is an "express lane" holding a subset. Normally the height of a new node is random; here the height is given so the result is deterministic. You get a list of ops like ["insert", 5, 1] (value 5 at height 1, i.e. present on levels 0 and 1) and ["search", 5]. Return the list of boolean results of every search.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement skiplist_ops(ops): keep a head sentinel with forward pointers per level. To insert, record the rightmost node you pass on each level (the "update" path), then splice the new node in on levels 0…height. To search, descend the same way and check the level-0 successor.
Examples and constraints 4 examples
ops = [["insert",5,1],["insert",3,2],["insert",8,0],["search",3],["search",4],["search",8]][True, False, True]ops = [["insert",4,0],["insert",6,2],["insert",2,1],["insert",8,3],["search",6],["search",8],["search",5]][True, True, False]ops = [["insert",10,3],["insert",10,0],["search",10],["search",9]][True, False]ops = [["search",7]][False]- A node inserted at height
happears on levels0…h(so height 0 = level 0 only). - Within every level the nodes stay sorted by value — insert into the correct position on each of its levels.
- Search starts at the top level and, at each level, moves right while the next value is smaller than the target, then drops down a level; the target is present iff the level-0 successor equals it.
Hints 4 ideas
- Use a fixed maximum number of levels (e.g. 16). The head is a sentinel whose forward[i] points to the first node on level i.
- For both insert and search, walk from the top level down: at each level advance while
next.value < target, then drop down — recording where you stopped on each level (the "update" array) for insert. - To insert at height
h, for each level0…hset the new node’s forward pointer toupdate[level].forward[level]and pointupdate[level].forward[level]at the new node. - A search succeeds iff, after descending to level 0, the very next node equals the target value.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious structure is the bottom level alone: one sorted linked list. Insert walks from the head until the next value is not smaller and splices the node in; search walks the same way and checks the next node. It is correct, because the list is always sorted, and it uses O(n) space. But every operation walks node by node, so each costs O(n) and n inserts cost O(n²): a search for the last of a million values follows a million pointers. A sorted array with binary search fixes the search but not the insert, which still shifts O(n) elements. The skip list keeps the linked list and adds express lanes above it.
The approach
Each level is a sorted linked list over a subset of the level below, so a search covers ground on the sparse upper levels and refines on the dense lower ones. The rule on every level: move right while the next value is smaller than the target, then drop down. The node where you drop down on level i is the last node there that is smaller than the target, which is exactly where a new node must be spliced in on that level. With random heights each level holds about half the nodes of the one below, which gives expected O(log n) steps.
In the reference, a node is [value, forward] and head is a sentinel with MAX_LEVEL = 16 forward pointers. descend(value, update) runs that loop from level 15 down to 0 and, when given an update list, stores the drop-down node of each level. An insert builds node = [value, [None] * (height + 1)] and, for each level i up to height, sets node[1][i] = update[i][1][i] and then update[i][1][i] = node. A search calls descend(value) and checks whether the level-0 successor nxt holds value.
Walk through an example
Trace the first example, where the middle column shows the node at which descend drops down on levels 2, 1 and 0 (levels 3 to 15 stay empty, so it never leaves head there).
| step | op | drops down at (L2, L1, L0) | result |
|---|---|---|---|
| 1 | insert 5, height 1 | head, head, head: the list is empty | L1: 5 · L0: 5 |
| 2 | insert 3, height 2 | head, head, head: 5 is not smaller than 3 | L2: 3 · L1: 3, 5 · L0: 3, 5 |
| 3 | insert 8, height 0 | 3, 5, 5 | L0: 3, 5, 8 |
| 4 | search 3 | head, head, head | successor 3 → True |
| 5 | search 4 | 3, 3, 3 | successor 5 → False |
| 6 | search 8 | 3, 5, 5 | successor 8 → True |
Complexity
Optimal: with random heights, expected O(log n) per insert and search, and expected O(n) space, about two forward pointers per node when each level keeps half the nodes. The reference also walks all 16 levels on every call, a constant, and with given heights its speed depends on them: all heights 0 is a plain list. Brute force (one sorted linked list): O(n) per operation, O(n) space.
Edge cases to test
- Searching an empty list:
[["search",7]]→[False].descendnever leaveshead, whose level-0 successor isNone. - Duplicate values:
[["insert",10,3],["insert",10,0],["search",10],["search",9]]→[True, False]. Both nodes are kept, and becausedescendstops before any value that is not smaller, the second 10 is spliced in front of the first on level 0. - A height of
MAX_LEVELor more:["insert",1,16]raisesIndexErrorin Python and aTypeErrorin TypeScript, becauseupdatehas only 16 entries. Height 15 is the tallest that works; clamp or reject anything above it. - All heights 0: every node lives on level 0 only, the express lanes stay empty, and each operation is a linear walk, which is the brute force again.
- Values at either end: inserting 1 after 5 finds
updateequal toheadon every level and becomes the first node, so[["insert",5,0],["insert",1,0],["search",1],["search",99]]→[True, False]; the search for 99 ends at the last node, whose successor isNone.
Common mistakes
- Not tracking the update/predecessor pointer for each level before splicing.
- Off by one on height: a node at height
hlives on levels0…h, so it needsh + 1forward pointers and the splice loop isrange(height + 1). - Tracking the current top level instead of always starting at level 15, as the reference does, and forgetting to raise it when a taller node arrives, which leaves the new express lanes unreachable.
- Moving right while the next value is less than or equal to the target: the descent then steps onto the target itself, the level-0 successor is the node after it, and the search reports it missing.
Follow-ups an interviewer asks next
- How do you choose heights when they are not given?
- Flip coins: start at height 0 and add a level while a random draw is below
p, up toMAX_LEVEL - 1. Withp = 1/2, a node reaches heightkwith probability2^-k, so each level holds about half the nodes below it. Redis usesp = 1/4and up to 32 levels, which averages about 1.33 forward pointers per node instead of 2. - How would you delete a value?
- Run the same descent to fill
update. The node to remove is the level-0 successor ofupdate[0], if it holds the value. Then, on every leveliwhereupdate[i]points at that node, copy the node’s own forward pointer for that level intoupdate[i]. It costs the same expectedO(log n)as an insert. - How do you support range queries and rank?
- For a range, descend to the first value at or above the low end, then walk level 0 until you pass the high end:
O(log n + k)forkresults. For rank, store on each forward pointer the number of level-0 nodes it skips, its span, and add the spans along the descent. Redis keeps exactly such spans, which is howZRANKruns in logarithmic time. - Why are skip lists popular in concurrent code?
- An insert only changes the forward pointers next to the new node, one level at a time, so it can be done bottom-up with one compare-and-swap per level, with none of the multi-node rotations a balanced tree needs. Java’s
ConcurrentSkipListMapworks this way, and LevelDB’s memtable is a skip list that readers traverse without locks while a single writer inserts.
Where this shows up
Skip lists are a simpler, more concurrency-friendly alternative to balanced trees — Redis sorted sets are built on them, and so is LevelDB’s memtable. Their randomized balancing achieves expected O(log n), matching what AVL or red-black trees guarantee in the worst case, without any of the rotation bookkeeping, which is why they are popular in lock-free designs.
Reference solution
Reference solution — Python
MAX_LEVEL = 16
def skiplist_ops(ops):
# a node is [value, forward-pointers]; the head sentinel has MAX_LEVEL forwards
head = [None, [None] * MAX_LEVEL]
out = []
def descend(value, update=None):
x = head
for i in range(MAX_LEVEL - 1, -1, -1):
while x[1][i] is not None and x[1][i][0] < value:
x = x[1][i] # move right while smaller
if update is not None:
update[i] = x # remember where we dropped down
return x
for op in ops:
if op[0] == "insert":
value, height = op[1], op[2]
update = [head] * MAX_LEVEL
descend(value, update)
node = [value, [None] * (height + 1)]
for i in range(height + 1): # splice in on levels 0..height
node[1][i] = update[i][1][i]
update[i][1][i] = node
else: # search
value = op[1]
x = descend(value)
nxt = x[1][0] # level-0 successor
out.append(nxt is not None and nxt[0] == value)
return out
print(skiplist_ops([["insert",5,1],["insert",3,2],["insert",8,0],
["search",3],["search",4],["search",8]])) # -> [True, False, True]Reference solution — TypeScript
const MAX_LEVEL = 16;
function skiplistOps(ops: any[][]): boolean[] {
// a node is { value, forward[] }; the head sentinel has MAX_LEVEL forwards
const head: any = { value: null, forward: new Array(MAX_LEVEL).fill(null) };
const out: boolean[] = [];
function descend(value: number, update: any[] | null): any {
let x = head;
for (let i = MAX_LEVEL - 1; i >= 0; i--) {
while (x.forward[i] !== null && x.forward[i].value < value) x = x.forward[i];
if (update) update[i] = x; // remember where we dropped down
}
return x;
}
for (const op of ops) {
if (op[0] === "insert") {
const value = op[1] as number, height = op[2] as number;
const update = new Array(MAX_LEVEL).fill(head);
descend(value, update);
const node: any = { value, forward: new Array(height + 1).fill(null) };
for (let i = 0; i <= height; i++) { // splice in on levels 0..height
node.forward[i] = update[i].forward[i];
update[i].forward[i] = node;
}
} else { // search
const value = op[1] as number;
const x = descend(value, null);
const nxt = x.forward[0]; // level-0 successor
out.push(nxt !== null && nxt.value === value);
}
}
return out;
}
console.log(skiplistOps([["insert",5,1],["insert",3,2],["insert",8,0],
["search",3],["search",4],["search",8]])); // -> [true,false,true]Questions about Skip List Insert & Search
How does a skip list achieve O(log n) search?
It stacks sorted linked lists on top of each other. The bottom level holds every value, and each higher level holds roughly half the values of the level below, chosen at random. A search starts on the top level, moves right while the next value is smaller than the target, then drops down a level and repeats. Because each level is about half as dense as the one below, a search makes only a couple of moves per level on average, and there are about log n levels, so the expected cost is O(log n). This challenge supplies the heights instead of drawing them at random.
Why use a skip list instead of a balanced binary search tree?
Both give logarithmic search and insert, but a skip list is much simpler to implement: an insert is one descent plus a few pointer updates per level, with no rotations and no color rules. It also suits concurrent code, since an insert only touches the pointers next to the new node. The costs are that its bounds hold in expectation rather than in the worst case, and that following pointers is less cache-friendly than an array-based structure. Redis sorted sets, LevelDB’s memtable and Java’s ConcurrentSkipListMap all use skip lists.
What is the update array in skip list insertion?
It records, for each level, the last node the search passed that is smaller than the value being inserted, which is the node where the descent dropped down. Those are exactly the nodes whose forward pointers must change: on each level the new node takes over its predecessor’s forward pointer, and the predecessor then points at the new node. Recording them during the single descent avoids searching again on every level. In the reference solution, descend fills update, and the splice loop then runs from level 0 up to the new node’s height.
How are skip list node heights chosen?
Normally by coin flips. A new node starts at height 0, and while a random draw comes up below a probability p, usually one half or one quarter, its height grows by one, up to a fixed maximum. That makes taller nodes exponentially rarer, so each level holds about p times as many nodes as the level below, which keeps searches logarithmic in expectation, and nothing ever has to rebalance the list. This challenge passes the heights in with each insert instead, so the structure, and therefore the test results, are deterministic.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →