Project: a spell checker, three ways
A sorted list, a hash set and a trie, measured for speed and memory, with suggestions.
Create a free account Try part of it here
Why it matters
A spell checker looks simple: is this word in the dictionary? But the way you store the dictionary decides how fast the check is, how much memory it takes, and which extra features are cheap. Autocomplete needs prefixes, "did you mean" needs near misses, and a phone keyboard needs all of it in a few megabytes. Building the same checker three ways and measuring them is the clearest way to see data structures as engineering choices, and it's the kind of trade-off question that comes up in technical interviews.
What you'll be able to do
- Store a word list three ways, as a sorted list, a hash set and a trie, behind one interface
- Measure each one's build time, lookup time and memory, and explain the differences
- Use a trie for what the others can't do quickly: every word with a given prefix
- Suggest corrections by generating every word one edit away
What it covers
- The job, and a dictionary to test with
- Way 1: a sorted list and binary search
- Way 2: a hash set
- Every word with a given start
- Measure, don't guess
- Suggesting the right word
Show the answer
A set of the words
A set answers membership in about constant time. A list checks word by word, and rereading a file for every word is slower still. This project builds three versions and measures them.
The job, and a dictionary to test with
The checker needs one operation above all: word in dictionary, answered for every word of a text. To compare structures fairly, each one gets the same tiny interface: build it from a list of words, then ask whether a word is in it. Everything else in this project is measured against that.
For timing we need a big dictionary, so the first cell makes one: 60,000 invented words built from syllables, the same every run.
import random def make_words(n, seed=0): rng = random.Random(seed) parts = ["al", "der", "fen", "hol", "low", "mes", "ra", "ridge", "pine", "gap", "tor", "ka", "lin", "mo", "ves", "tra"] words = set() while len(words) < n: words.add("".join(rng.choice(parts) for _ in range(rng.randint(2, 4)))) return sorted(words) WORDS = make_words(60_000) print(len(WORDS), WORDS[:5], WORDS[-3:])
Way 1: a sorted list and binary search
Keep the words sorted, and a lookup is binary search: compare with the middle word, throw away half, repeat. Python's bisect module does the halving. bisect_left(words, w) returns the position where w is, or where it would be inserted; the word is present only if the item at that position equals it.
import bisect def in_sorted(words, w): i = bisect.bisect_left(words, w) return i < len(words) and words[i] == w words = ["alder", "fern", "gap", "pine", "ridge"] print(in_sorted(words, "gap"), in_sorted(words, "gape"), in_sorted(words, "zebra")) print(bisect.bisect_left(words, "gape")) # where "gape" would go: after "gap"
For 60,000 words that's about 16 comparisons per lookup (2 to the 16th is 65,536), and the list is compact in memory. The cost is change: adding a word means shifting everything after it to keep the order, so a sorted list suits a dictionary that's built once and rarely changes.
bisect.bisect_left(words, "zebra") return, and why does in_sorted check i < len(words) first?import bisect print(bisect.bisect_left(["fern", "gap", "ridge"], "zebra"))
Show the answer
3, one past the end, so words[i] would fail
zebra sorts after everything, so it would be inserted at position 3, past the last index. bisect never returns -1; it returns an insertion point, and checking the index first avoids an IndexError.
Way 2: a hash set
A set is a hash table, the structure you built in the data structures lesson: the word's hash picks a bucket, and only that bucket is searched. A lookup takes about the same time whether the set holds 60 words or 60 million, and adding a word is cheap too. The price is memory: a hash table keeps spare empty slots so collisions stay rare. And it has no order, so it can't answer "which words come after fen?" without looking at all of them.
Way 3: a trie
A trie (said "tree", from re*trie*val) stores words letter by letter in a tree of nested dictionaries. Words that share a beginning share a path: ridge, ridges and ridgeline all walk through r, i, d, g, e. A marker at the end of a path, here the key "$", says "a word ends here".
END = "$" def trie_add(trie, word): node = trie for ch in word: node = node.setdefault(ch, {}) # walk down, creating the path as needed node[END] = True def trie_has(trie, word): node = trie for ch in word: if ch not in node: return False node = node[ch] return END in node trie = {} for w in ["ridge", "ridges", "ridgeline", "rim"]: trie_add(trie, w) print(trie_has(trie, "ridge"), trie_has(trie, "ridg"), trie_has(trie, "rims")) print(list(trie["r"]["i"].keys())) # after "ri": d or m
A lookup takes one step per letter, whatever the dictionary's size. "ridg" walks the whole path but finds no end marker, so it's a prefix, not a word. That's the trie's real strength: every word starting with a prefix sits below one node.
Watch node walk down one letter at a time, creating each step of the path the first time it's needed.
Every word with a given start
Write with_prefix(trie, prefix): walk down the trie along prefix, then collect every word below that node, in alphabetical order. A recursive helper that carries the letters walked so far works well: at each node, if it has the end marker, the letters so far are a word; then visit each child letter in sorted order. Return [] when the prefix isn't in the trie.
END = "$" def trie_add(trie, word): node = trie for ch in word: node = node.setdefault(ch, {}) node[END] = True def with_prefix(trie, prefix): return [prefix] trie = {} for w in ["ridge", "ridges", "ridgeline", "rim", "pine"]: trie_add(trie, w) print(with_prefix(trie, "rid"))
Measure, don't guess
Now the comparison. time.perf_counter times the build and 20,000 lookups (half real words, half not), and tracemalloc measures how much memory each structure allocates while it's built. Run it and compare the columns.
import bisect import random import time import tracemalloc def make_words(n, seed=0): rng = random.Random(seed) parts = ["al", "der", "fen", "hol", "low", "mes", "ra", "ridge", "pine", "gap", "tor", "ka", "lin", "mo", "ves", "tra"] words = set() while len(words) < n: words.add("".join(rng.choice(parts) for _ in range(rng.randint(2, 4)))) return sorted(words) END = "$" def build_trie(words): trie = {} for w in words: node = trie for ch in w: node = node.setdefault(ch, {}) node[END] = True return trie def trie_has(trie, w): node = trie for ch in w: if ch not in node: return False node = node[ch] return END in node def in_sorted(words, w): i = bisect.bisect_left(words, w) return i < len(words) and words[i] == w WORDS = make_words(60_000) rng = random.Random(1) queries = rng.sample(WORDS, 10_000) + [w + "x" for w in rng.sample(WORDS, 10_000)] ways = { "sorted list": (lambda ws: sorted(ws), in_sorted), "set": (lambda ws: set(ws), lambda s, w: w in s), "trie": (build_trie, trie_has), } for name, (build, has) in ways.items(): tracemalloc.start() t0 = time.perf_counter() store = build(WORDS) built = time.perf_counter() - t0 memory = tracemalloc.get_traced_memory()[0] tracemalloc.stop() t0 = time.perf_counter() hits = sum(has(store, q) for q in queries) looked = time.perf_counter() - t0 print(f"{name:>11}: build {built * 1000:6.0f} ms 20,000 lookups {looked * 1000:5.0f} ms memory {memory / 1e6:5.1f} MB hits {hits}")
Every way finds the same 10,000 hits, so all three are correct. The numbers vary from run to run, but the pattern holds. The set is fastest to look up, and costs more memory than the sorted list's compact array. (The memory count leaves out the word strings themselves, which all three share.) The sorted list is smallest, with lookups several times slower than the set's. The trie, built from thousands of small dictionaries, is the slowest to build and to search (each letter is a separate dictionary step in Python), and by far the biggest. Real tries are written in compact arrays, not dictionaries, which is why phone keyboards can afford them.
Numbers like these are a starting point for a choice, not the whole of it. Ask what else the feature needs:
| need | best fit |
|---|---|
| only "is this word valid?", as fast as possible | a set |
| the smallest memory, and a list that rarely changes | a sorted list with bisect |
| every word starting with a prefix, for autocomplete | a trie, or a sorted list scanned from the first match |
| words added and removed all day | a set, or a trie; a sorted list pays to shift its items |
Measure on data shaped like yours, too: real dictionaries share far more prefixes than random syllables, which makes tries smaller, and lookups of real typos fail at different depths than invented ones.
Show the answer
A trie, or a sorted list read from the first match
A set answers "is this exact name present?" but has no order, so finding everything that starts with "rid" means scanning it all. A trie keeps those names under one node; a sorted list keeps them next to each other, so bisect finds the first and you read until the prefix stops matching.
Suggesting the right word
When a word isn't found, a checker suggests close words. The edit distance from the algorithms lesson measures closeness, but comparing a typo with all 60,000 words is slow. A neat trick turns it around: generate every string one edit away from the typo (each letter deleted, each neighboring pair swapped, each letter replaced and each letter inserted), then keep the ones that are real words. Here a swap means trading two neighboring letters, so tnet to tent is one edit; the algorithms lesson's edit distance has no such step and counts it as two replacements. That's a few hundred set lookups, and the set makes each one nearly free.
import string def one_edit_away(word): letters = string.ascii_lowercase splits = [(word[:i], word[i:]) for i in range(len(word) + 1)] deletes = [a + b[1:] for a, b in splits if b] swaps = [a + b[1] + b[0] + b[2:] for a, b in splits if len(b) > 1] replaces = [a + c + b[1:] for a, b in splits if b for c in letters] inserts = [a + c + b for a, b in splits for c in letters] return set(deletes + swaps + replaces + inserts) dictionary = {"tent", "tents", "trail", "trial", "stove", "store", "lantern", "rope"} for typo in ["tnet", "trial", "stve", "lnatren", "xyz"]: print(typo, sorted(one_edit_away(typo) & dictionary))
tnet finds tent by one swap, and stve finds stove by one insert. Notice that trial is spelled correctly but is also one swap from trail: a checker only suggests for words that aren't in the dictionary. lnatren is two swaps from lantern, so one edit finds nothing; applying one_edit_away to every result gives the words two edits away, a much bigger set, usually tried only when one edit finds nothing.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why is a Python trie built from nested dictionaries so much bigger in memory than a set of the same words?