Learn Zig Series (#107) - Skip Lists
Learn Zig Series (#107) - Skip Lists
What will I learn?
- Why a skip list is what you get when you take last episode's linked list and let each node hold more than one forward pointer -- and why that single change drops search from O(n) to O(log n) expected, with no tree rotations anywhere in sight;
- How randomness does the balancing here: each node flips a coin to decide how tall it is, and the probability distribution alone keeps the structure balanced on average without a single rebalancing rule;
- How to build a working skip list from scratch in Zig -- node towers as allocator-owned slices (episodes 7 and 26), generic over
comptime T(episode 14), searched, inserted into, and removed from, all with the?*Nodeoptionals you now know cold from episode 106; - Why the search, insert, and remove operations all share the same descent loop, and how a small
updatearray is the trick that makes insert and remove fall out of that loop almost for free; - How to test a probabilistic structure so the test is deterministic (seed the RNG) and still proves ordering, membership, and zero leaks via
std.testing.allocator(episode 12); - Where skip lists actually earn their keep in the wild (Redis sorted sets are the famous one) and when a balanced tree or a plain sorted array beats them;
- How this maps onto the equivalents in C, Rust, and Go, and why the "no rotations" property makes skip lists the structure people reach for when concurrency enters the picture.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- Episode 106 fresh in your head -- a skip list is literally a linked list with taller nodes, so if singly/doubly lists,
?*Node, and allocator ownership are fuzzy, re-read that one first; - Comfort with generics via comptime (episode 14) and allocators (episodes 7 and 26), since we build these towers over an arbitrary
Tand own every byte; - The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists (this post)
Learn Zig Series (#107) - Skip Lists
Last episode I closed with a promise: let a node hold more than one forward pointer, pointers that skip ahead by different strides, and the humble linked list stops being a slow O(n) walk and becomes something you can search in logarithmic time. That structure has a name -- the skip list -- and it is one of my favourite things to teach, because it delivers balanced-tree performance without a single line of the rotation logic that makes balanced trees miserable to write. It gets there by cheating in the most honest way imaginable: it flips coins. Before we build it, let me clear the small debt from episode 106.
Solutions to Episode 106 Exercises
Exercise 1 -- reverse a singly linked list in place. The classic three-pointer walk: keep the node you already flipped (prev), the node you're on (curr), and stash next before you overwrite the link, or you saw off the branch you're standing on. No allocations, no new nodes, just rewiring.
// Add this inside SinglyLinkedList(comptime T: type). We reverse by walking once and
// flipping each node's `next` to point at its predecessor. `next` MUST be read before
// we overwrite `node.next`, or the rest of the list vanishes -- same "save before you
// clobber" discipline as the deinit loop from last episode.
fn reverse(self: *Self) void {
var prev: ?*ListNode = null;
var curr = self.head;
while (curr) |node| {
const next = node.next; // stash the rest of the list before we cut the link
node.next = prev; // flip this node to point backward
prev = node; // advance the "already reversed" cursor
curr = next; // advance the "still to do" cursor
}
self.head = prev; // the old tail is the new head
}
The empty and single-node cases need no special handling -- with an empty list curr is null and the loop never runs; with one node it runs once, sets that node's next to null (which it already was), and reassigns head to itself. Elegant precisely because it doesn't special-case anything.
Exercise 2 -- popBack and insertAfter for the doubly linked list. popBack is almost free because we already wrote O(1) remove last episode: grab the tail's value, then let remove do the splicing. insertAfter is the mirror of pushBack, patching four pointers and minding the case where we insert after the current tail.
// Both go inside DoublyLinkedList(comptime T: type) from episode 106.
// popBack: the tail's value out, then reuse remove() -- no need to duplicate splice logic.
fn popBack(self: *Self) ?T {
const node = self.tail orelse return null;
const value = node.value;
self.remove(node); // O(1), and it frees the node for us
return value;
}
// insertAfter: splice a fresh node in right behind `node`. The one case to get right is
// inserting after the current tail -- then there is no `node.next`, and the fresh node
// becomes the new tail.
fn insertAfter(self: *Self, node: *ListNode, value: T) !*ListNode {
const fresh = try self.allocator.create(ListNode);
fresh.* = .{ .value = value, .prev = node, .next = node.next };
if (node.next) |n| n.prev = fresh else self.tail = fresh;
node.next = fresh;
self.len += 1;
return fresh;
}
Exercise 3 -- a working LRU cache on top of the doubly linked list plus a hash map (episode 22). The hash map gives O(1) "where is this key", the list gives O(1) "make this the most-recent" and O(1) "evict the least-recent". Neither can do it alone; married, they're the LRU workhorse.
// A real, compilable LRU cache. Uses episode 106's DoublyLinkedList for recency order
// and a std.AutoHashMap for lookup. On a hit we move the node to the back (most recent);
// on overflow we evict the front (least recent). Note the honest trade: because our
// ep106 remove() frees the node, "move to back" is remove-then-pushBack rather than an
// in-place relink. A production list would expose an unlink-without-free to avoid that.
fn LruCache(comptime K: type, comptime V: type) type {
return struct {
const Self = @This();
const Entry = struct { key: K, value: V };
const List = DoublyLinkedList(Entry);
map: std.AutoHashMap(K, *List.ListNode),
order: List,
capacity: usize,
fn init(allocator: std.mem.Allocator, capacity: usize) Self {
return .{
.map = std.AutoHashMap(K, *List.ListNode).init(allocator),
.order = List.init(allocator),
.capacity = capacity,
};
}
fn deinit(self: *Self) void {
self.order.deinit(); // frees every node
self.map.deinit();
}
fn get(self: *Self, key: K) ?V {
const node = self.map.get(key) orelse return null;
const entry = node.value; // copy out before remove frees the node
self.order.remove(node);
const fresh = self.order.pushBack(entry) catch return null;
self.map.put(key, fresh) catch {}; // re-point the map at the new node
return entry.value;
}
fn put(self: *Self, key: K, value: V) !void {
if (self.map.get(key)) |node| { // overwrite: drop the old node first
self.order.remove(node);
_ = self.map.remove(key);
}
const node = try self.order.pushBack(.{ .key = key, .value = value });
try self.map.put(key, node);
if (self.order.len > self.capacity) { // over budget: evict least-recent
const oldest = self.order.head.?;
const old_key = oldest.value.key;
self.order.remove(oldest);
_ = self.map.remove(old_key);
}
}
};
}
That's the debt paid. Nota bene: exercise 3 is the seed of a pattern -- "a fast index married to an ordered chain" -- that comes back hard today, because a skip list is exactly that idea folded in on itself. Let's build it.
The one-sentence intuition
Imagine a sorted linked list of a thousand nodes. Finding a value means walking up to a thousand next pointers -- O(n), miserable. Now imagine you keep a second list on top that contains only every other node, with each of those nodes pointing both down into the full list and forward to the next node in the express lane. To search, you ride the express lane until you overshoot, drop down one level, and continue. You've halved the walk. Stack another express lane on top of that, holding every fourth node, and you halve it again. Keep stacking and each level doubles your stride, so a list of n elements needs about log2(n) levels and a search touches only a handful of nodes per level. That is a skip list: a tower of sorted linked lists, sparse at the top, dense at the bottom, where the bottom level holds everything and every level above is a shrinking express lane.
The genius bit -- the thing that makes it practical rather than a bookkeeping nightmare -- is how we decide which nodes ride which express lanes. A rigid "every other node" scheme falls apart the instant you insert or delete, because you'd have to renumber half the structure to keep the levels perfectly spaced. So we don't keep them perfectly spaced. When a new node arrives, it flips a coin: heads, it gets promoted to the next level up and flips again; tails, it stops. A node's height is just how many heads it flipped in a row (plus one). On average half the nodes reach level 2, a quarter reach level 3, an eighth reach level 4, and so on -- which is exactly the density the ideal express-lane scheme wanted, achieved with zero coordination and zero rebalancing. Randomness does the balancing for free. That still delights me after all these years ;-)
The node is a tower, not a link
In episode 106 a node had one next. Here a node has a whole array of forward pointers -- one per level it participates in -- and its length is its height. A tall node reaches into the high express lanes; a short node only exists on the ground floor. Because heights vary per node and are decided at runtime, that array has to be a heap-allocated slice (episode 5's slices, episode 7's allocator), sized when the node is born.
const std = @import("std");
// A skip-list node carries its value and a SLICE of forward pointers -- forward[i] is
// "the next node at level i". The slice length is this node's height, decided by coin
// flips at insert time. forward[0] threads the complete bottom-level list (every node);
// higher indices are the sparse express lanes. Each entry is a ?*Node -- the same
// optional pointer from episode 106, meaning "next at this level, or the honest end".
fn skipNode(comptime T: type) type {
return struct {
value: T,
forward: []?*@This(),
};
}
Compare that to last episode's next: ?*Node. The element type is identical -- an optional pointer, "a next node or nothing". We've just gone from one of them to a slice of them. Everything you learned about peeling optionals with if/orelse applies unchanged; there's simply more of it, indexed by level.
The list, its sentinel head, and coin flips
The manager type holds a sentinel head -- a dummy node whose value is meaningless but whose forward slice is full-height (max_level entries), so it can point into every express lane at once. Searches always start from this head. We also carry the current highest level actually in use (so we don't waste time descending through empty top lanes), a length, an allocator, and a seeded random number generator. Seeding it explicitly matters enormously for testing, as we'll see.
fn SkipList(comptime T: type, comptime lessThan: fn (a: T, b: T) bool) type {
return struct {
const Self = @This();
const Node = skipNode(T);
const max_level: usize = 16; // supports ~2^16 = 65k elements comfortably at p=0.5
const p: f64 = 0.5; // promotion probability -- the coin's bias
head: *Node, // sentinel: value is unused, forward has max_level entries
level: usize, // highest level currently in use (1 = ground floor only)
len: usize,
allocator: std.mem.Allocator,
rng: std.Random.DefaultPrng,
fn init(allocator: std.mem.Allocator, seed: u64) !Self {
const head = try allocator.create(Node);
head.forward = try allocator.alloc(?*Node, max_level);
@memset(head.forward, null); // head points nowhere until we insert
head.value = undefined; // sentinel value is never read
return .{
.head = head,
.level = 1,
.len = 0,
.allocator = allocator,
.rng = std.Random.DefaultPrng.init(seed),
};
}
// Coin-flip height: start at 1, and while the coin says "promote" (and we haven't
// hit the ceiling), climb. At p=0.5 this yields height>=2 half the time, >=3 a
// quarter of the time, ... -- precisely the express-lane density we want.
fn randomLevel(self: *Self) usize {
var lvl: usize = 1;
while (self.rng.random().float(f64) < p and lvl < max_level) : (lvl += 1) {}
return lvl;
}
fn createNode(self: *Self, value: T, height: usize) !*Node {
const node = try self.allocator.create(Node);
node.forward = try self.allocator.alloc(?*Node, height);
@memset(node.forward, null);
node.value = value;
return node;
}
};
}
Passing lessThan as a comptime function parameter (episode 14) is how we stay generic over ordering without hard-coding <. The standard library's ordered containers take a comparator for the same reason: T might be a struct you want sorted by one field, and only you know the order. For u32 you'd pass a two-line "a is less than b" function and be done.
Search: descend and slide
Every operation on a skip list -- find, insert, remove -- begins with the same descent. Start at the sentinel head on the highest level in use. Slide forward along that level as long as the next node's value is still less than your target. The moment the next node would overshoot (or the lane ends), drop down one level and keep sliding. When you fall off the bottom level, the node immediately to your right is the only possible match. That's the entire algorithm, and it's genuinely just a staircase: right, right, down, right, down, right.
// find: descend from the top express lane, sliding right while the next value is still
// too small, dropping a level each time we'd overshoot. After the bottom level, the
// candidate is exactly forward[0] of wherever we stopped. Returns the node or null.
fn find(self: *Self, value: T) ?*Node {
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1; // levels are 0-indexed; self.level is a count, so pre-decrement
while (node.forward[i]) |next| {
if (lessThan(next.value, value)) node = next else break;
}
}
// `node` is now the greatest node strictly less than `value` (or the head).
const candidate = node.forward[0] orelse return null;
// Equal means: neither less than the other. That's how we express "==" using only lessThan.
if (!lessThan(candidate.value, value) and !lessThan(value, candidate.value)) return candidate;
return null;
}
Notice the little trick at the end: we only have a lessThan, no equals, so we express equality as "neither is less than the other". That's a standard move when you build ordered structures from a single comparator, and it's worth internalising -- it keeps the interface minimal and it's how the real containers do it too. The expected cost of this descent is O(log n), because we spend O(1) sliding per level on average and there are O(log n) levels. The worst case is O(n) -- if the coins conspire to make every node the same height you've got a plain linked list again -- but the probability of that decays so fast it's a non-issue in practice, which is the whole probabilistic bargain.
Insert: the descent, remembered
Insert is the search you just wrote, with one addition: as you descend, you remember the last node you stood on at each level -- the node whose forward pointer at that level will need rewiring to point at the newcomer. We stash those in an update array indexed by level. Once we've found the insertion point, we roll a height for the new node, patch any brand-new top levels to start at the head, and splice the node into every level up to its height. It's the doubly-linked splice from last episode, done once per level the node reaches.
// insert: descend while recording, in `update[i]`, the last node we passed at level i --
// each of those is a node whose forward[i] must be rerouted through the newcomer. Then
// roll a height, extend the active level if this node is the new tallest, and splice.
fn insert(self: *Self, value: T) !void {
var update: [max_level]*Node = undefined;
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1;
while (node.forward[i]) |next| {
if (lessThan(next.value, value)) node = next else break;
}
update[i] = node; // remember the pre-insertion node at THIS level
}
const lvl = self.randomLevel();
if (lvl > self.level) {
// The new node is taller than anything so far: the new top lanes start at the head.
var j = self.level;
while (j < lvl) : (j += 1) update[j] = self.head;
self.level = lvl;
}
const fresh = try self.createNode(value, lvl);
var k: usize = 0;
while (k < lvl) : (k += 1) {
fresh.forward[k] = update[k].forward[k]; // new node inherits what update[k] pointed to
update[k].forward[k] = fresh; // and update[k] now points at the new node
}
self.len += 1;
}
Read the splice loop against episode 106's pushBack and it's the same two-line dance -- "new node inherits the old link, old node points at new node" -- just repeated for each level the coin granted. This implementation allows duplicate values (a new equal key simply lands next to the old one); if you wanted set semantics you'd check for an existing match before inserting and update in place instead. I'll leave that flavour to the exercises.
Remove: descend, unhook every level
Remove is insert's twin. Same descent, same update array. Once we've descended, the node to unlink (if it exists) is update[0].forward[0]. We walk up from the ground floor unhooking it at each level it participates in -- and we stop the moment a level's forward pointer isn't the victim, because that means the victim never reached this level. Finally we free the node's tower and the node itself (episode 7's ownership rule: whoever allocated it frees it), and we trim any now-empty top levels so future searches don't descend through dead lanes.
// remove: find the node via the recorded update[] path, then unhook it level by level.
// Returns true if a matching node was found and freed, false if it wasn't present.
fn remove(self: *Self, value: T) bool {
var update: [max_level]*Node = undefined;
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1;
while (node.forward[i]) |next| {
if (lessThan(next.value, value)) node = next else break;
}
update[i] = node;
}
const target = update[0].forward[0] orelse return false;
if (lessThan(target.value, value) or lessThan(value, target.value)) return false; // not equal -> not present
var k: usize = 0;
while (k < self.level) : (k += 1) {
if (update[k].forward[k] != target) break; // target didn't reach this high; done
update[k].forward[k] = target.forward[k]; // splice the express lane past it
}
self.allocator.free(target.forward); // free the tower slice...
self.allocator.destroy(target); // ...then the node (order matters: read before free)
// Trim empty top levels so we don't descend through dead air on the next search.
while (self.level > 1 and self.head.forward[self.level - 1] == null) : (self.level -= 1) {}
self.len -= 1;
return true;
}
The update[k].forward[k] != target guard is the subtle line. A short node only lives on the low levels, so above its height the express lanes point over it to something else. The instant we see a level that doesn't point at our victim, we know the victim isn't up there and we're finished -- no need to climb to max_level blindly. And as always, we read target.forward[k] before we free target, the exact use-after-free trap I waved both hands at last episode.
Cleaning up the whole structure is the same ground-floor walk as a plain linked list, because forward[0] threads every node in order. We free each node's tower slice, then the node, then the sentinel head's slice and the head itself.
// deinit: forward[0] is the complete bottom-level list, so one walk visits every node.
// Free each node's tower slice AND the node; finally free the sentinel head.
fn deinit(self: *Self) void {
var it = self.head.forward[0];
while (it) |node| {
it = node.forward[0]; // read next BEFORE freeing (episode 106's lesson)
self.allocator.free(node.forward);
self.allocator.destroy(node);
}
self.allocator.free(self.head.forward);
self.allocator.destroy(self.head);
}
Testing a random structure deterministically
Here's the wrinkle a probabilistic structure adds: if the RNG is truly random, your test does something slightly different every run, and a test you can't reproduce is a test you can't trust. The fix is the same one game developers and simulation writers use -- seed the generator with a fixed value. Same seed, same sequence of coin flips, same tower heights, byte-for-byte identical structure every single run. The randomness that balances the structure at runtime becomes perfectly repeatable under test. Pair that with std.testing.allocator (episode 12) and you get the two things that matter: correct ordering and proof that not one node leaked.
fn u32Less(a: u32, b: u32) bool {
return a < b;
}
test "skip list: ordered membership and no leaks" {
var list = try SkipList(u32, u32Less).init(std.testing.allocator, 0x5EED); // fixed seed!
defer list.deinit(); // testing.allocator fails the test if this frees too little
// Insert deliberately out of order -- the skip list must sort them for us.
const values = [_]u32{ 42, 7, 99, 7, 13, 3, 56 };
for (values) |v| try list.insert(v);
try std.testing.expectEqual(@as(usize, values.len), list.len); // duplicates allowed
// Everything we put in is findable...
try std.testing.expect(list.find(3) != null);
try std.testing.expect(list.find(99) != null);
// ...and things we never inserted are not.
try std.testing.expect(list.find(100) == null);
try std.testing.expect(list.find(0) == null);
// The bottom level MUST be fully sorted ascending -- the load-bearing invariant.
var it = list.head.forward[0];
var prev: u32 = 0;
while (it) |node| : (it = node.forward[0]) {
try std.testing.expect(node.value >= prev);
prev = node.value;
}
// Remove present and absent keys, and confirm the counts and membership react.
try std.testing.expect(list.remove(13)); // present -> true
try std.testing.expect(!list.remove(1000)); // absent -> false
try std.testing.expectEqual(@as(usize, values.len - 1), list.len);
try std.testing.expect(list.find(13) == null);
}
That walk over forward[0] checking node.value >= prev is the assertion I'd defend hardest in review: it proves the single invariant everything else depends on -- the ground floor is sorted. If insert botched a splice, this catches it instantly, in stead of letting a subtly corrupt structure limp along and fail somewhere unrelated three functions later. And because we seeded the RNG, this test is as deterministic as any test over a sorted array, despite the coins flipping under the hood.
Performance: the honest numbers
Skip lists give you expected O(log n) search, insert, and delete -- the same asymptotic class as a balanced binary search tree. The word "expected" is doing real work: it's an average over the coin flips, not a guarantee. There's a vanishingly small chance the RNG produces a pathological all-same-height structure that degrades to O(n), but "vanishingly small" here means probabilities like 2 raised to negative large numbers -- less likely than your machine being hit by a cosmic ray mid-search. In exchange for that theoretical wart, you get code you can actually write correctly on the first try, which is not something anyone says about red-black trees.
The cache story is the same uncomfortable truth from last episode, and I won't sugar-coat it: a skip list is a pointer-chasing structure, so every hop is a potential cache miss (episode 34's territory), and against a plain sorted array searched by binary search it loses on raw lookup speed because the array is contiguous and cache-friendly. So why does anyone use one? Because a sorted array is O(n) to insert into the middle -- you have to shove everything over -- while a skip list splices in O(log n) without moving a byte. The skip list wins precisely when you need a container that stays sorted and mutates constantly: frequent inserts and deletes interleaved with lookups and occassional range scans. If your data is write-once-read-many, sort an array and binary-search it; if it churns, the skip list earns its pointers.
Real-world applications
The headline user is Redis. Its sorted set type -- the thing behind every leaderboard, every "top N by score", every priority queue people bolt onto Redis -- is a skip list underneath (paired with a hash map, exactly the marriage from exercise 3, so it can do both O(1) lookup-by-member and O(log n) ordered range queries). Redis's creator picked a skip list over a balanced tree explicitly because it's simpler to implement, simpler to get right, and just as fast in practice. When you run ZADD and ZRANGE, you're driving the structure you just built.
Beyond Redis, skip lists show up in the memtables of LSM-tree databases like LevelDB and RocksDB -- the in-memory sorted buffer that absorbs writes before they're flushed to disk needs fast ordered inserts, which is the skip list's sweet spot. They also turn up in concurrent-data-structure libraries (Java's ConcurrentSkipListMap is the famous one), and the reason ties directly back to our design: because there are no rotations, a lock-free concurrent skip list is achievable, whereas a lock-free balanced tree is a research paper you probably don't want to implement on a deadline. The absence of rebalancing isn't just a convenience for you and me -- it's what makes the structure friendly to parallelism.
How this compares to C, Rust, and Go
In C, a skip list is a comfortable afternoon's work -- William Pugh's original 1990 paper includes C-flavoured pseudocode that's almost copy-paste ready, and the whole thing is maybe a hundred lines. The catch is the same as every C data structure: the forward-pointer arrays are raw malloc'd memory with no bounds checking, an off-by-one in the level indexing is silent heap corruption, and there's no testing.allocator to shout when you forget to free a tower. Zig doesn't change the algorithm one bit -- our code is the same shape as Pugh's -- but ?*Node turns "did you handle the end of the lane" into a compile-time obligation, and the testing allocator turns leaks into red bars. Same structure, far fewer 3am debugging sessions.
In Rust, the skip list is less painful than the doubly linked list was, which surprises people. A skip list's pointers all flow strictly forward -- no node points back at its predecessor -- so you avoid the mutual-aliasing nightmare that sent us into Rc<RefCell<>> or unsafe last episode. You still fight the borrow checker over the multiple forward pointers sharing access, and serious crates like crossbeam-skiplist still reach for unsafe and atomics for the concurrent case, but a simple single-threaded skip list in safe Rust is far more tractable than a doubly linked one. It's a nice illustration that "which structure is hard in Rust" is really "which structure has backward or shared mutable pointers".
In Go, you'd typically pull in a third-party package (the standard library has no skip list), and the generics added in Go 1.18 finally make a clean typed one possible without interface{} boxing on every element. Idiomatically, though, a Go programmer reaching for "sorted with fast inserts" often just uses a map plus a slice they re-sort, or grabs a red-black tree package -- the skip list is more a specialist's tool there. Our Zig version, by contrast, owns every allocation explicitly and pays no GC tax, which matters exactly in the write-heavy hot paths (memtables, leaderboards) where skip lists actually live.
Exercises
- Add a
count(self: *Self, value: T) usizethat returns how many nodes hold a value equal to the target. Since ourinsertpermits duplicates, they sit adjacent onforward[0]; find the first match with the usual descent, then walkforward[0]counting equal values until one isn't. Test it against a list containing several copies of the same key. - Give the skip list a
range(self, lo: T, hi: T, out: *std.ArrayList(T)) !voidthat appends, in sorted order, every valuevwithlo <= v <= hi. Descend to the first node>= lo, then walkforward[0]collecting until you passhi. This is the operation Redis'sZRANGEBYSCOREis built on -- prove it returns the right slice for a query fully inside, partly overlapping, and entirely outside the data. - Convert the structure from a multiset into a proper map: make
inserttake a key and a value, treat equal keys as the same entry (update the value in place instead of adding a duplicate), and addget(key) ?V. You'll compare keys withlessThanon the key type only. Back it withstd.testing.allocatorand prove that overwriting an existing key does not changelenand does not leak.
Where this is heading
Step back and notice the shape of what we did: we took a linked list, gave each node a random number of forward pointers, and bought ourselves logarithmic search without ever writing a rebalancing rule. The balancing came from probability. That's one of two great answers to the question "how do I keep an ordered collection fast under constant mutation" -- the randomised answer. The other answer is deterministic: instead of trusting coins, you enforce a strict structural rule and do real work to maintain it on every insert and delete, so the balance is guaranteed rather than merely expected. That family of structures fans out from a simple binary node into shapes that pack many keys into each node -- tuned, in the fastest of them, to the size of a disk block or a cache line, so that touching memory once buys you a whole fistful of comparisons. That deterministic road is where we go next. Same first principles -- nodes, pointers, ownership, the optionals you now know cold -- pointed at guaranteed balance in stead of probable balance. One pointer at a time, we keep climbing.
Thanks for reading, and de groeten!
Leave Learn Zig Series (#107) - Skip Lists to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Ethical Hacking (#87) - The Future of Security - Quantum, AI, and Beyond
- Learn AI Series (#127) - AI Security
- Learn Zig Series (#108) - B-Trees
- Learn Ethical Hacking (#86) - Critical Infrastructure Security - Hospitals, Power Grids, Water
- Learn AI Series (#126) - Distributed Training
- Learn Zig Series (#107) - Skip Lists
- Learn Ethical Hacking (#85) - Defending Against AI-Powered Attacks - A Practical Guide
- Learn AI Series (#125) - GPU Programming Basics
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Ethical Hacking (#84) - Cloud Breach Simulation - From S3 Bucket to Account Takeover