Pick the Structure: Ten Requirements, and Nobody Tells You the Answer
Sixteen pages in this collection take one structure apart each. That is the right way to learn them and the wrong way to finish, because the skill none of them practises is the one every real task begins with.
You are handed a requirement. It never names the structure.
Someone who can explain a trie perfectly and still reaches for a hash map when asked to build autocomplete has learned the pages, not the subject. So this one runs the other way round.
Ten jobs
Four candidates each. The wrong options are not filler โ each is the one somebody reaches for out of habit, and the explanation says why it tempts and where it breaks.
Every other page in this collection takes one structure apart. This one starts from the other end, which is where real work starts: you are handed a requirement, and it never names the structure.
10 jobs โ autocomplete over a million words, deduplicating a ten-billion-item stream, always taking the smallest pending task, range queries on a billion rows sitting on disk, tracking which accounts have been merged. Four candidates each, and the wrong ones are not silly: each is the answer somebody reaches for by habit.
Two of them are traps this collection has already measured โ sorted keys arriving into a plain search tree, and a nightly scan meeting a cache. Every answer links to the page with the program that proves it.
Read what the job actually needs, then choose. The explanation appears after you commit โ and links to the page that measures it.
- Correct so far
- 0/0
- Requirement
- 1/10
- Left
- 10
- Final score
- โ
This round, as text
Your choice
Your run
Two of these are traps this collection has already measured. Every answer links to the page with the program that proves it.
Three of them, costed
Choosing wrong is not a style error. Here is what it costs on three of the ten.
from itertools import product
import math
WORDS = ["".join(p) for p in product("abcdert", repeat=4)] # 2,401 four-letter words
PREFIX = "car"
def trie_prefix_cost(words, prefix):
"""Walk the prefix, then collect the subtree. Cost = nodes touched."""
root = {}
for w in words:
node = root
for ch in w:
node = node.setdefault(ch, {})
node["$"] = True
node, steps = root, 0
for ch in prefix:
if ch not in node:
return steps, 0
node, steps = node[ch], steps + 1
matches = 0
stack = [node]
while stack:
cur = stack.pop()
for ch, child in cur.items():
if ch == "$":
matches += 1
else:
steps += 1
stack.append(child)
return steps, matches
def hash_prefix_cost(words, prefix):
"""A hash table scatters keys, so prefix search has to examine every one."""
return len(words), sum(1 for w in words if w.startswith(prefix))
def repeated_min_cost(n, take):
"""Take the smallest `take` times: re-sorting each time versus a heap."""
resort = take * math.ceil(n * math.log2(n)) # n log n per round
heap = math.ceil(n) + take * math.ceil(math.log2(n)) # build once, then log n per pop
return resort, heap
def membership_bytes(n, avg_len, bits_per_item):
"""Storing the strings, versus storing only a bit pattern about them."""
return n * avg_len, math.ceil(n * bits_per_item / 8)
t_steps, t_hits = trie_prefix_cost(WORDS, PREFIX)
h_steps, h_hits = hash_prefix_cost(WORDS, PREFIX)
print(f"1. all completions of {PREFIX!r} among {len(WORDS):,} words (trie vs hash table)")
print(f" trie {t_steps:>10,} nodes touched {t_hits} matches")
print(f" hash table {h_steps:>10,} keys scanned {h_hits} matches")
print()
resort, heap = repeated_min_cost(100_000, 20)
print(f"2. take the smallest item 20 times from 100,000 (re-sort vs heap)")
print(f" re-sort each time {resort:>12,} operations")
print(f" heap {heap:>12,} operations")
print()
exact, bloom = membership_bytes(10_000_000, 24, 10)
print(f"3. have I seen this before, for 10,000,000 items (store them vs Bloom filter)")
print(f" store the strings {exact:>12,} bytes")
print(f" bloom filter {bloom:>12,} bytes at 10 bits each")1. all completions of 'car' among 2,401 words (trie vs hash table) trie 10 nodes touched 7 matches hash table 2,401 keys scanned 7 matches 2. take the smallest item 20 times from 100,000 (re-sort vs heap) re-sort each time 33,219,300 operations heap 100,340 operations 3. have I seen this before, for 10,000,000 items (store them vs Bloom filter) store the strings 240,000,000 bytes bloom filter 12,500,000 bytes at 10 bits each
Same seven matches, 10 nodes against 2,401 keys. Same twenty items taken, 100,340 operations against 33 million. Same question answered, 12.5 MB against 240.
None of those gaps comes from one structure being faster in general. Each comes from the structure matching the shape of the question: a trie keeps related keys adjacent, a heap keeps only the promise you needed, a Bloom filter stores a fact about the data instead of the data.
The question that decides it
Almost every choice in this collection comes down to one thing: what are you going to ask, over and over?
- Ask for one exact key โ the structure that scatters. Hash table.
- Ask about a neighbourhood โ prefixes, ranges, ordering โ a structure that keeps related keys together. Trie, balanced tree, B-tree.
- Ask for the extreme, repeatedly โ heap.
- Ask about the most recent, or the longest-waiting โ stack or queue.
- Ask a yes/no question where one kind of wrong answer is free โ Bloom filter.
And the second question, which the traps are about: what order does the data arrive in, and can you control it? Sorted input ruins a plain search tree and a naive quicksort pivot alike; a scan longer than a cache empties it for nothing. None of those shows up in a complexity table, and all of them show up in production.
What this page simplifies
- Real requirements are ambiguous. These are stated cleanly so there is a defensible answer. In practice the hardest part is discovering what will actually be asked, and the answer often changes six months in.
- One structure, one job. Real systems compose โ an LRU cache is a hash map and a linked list, a database index is a B-tree with a Bloom filter in front of it.
- The cost model is the one used throughout this collection. Operations counted, not seconds; cache behaviour and allocation are not modelled, and both favour the flat contiguous structures.
- The third figure assumes a ten-bit Bloom filter. That is a choice of false-positive rate, not a constant โ the rate is a dial you set by choosing the memory.