Data structures and storage engines
Sorted trees, skip lists and tries
The structures that keep keys in order for range queries, the red-black and skip list options for in-memory sorting, and tries for prefix search.
A hash map is fast for exact keys, but it can't answer "all keys between 100 and 200" or "all words starting with ca". For those, keep keys in sorted order. Three structures do it, each with a different trade-off between simplicity, memory and speed.
- Balanced binary trees, such as red-black trees, keep keys sorted with O(log n) updates, by rotating nodes to stay balanced.
- Skip lists are sorted linked lists with extra express lanes, giving O(log n) average operations. They are simpler to write concurrently.
- Tries store keys character by character, so prefix queries cost time proportional to the prefix length.
The big idea
A sorted structure trades a little speed on exact lookups for ordering. A binary search over a sorted array is already O(log n) and fast for reads. The trees and skip lists add cheap inserts and deletes on top of that.
Pick by workload:
| Need | Choose | Why |
|---|---|---|
| Mostly reads, rare changes | Sorted array with binary search | Simple and cache-friendly |
| Frequent inserts and ordered scans in memory | Red-black tree, or a language's sorted map | Balanced updates in O(log n) |
| Concurrent sorted map with simple code | Skip list | Lock-free and fine-grained variants are easier to build |
| Prefix search over strings | Trie | Cost depends on the prefix, not the number of keys |
| Sorted keys on disk | B+tree | Pages and fanout, covered in B+tree lookups |
Walking through it: a trie
A trie stores each character as a level. Words sharing a prefix share the path. To check a prefix, walk the path, and fail at the first missing character.
trie = {}
def insert(w):
node = trie
for ch in w: node = node.setdefault(ch, {})
node["$"] = True
def has_prefix(p):
node = trie
for ch in p:
if ch not in node: return False
node = node[ch]
return True
for w in ["car", "cart", "dog"]: insert(w)
print(has_prefix("ca"), has_prefix("cab"), has_prefix("do"))Output:
True False Trueca is a prefix of car. cab fails at the b, since no word has cab in it. do is a prefix of dog. The lookup costs the length of the prefix, however many words are stored. That is why tries power autocomplete, covered in search autocomplete.
Tries use a lot of memory. Each node may hold a pointer per character. Compressed variants, such as radix trees, merge single-child chains to save space.
Red-black trees and skip lists
A red-black tree keeps every path from root to leaf within about twice the shortest path, by colouring nodes and rotating them on insert and delete. Most standard libraries' ordered maps are red-black or similar balanced trees. Their cost is pointer chasing, which hurts cache performance, as covered in arrays and memory layout.
A skip list is a linked list with several levels of express lanes. Each node is promoted to higher levels at random, so searches skip ahead. Inserts only touch local pointers, which makes them simpler to make concurrent. Redis uses a skip list for its sorted sets.
Common mistakes
- Using a hash map for range queries and then scanning every key on each request.
- Building a trie for a small set of words, where a sorted array is smaller and fast enough.
- Ignoring the memory cost of tries on large vocabularies.
Check yourself
- Why does a trie's prefix lookup not depend on the number of words? Answer: it walks one node per character of the prefix, so the cost is the prefix length.
- Why is a skip list easier to make concurrent than a balanced tree? Answer: its updates touch a few local pointers, while a tree rotation can change nodes far apart, which requires more locking or complex lock-free code.
Key takeaways
- Use sorted structures when you need range queries or ordered scans.
- Red-black trees and skip lists give O(log n) ordered updates in memory.
- Tries answer prefix queries in time proportional to the prefix length.
Further learning
Next, see LRU caches, which combine a hash map with a list to evict in constant time.
Related
B+tree lookups
How a B+tree finds a key in a few page reads, why its fanout is large, how page splits happen, and why random keys hurt inserts.
Data structures in practice
Choosing data structures by memory layout, access pattern and the operations that matter, with the common ones for arrays, hash maps, heaps, trees, caches and graphs.
Hash maps
How hash maps map keys to buckets, how load factor and collisions set their speed, and why adversarial keys can turn O(1) lookups into O(n).
Case study: search autocomplete
Typeahead under 100 ms: tries with cached top-k, offline query-log aggregation, prefix sharding and client debouncing.