Prefix autocompleteLESSON 2.29 · 29 OF 43 IN CHAPTER
PART A / Coding problems and trade-offs
Step 55 of 252
LESSON 2.29 · 29 OF 43 IN CHAPTERTry it, then open the solution

Prefix autocomplete

THE PROBLEM

“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.

Diagram: Test-case scenarios to settle before coding

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.

Diagram: Test-case scenarios to settle before coding

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.

solution.py · solution.py
"""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'