Index Ai Academy
Phase 7 · Software engineering · Data structures & algorithms

Project: a spell checker, three ways

A sorted list, a hash set and a trie, measured for speed and memory, with suggestions.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The job, and a dictionary to test with
  2. Way 1: a sorted list and binary search
  3. Way 2: a hash set
  4. Every word with a given start
  5. Measure, don't guess
  6. Suggesting the right word
Quick check
A spell checker must answer "is this word in the dictionary?" for every word of a long document. Which way of storing 100,000 words is quickest?
  1. A list of the words
  2. A set of the words
  3. A text file reread for each word
  4. One long string of every 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:])
It runs right here in your browser, with nothing to install.

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.

Quick check
In the sorted list ["fern", "gap", "ridge"], what does 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"))
  1. 3, one past the end, so words[i] would fail
  2. 0, because zebra isn't in the list at all
  3. -1, the usual value meaning "not found"
  4. 2, the position of the nearest word before it
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.

Try it now

Watch node walk down one letter at a time, creating each step of the path the first time it's needed.

Drill

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"))
In the lesson, your code is checked as you write it.

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:

needbest fit
only "is this word valid?", as fast as possiblea set
the smallest memory, and a list that rarely changesa sorted list with bisect
every word starting with a prefix, for autocompletea trie, or a sorted list scanned from the first match
words added and removed all daya 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.

Quick check
A search box should show every product name starting with what the user has typed so far, after each key press. Which structure fits best?
  1. An unsorted list, scanned on every key press
  2. A set, since its exact lookups are the fastest
  3. A trie, or a sorted list read from the first match
  4. A dictionary from product names to prices
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

Create a free account

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?

  1. Every letter on every path is its own small dictionary
  2. A trie stores every word twice, once forward and once backward
  3. Python compresses sets but can't compress dictionaries
  4. A trie keeps a copy of the whole word list at each node

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson