Part 3 · Data Structures · Graded project
Project 3: Autocomplete Engine
Build the search box of an online store from first principles: a trie for prefixes, a heap for ranking, an LRU cache for speed, and a pruned edit-distance search for typos. Every structure from Part 3 earns its place.
- Implement a trie with insert, membership, prefix search, pruning delete, and sorted iteration
- Rank completions with
heapqusing a composite key that breaks ties deterministically - Build an O(1) LRU cache from a dict and a doubly linked list, and invalidate it correctly
- Combine dynamic programming with tree traversal to find typo-tolerant matches efficiently
- Design a facade that normalizes Unicode input, loads CSV data, and reports statistics
- Prove behavior with 68 graded tests, including a performance sanity check
The scenario
You have joined the search team at VoltMart, a fictional electronics store with a catalog of tens of thousands of products. Product managers want a search box that behaves like the ones shoppers already know:
- Type
ipand see iphone, ipad, ipod, best sellers first. - Type
IPorIpand see the same thing. TypeCRÈMEand match crème brûlée (yes, they also sell espresso machines and the marketing team names bundles after desserts). - Type
labtopand still see laptop. - Click a suggestion and nudge it upward for the next shopper.
- Answer the same keystrokes again (thousands of shoppers type
i) without recomputing.
A sorted list plus bisect could answer prefix queries, and a dict could answer exact lookups, but
neither gives you all of the above together. You will assemble the right structures yourself, which is exactly
what Part 3 prepared you for.
Learning goals and feature spec
Trie
insert(word, score), contains/in,
starts_with, delete (prunes dead branches), len, iteration in
lexicographic order, items(prefix), words_with_prefix.
Ranking
score, add_score, and
top_k(prefix, k) with heapq: highest score first, ties alphabetical.
LRU cache
LRUCache[K, V] with O(1) get/put,
keys() (most recent first), hits/misses, clear. No
OrderedDict.
Typo tolerance
levenshtein, within_distance, and
fuzzy_search(trie, query, max_distance) that prunes while walking.
Engine facade
AutocompleteEngine: add_term,
remove_term, load_csv, complete, suggest,
record_selection, stats().
Normalization
normalize(text): NFC, casefold, collapse whitespace,
so every entry point agrees on what a term is.
| Operation | Target cost | Why |
|---|---|---|
insert, contains, delete | O(L) | One node per character of the term. |
top_k(prefix, k) | O(L + m log k) | Walk to the prefix node, then heap-select among m matches. |
cached complete | O(L + k) | Normalize, one dict lookup, copy the list. |
LRUCache.get/put | O(1) | Dict finds the node; linked list relinks it. |
fuzzy_search | O(nodes visited × |query|) | Pruning keeps the visited set small for d ≤ 2. |
Architecture
The engine is a facade: callers see one class, and the class coordinates the structures behind it. Each module is independently testable, which is how the grading tests are organized.
flowchart LR
U[Shopper keystrokes] --> N[normalize]
N --> E[AutocompleteEngine]
E -- "(prefix, k)" --> C[LRUCache]
C -- miss --> T[Trie.top_k]
T --> H[heapq selection]
E -- "fewer than k" --> F[fuzzy_search]
F --> T2[Trie nodes + DP rows]
CSV[catalog.csv] --> E
S[record_selection] --> E
E -- "any update" --> X[cache.clear]
top_k("ap", 2) walks to the
p node, gathers the four terms beneath it, and heap-selects app (80) and apple (50).Getting started with Pixi
pixi run projects # list every project and its status
pixi run start p03 # copy the starter into work/p03_autocomplete/
pixi run grade p03 # run all 68 tests against your work/ copy
The starter contains the full public API with type hints, docstrings, and TODO(milestone N)
comments; every body raises NotImplementedError, so every test fails at first. Work milestone by
milestone and watch the failure count fall. Open work/p03_autocomplete/ in your editor; the tests
live in projects/p03_autocomplete/tests/ and you can read them for the exact contract.
Milestones
Milestone 1: the Trie (trie.py)
Each TrieNode holds a children dict, an is_word flag, and a
score. Write a private _find(prefix) helper that returns the node at the end of a
path or None; most methods are three lines on top of it. Iteration must be lexicographic, which
means visiting children in sorted key order and yielding a node's own word before its descendants.
insertcreates nodes on demand, rejects the empty string, and counts only new termscontainsdistinguishes words from mere prefixes;inworks and rejects non-stringsdeletereturns a bool and prunes nodes that no longer lead to any worditems,words_with_prefix, anditerare sorted regardless of insertion order- Unicode terms (
日本語,café) work without special cases
Hint: pruning on delete
Record (parent, char) pairs on the way down. After unmarking the final node, walk that list
backwards: delete parent.children[char] while the child is neither a word nor has children;
stop at the first node that is still needed.
Hint: iterative sorted DFS
With an explicit stack, push children in reverse sorted order so the smallest pops first. Yield the current node's word when you pop it, before its children are pushed.
Milestone 2: weighted completions (Trie.top_k)
Sorting all m matches costs O(m log m). A heap of size k does it in O(m log k), which matters when the
prefix is one letter and m is in the thousands. The ordering rule is "highest score first, then
alphabetical", which is ascending order of the key (-score, term).
scoreandadd_scoreraiseKeyErrorfor unknown terms- Re-inserting a term replaces its score without changing
len top_kusesheapqand breaks ties alphabetically- Edge cases: empty prefix ranks everything, unknown prefix returns
[],k=0returns[], negative k raises
Hint: two heap strategies
heapq.nsmallest(k, self.items(prefix), key=lambda p: (-p[1], p[0])) is one line. To build it
yourself, keep a min-heap of the k best seen so far keyed by a value whose smallest element is the
worst of the k; replace the root when a better item arrives (heapreplace). Python 3.14's
heappushpop_max family can help with the inverted order.
Milestone 3: an LRU cache from scratch (lru.py)
A dict maps keys to nodes; a doubly linked list orders nodes from most to least recently used. Two
sentinel nodes (head and tail) mean you never special-case an empty list. A test reads your
module's source to make sure OrderedDict and lru_cache are not used.
getmoves a hit to the front and counts hits and missesputupdates existing keys in place and evicts from the back when fullindoes not count as a use;keys()is most-recent-first- Capacity below 1 raises
ValueError; capacity 1 works
Hint: two helpers do all the work
_unlink(node) sets node.prev.next = node.next and node.next.prev = node.prev;
_push_front(node) splices it between head and head.next. A "move to
front" is unlink then push. Eviction is unlink tail.prev and del self._map[node.key],
which is why each node stores its key.
Milestone 4: typo tolerance (fuzzy.py)
Computing levenshtein(query, term) for every term costs O(n × L²). Instead, walk the trie and
compute one DP row per node: the row for node "lap" is derived from the row for "la". Terms that share a
prefix share the work. Better still, if every value in a row exceeds the bound, no extension of that prefix
can come back under it, so the whole subtree is skipped.
levenshteinpasses the classic cases (kitten/sitting = 3, flaw/lawn = 2) using two rowswithin_distancefails fast on length gaps and stops whenmin(row)exceeds the boundfuzzy_searchmatches a brute-force scan exactly, sorted by (distance, term)
Hint: the row recurrence
For trie character c and parent row prev: row[0] = prev[0] + 1, and for
each query position j, row[j] = min(row[j-1] + 1, prev[j] + 1, prev[j-1] + (query[j-1] != c)).
The node is a match if it ends a word and row[-1] <= max_distance.
Milestone 5: the engine facade (engine.py)
The facade makes the parts behave as one product. Normalize at every entry point, cache
(normalized prefix, k) results, and clear the cache on every mutation: adding, removing,
loading, and selecting. A stale cache is the classic bug here, and three tests target it.
normalizehandles case,ß, combining accents, and whitespaceload_csvreads aterm,scoreheader, skips blank rows, and reports bad rows with line numberscompletereturns copies, so mutating a result never corrupts the cachesuggesttops up with typo matches ordered by distance, score, term, without duplicatesrecord_selectionboosts the score, andstats()reports terms, queries, hits, misses, selections, and hit rate- 20,000 terms plus 21 queries finish well within the generous time bound
Hint: why copies matter
If complete returns the very list stored in the cache, a caller doing
results.append("ad") changes what the next shopper sees. Store one list and return
list(cached). This is the aliasing lesson from early in the course, now with consequences.
Grading and rubric
pixi run grade p03 runs 68 tests in five files, one per milestone. Passing all of them is the
automated part; the rubric below is how a reviewer would judge the code.
| Criterion | Excellent | Weight |
|---|---|---|
| Correctness | All 68 tests pass; edge cases (empty prefix, unicode, ties) handled deliberately. | 40% |
| Complexity | Trie operations O(L); top-k uses a heap; LRU is O(1); fuzzy search prunes. | 20% |
| Design | Small helpers (_find, _unlink), a facade with no leaking internals, cache invalidation in one place. | 15% |
| Types and docs | Full hints (generic LRUCache[K, V]), Google docstrings, ruff-clean. | 15% |
| Robustness | Clear exceptions with useful messages; no aliasing bugs; no prints in library code. | 10% |
Stretch goals
- Fuzzy prefixes: suggest laptop after only
labt. Accept a node whenmin(row) <= drather thanrow[-1]at the end of the query, then collect its subtree. - Damerau-Levenshtein: make the transposition
ipda→ipadcost 1 (you need the grandparent row). - Precomputed top-k: store the best k terms at each node for O(L) queries, and keep them correct when scores change.
- Selective invalidation: clear only cache entries whose prefix is a prefix of the changed term.
- A CLI:
pixi run python -m autocomplete catalog.csvwith an interactive prompt (add a__main__.py). - Measure: use
timeitto comparetop_kagainst sorting all matches, and the cache hit rate on a realistic query log (Zipf-distributed prefixes).