skip to content

Why does a skip-list insert cost no more than the search that precedes it?

level: middleimportance: should knowfreq 44%

answer

  1. What does the descent already visit?
  2. One node remembered per level
  3. Rightmost key smaller than the target
  4. Splice needs only those neighbours
  5. No invariant means nothing to repair

basics

~20 s

The descending search already visits, on every level, the last node whose key is smaller than the target. Recording those predecessors turns insertion into a few pointer rewires at the new tower's levels — no rebalancing, no traversal repeated, so insert is expected O(log n) dominated by the search.

solid answer

~50 s

A skip-list search walks right while the next key is smaller than the target and drops a level otherwise. The node it stands on at the moment it drops off level *k* is exactly the predecessor of the target at level *k*, so a search that records one node per level ends holding every insertion point at once. Insertion then draws the new node's height from coin flips and splices it in: for each level of its tower, point the new node's forward pointer where the recorded predecessor pointed and repoint that predecessor to the new node. Because the expected tower height is 1/(1−p) — about two at p = 1/2 — that splice is a constant number of pointer writes on average. There is no rotation, no height invariant to restore, no subtree to rebuild; the search dominates and the whole insert is expected O(log n).

code

pseudocode · 11 lines
pseudocode
x = head
for level = top downto 0:
    while x.next[level] != null and x.next[level].key < target:
        x = x.next[level]
    predecessor[level] = x

// lookup: check predecessor[0].next[0]
// insert: h = random_height()
//   for i in 0..h-1:
//       new.next[i] = predecessor[i].next[i]
//       predecessor[i].next[i] = new

go deeper

for a junior

Recall that inserting into a skip list means splicing pointers, not shifting elements, and that the position was already found by the search that preceded it.

for a middle

Be ready to state the descent's invariant — the current node's key is always below the target — and explain why the node held at each drop is exactly that level's predecessor.

for a senior

Demonstrate the boundary judgment: strict versus non-strict comparison in the walk, extending the head sentinel when a tower grows past the top level, and the mirror-image delete.

for a principal

Own the maintainability argument: an insert path with no structural case analysis is reviewable and portable to a concurrent design, which is often worth more to a team than a marginal constant-factor win.

## The search already knows the answer The cheapest way to see why skip-list insertion is inexpensive is to look at what the search leaves behind. ``` x = head for level = top downto 0: while x.next[level] != null and x.next[level].key < target: x = x.next[level] predecessor[level] = x // last node before target at this level // predecessor[0].next[0] is the first key >= target ``` The loop moves right while the next key is strictly smaller than the target, and otherwise drops one level. The invariant it maintains is: **`x.key < target`, and every key strictly between `x` and `target` lies below the current level.** So at the instant the loop leaves level *k*, `x` is the rightmost node on level *k* whose key is less than the target — its predecessor at that level. Capturing `x` into `predecessor[level]` costs one array store and nothing else. A plain lookup ignores that array and just inspects `predecessor[0].next[0]`. An insert uses the whole array. ## Splicing the new node in Insertion is then: 1. Draw a height *h* for the new node by repeated coin flips. 2. If *h* exceeds the current top level, treat the head sentinel as the predecessor on those new levels. 3. For each level *i* from 0 to *h*−1: set `new.next[i] = predecessor[i].next[i]`, then `predecessor[i].next[i] = new`. That is the entire structural change. Every touched pointer belongs either to the new node or to one of *h* predecessor nodes. Nothing else in the structure is read, moved, or rewritten. Because heights are geometric with parameter p, the expected height is 1/(1−p) — two at p = 1/2 — so the splice is **expected O(1) pointer writes**, and the search's expected O(log n) dominates the total. Deletion is the mirror image and uses the same recorded predecessors: for each level where `predecessor[i].next[i]` is the victim, skip it. ## Why there is nothing to rebalance This is the property that distinguishes a skip list from balanced search trees. A balanced tree maintains a structural invariant — a bound on height, or on the relationship between sibling subtrees — and an insertion that breaks it must be repaired by restructuring on the path back to the root. That restructuring is where the complexity of implementing balanced trees lives, and where their concurrent versions get hard: a repair can touch nodes far from the insertion point, potentially up to the root. A skip list maintains **no such invariant**. Its only shape requirement is the one randomness already satisfies in expectation, and a single insert cannot violate a rule that does not exist. So the modification is local by construction: a bounded, small set of neighbouring pointers, all of them adjacent to the insertion point. Two consequences follow directly: - **Implementation size.** The insert path is a search plus a loop of two-line splices, with no case analysis over structural configurations. It is dramatically smaller than a balanced-tree insert, and small code is code you can review. - **Concurrency.** Since a writer touches only a few adjacent pointers, fine-grained locking (lock just the predecessors involved) and lock-free designs (compare-and-swap each level's forward pointer, bottom level first, with logically-deleted marks for removal) are tractable. That is why skip lists appear as the ordered structure in concurrent settings far more often than their sequential performance alone would justify. ## The boundary cases worth naming - **Strict `<` matters.** If the walk used `<=`, it would step *past* a node with a key equal to the target, and the predecessor array would point one node too far right — inserting an equal key on the wrong side, or making a delete miss its victim. Off-by-one at this comparison is the classic skip-list bug. - **Growing the top.** When a drawn height exceeds the current maximum, the head sentinel must be extended and used as the predecessor on the new levels; forgetting this leaves the tall part of the new tower unreachable from the head, so the key is present but invisible above level 0. - **A head sentinel earns its keep here.** With a head node that has a forward pointer at every level, no insertion is ever a special "insert before everything" case; the predecessor at each level is always a real node. - **Bottom-level truth.** Every key is on level 0, so a search that has descended correctly can never miss a key regardless of what the upper levels look like — an important safety property when levels are being modified concurrently. ## The line to say out loud "The descent already computes the predecessor at every level; record it, and the insert is a constant number of pointer writes on top of the search — nothing to rebalance, because there is no invariant to break."

  • What breaks if the inner walk uses `<=` instead of `<` on the key comparison?
    The walk steps past a node whose key equals the target, so each recorded predecessor sits one node too far right. A lookup for an existing key then reports it missing, a delete skips its victim, and an insert of an equal key lands on the wrong side of the existing one — breaking any stability or ordering the caller expected. It is the canonical off-by-one here.
  • How does deletion reuse the same recorded path?
    Identically. Run the same descent to fill the predecessor array, then for each level i where `predecessor[i].next[i]` is the victim, set it to the victim's own forward pointer at that level. Levels above the victim's tower are untouched. If the top levels are now empty, the recorded maximum level can be lowered. Cost is expected O(log n) for the search plus expected constant pointer writes.
  • What must the insert do when the drawn height exceeds the current top level?
    Extend the head sentinel's pointer array to the new height and use the head as the predecessor on every newly created level, then raise the recorded top level. Skipping this leaves the tall portion of the new tower unreachable from the head: the key is still findable via level 0, so the structure stays correct but silently loses the shortcut it just paid to create.

saying these in an interview costs you the question

  • Thinks insertion needs a second traversal
  • Says the structure rebalances after each insert
  • Claims all towers must be re-flipped on insert
  • Forgets the head sentinel on newly created levels
  • Uses <= in the walk and shifts every predecessor

context