Prefix autocomplete
“An editor suggests dictionary words after a user types a prefix. Repeatedly scanning the whole dictionary wastes work. Build a reusable index that returns at most k matching words in lexical order. A word may also be a prefix of another word, so how will your structure remember both facts?”
Write this:
class Trie:
def __init__(self, words=()):
...
def add(self, word):
...
def suggest(self, prefix, limit=5):
...
Constructed practice question. Prerequisite: tries and search. A trie stores one character per edge; the path from the root spells a prefix. A terminal marker records a complete word independently of whether children exist.
| Contract | Required behavior |
|---|---|
| Input | Nonempty lowercase ASCII words; add(word) and suggest(prefix,limit) |
| Output | Up to limit unique matching words, ascending lexicographic order |
| Boundaries | Empty prefix means all words; zero limit/no match gives []; duplicate adds collapse |
| Failure | Nonstring/invalid characters/empty added word, or noninteger/negative limit (bool excluded), raise ValueError |
| Scope | Exact prefix, fixed alphabet; no popularity, fuzzy matching, or removal |
Trie(words) accepts an iterable of word strings, including an empty iterable;
a bare string or noniterable raises ValueError. add and suggest validate
before changing state.
Optional refresher · the underlying tool
A trie stores one character per edge. Reaching a node for prefix "ap" does not mean "ap" was inserted as a complete word; terminal markers are separate:
node = {"children": {"p": {"children": {}, "end": True}}, "end": False}
print(node["end"]) # False: this node is a prefix only
Inserted car, card, cat; query prefix car with limit 5 gives ["car","card"] in lexical order. Describe when sorted traversal stops.
A design choice worth saying aloud
A node's children map describes paths; its is_terminal flag answers whether that path is a complete inserted word. Do not infer completion from the presence of a prefix node. If words need deletion or frequency ranking later, define where those counts live and how stale terminal markers are removed.
What the interviewer expects
The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.
Done means: Up to limit unique matching words, ascending lexicographic order.
Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.
Test-case scenarios to settle before coding
01 · Representative
- Input / starting state
- add car,card,cat; suggest
car, 5 - Expected result
["car","card"]
What it is testing: A terminal prefix word remains a suggestion.
02 · Limit
- Input / starting state
- same data; suggest
car, 1 - Expected result
["car"]
What it is testing: Stop after enough lexicographic results.
03 · Empty prefix
- Input / starting state
- suggest
"", 5 - Expected result
- first five words globally
What it is testing: The root represents all words.
04 · Duplicate add
- Input / starting state
- add car twice
- Expected result
- car appears once
What it is testing: Dictionary membership is unique.
05 · No match/zero limit
- Input / starting state
- prefix z or limit 0
- Expected result
[]
What it is testing: Both are normal results.
06 · Invalid
- Input / starting state
- uppercase/empty added word or negative limit
- Expected result
ValueError
What it is testing: The fixed alphabet contract is enforced.
For each case, show which branch or state change produces that result.
Adding [cart,cat,car,dog,car] gives suggest('ca',5) = [car,cart,cat] and
suggest('car',1) = [car]. suggest('z',5) returns []; 'Car' is rejected rather
than silently lowercased. Ask whether lexical ordering is truly desired: popularity
ranking changes what can be pruned during traversal.
Implement independently. Predict whether clearing car's terminal marker would remove cart, and explain why that differs from deleting the entire car node.
Solution, traversal order, and follow-ups
The baseline filters all N words with startswith(prefix) and sorts matches.
It may be adequate for a small immutable dictionary, but every keystroke revisits
unrelated words. A sorted word array plus binary search is a worthwhile alternative
when updates are rare; a trie makes shared prefixes explicit and supports insertion.
Insertion follows/creates character edges and marks the final node terminal. Lookup first follows the prefix in O(P) steps. If an edge is missing, return empty. Otherwise perform depth-first traversal beneath that node, visiting terminal words before their descendants and child edges in sorted order. Stop after k outputs.
the current character path exactly spells the current trie node, and every word in its subtree starts with that path. Sorted child traversal emits lexicographic order; emitting the terminal first puts car before cart. The explicit iterator stack restores the character path on return and avoids Python's recursion limit for long words. No complete prefix string is copied at every intermediate node.
| Visit | Terminal? | Output so far |
|---|---|---|
| ca | no | [] |
| car | yes | [car] |
| cart | yes | [car,cart] |
| cat | yes | [car,cart,cat] |
Let W be total inserted characters, P prefix length, S visited subtree nodes, L maximum word length, and B total output characters. Build costs O(W) time/space. Query time is O(P + S + B) for the fixed 26-character alphabet; sorting each node's at-most-26 children is constant bounded work per visit. Query auxiliary space is O(L), excluding O(B) output, because only the current path and its iterator frames are retained. A large alphabet would add child-sorting costs explicitly.
Follow-up 1 — remove car but retain cart. Predict the changed trie. Clear the terminal flag at car; prune a node only when it is nonterminal and childless. Removing cart afterward allows pruning back until another word or branch survives.
Follow-up 2 — rank by popularity. Lexical early stopping no longer finds the best k. Store/cache ranked candidates at prefix nodes, or explore using subtree score bounds. Score updates, ties, and deletions determine maintenance cost; merely sorting the first k lexical results is incorrect.
Senior depth includes terminal-versus-branch reasoning, bounded output traversal, and an independent filter/sort oracle. Lead depth addresses normalization and index-update/version contracts before adding international text or live ranking.
Reference: solution.py (download file, source below); tests cover prefix words, duplicate adds, limits, missing prefixes, dynamic insertion, and a 2,000-character word.
"""Lexicographic prefix lookup using explicit trie nodes and iterative DFS."""
from string import ascii_lowercase
class Node:
def __init__(self):
self.children = {}
self.terminal = False
class Trie:
def __init__(self, words=()):
if isinstance(words, (str, bytes)):
raise ValueError("expected an iterable of words, not one string")
try:
words = iter(words)
except TypeError as exc:
raise ValueError("expected an iterable of words") from exc
self.root = Node()
for word in words:
self.add(word)
@staticmethod
def _validate(text, allow_empty=False):
if not isinstance(text, str) or (not text and not allow_empty) or any(c not in ascii_lowercase for c in text):
raise ValueError("lowercase ASCII words required")
def add(self, word):
self._validate(word)
node = self.root
for char in word:
if char not in node.children:
node.children[char] = Node()
node = node.children[char]
node.terminal = True
def suggest(self, prefix, limit=5):
self._validate(prefix, allow_empty=True)
if type(limit) is not int or limit < 0:
raise ValueError("nonnegative integer limit required")
if limit == 0:
return []
node = self.root
for char in prefix:
if char not in node.children:
return []
node = node.children[char]
result = [prefix] if node.terminal else []
path = list(prefix)
stack = [iter(sorted(node.children.items()))]
while stack and len(result) < limit:
try:
char, child = next(stack[-1])
except StopIteration:
stack.pop()
if stack:
path.pop()
continue
path.append(char)
if child.terminal:
result.append(''.join(path))
stack.append(iter(sorted(child.children.items())))
return result[:limit]
python -m unittest discover -s curriculum/01-code/02-data-structures-algorithms/problems/30-trie-autocomplete -p 'test_*.py'