Implementing a Trie for Auto-Complete in Python
Auto-complete appears in search boxes, address fields, command palettes and mobile keyboards. When a user enters mel, the application may suggest Melbourne, Melville or a saved business name. A trie, also called a prefix tree, is a useful data structure for producing these suggestions quickly because it stores shared prefixes only once.
This guide builds a practical trie in Python, explains its complexity and adds result limits and ranking support. The same design can power suburb searches in Sydney, product searches for an Australian online shop, or a developer tool that completes Python commands.
What A Trie Stores
A trie is a tree where every edge represents a character. The path from the root to a node forms a prefix, while a marker on a node records whether that path is a complete word. For example, car, cat and cart share the path for ca, then branch at the next character.
Unlike a binary search tree, a trie does not compare complete strings while walking from the root. It follows one dictionary lookup per character. This makes prefix queries especially natural: find the node for the requested prefix, then visit every completed word below it.
The root represents an empty string. Each child node can contain a mapping such as {"a": node_a, "o": node_o}. A terminal flag distinguishes a stored word from an intermediate prefix. Without that flag, the structure could not tell whether car itself is valid when cart is also present.
Designing The Python Node
A small Python class is enough for a basic implementation. A dictionary is preferable to a fixed array because most nodes have only a few children, and dictionary access is expected to be constant time. The word field is optional but convenient: it avoids rebuilding a result character by character during traversal.
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
self.word = None
self.frequency = 0
The frequency field supports ranking later. It might represent the number of searches, purchases or selections associated with a suggestion. For an address finder in Perth or Adelaide, frequency could reflect how often users choose a particular suburb. A production system may store richer metadata, such as an identifier, display label or category.
Python dictionaries handle Unicode strings, so the same structure can accept words containing accented characters or non-English scripts. Normalising input remains important: "Sydney", "sydney" and " SYDNEY " should usually follow the same policy before insertion and lookup.
Inserting Words Into The Trie
Insertion starts at the root and processes each character from left to right. If the current node lacks a child for that character, the algorithm creates one. After the final character, it marks the node as a complete word and stores any ranking information.
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word, frequency=1):
word = word.strip().lower()
if not word:
return
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_word = True
node.word = word
node.frequency = frequency
Calling insert("car") and then insert("cart") reuses the nodes for c, a and r. Calling insert("car", frequency=20) a second time updates the stored ranking value in this implementation. A different application might accumulate frequency instead, depending on whether each insertion represents a replacement or a new event.
Useful insertion rules keep the data predictable:
- Trim surrounding whitespace before storing input.
- Choose a case policy and apply it consistently.
- Decide whether punctuation and hyphens are meaningful.
- Reject empty strings unless an empty suggestion is intentional.
If a dataset contains many repeated records, normalisation before insertion prevents duplicate branches. This matters when loading a large catalogue, a list of Australian postcodes or thousands of customer-entered search terms.
Finding Suggestions From A Prefix
Lookup first walks the trie until it reaches the node representing the prefix. If any character is missing, there are no matches. Once the prefix node is found, a depth-first search collects complete words in its subtree.
def autocomplete(self, prefix, limit=10):
prefix = prefix.strip().lower()
node = self.root
for char in prefix:
if char not in node.children:
return []
node = node.children[char]
matches = []
def collect(current):
if current.is_word:
matches.append(current)
for child in current.children.values():
collect(child)
collect(node)
matches.sort(key=lambda item: (-item.frequency, item.word))
return [item.word for item in matches[:limit]]
The result limit is important. A prefix such as s could match a large portion of a national product catalogue. Returning only the best ten suggestions reduces memory use and keeps a user interface responsive. For a Melbourne café finder, the ranking could favour popular venues; for a Sydney transport search, it could favour frequently selected stations.
The traversal order is not automatically alphabetical because dictionary insertion order reflects how data was loaded. Sorting the final matches by frequency and then by word gives stable output. For very large result sets, collecting every descendant before sorting is less efficient, so a bounded heap or ranked suggestions stored at each node may be a better design.
Analysing Time And Space Costs
Let m be the prefix length, n the number of stored words, and k the number of matching results. Walking to the prefix takes expected O(m) time. Traversing the matching subtree takes time related to the characters visited, and sorting k matches costs O(k log k).
Insertion of a word with length m takes O(m) expected time. The total memory requirement is proportional to the number of character nodes, although shared prefixes reduce the cost compared with storing every word as an entirely separate object. A dataset with little prefix overlap may need considerably more memory than a compact sorted list.
For a concise refresher on growth rates, the asymptotic notation guide explains how Big-O descriptions relate to algorithm behaviour. The key practical point is that a trie makes the prefix part predictable, while the number and depth of matching descendants determine the remaining work.
A plain dictionary scan has a different profile. To find words beginning with a prefix, it checks every stored string, often costing O(n) comparisons. That can be acceptable for a small configuration file, but the trie becomes attractive when suggestions are requested repeatedly against a large vocabulary.
Improving Ranking And Search Behaviour
Alphabetical ordering is easy to implement, but useful auto-complete usually needs relevance. Frequency is one option; recency, user history, geographic distance and exact-prefix popularity are others. A Brisbane food-delivery search might rank a frequently ordered restaurant above a rarely selected one, even when both names share the same prefix.
For a larger service, each node can maintain its best few suggestions. During insertion, the word is considered along every node on its path. Then a query can walk to the prefix node and immediately return its cached candidates without scanning the whole subtree. This increases insertion work and memory use, but greatly reduces query latency.
Other practical decisions include:
- Set a maximum prefix length accepted by the API.
- Return an empty list for unknown or blank prefixes.
- Keep ranking data separate from display text when labels change.
- Escape or validate suggestions before rendering them in a web page.
A trie should hold trusted application data rather than unbounded raw input. If users can insert arbitrary strings, impose limits on word length and total entries. Normalising case may be suitable for English search, while a multilingual Australian marketplace may need locale-aware comparison and accent handling.
Testing The Implementation
Tests should cover both ordinary words and shared-prefix edge cases. Insert car, cart, cat and dog, then verify that autocomplete("ca") returns the first three words and that autocomplete("car") returns car and cart. The result for z should be an empty list rather than an exception.
Also test duplicate insertion, mixed case, whitespace and a prefix that is itself a complete word. Frequency ranking deserves separate tests: if cat has a greater frequency than car, the returned order should reflect that value. A limit of zero should produce no suggestions, and a negative limit should be rejected or defined explicitly.
Benchmarking should use realistic workloads rather than a handful of toy words. Compare repeated trie queries with a list comprehension over the source vocabulary. If the data is loaded from SQL, an indexed prefix query may already be efficient; the discussion of SQL indexing guide is useful when deciding whether search should happen in the database, application memory or both.
For an interactive interface, measure latency at the service boundary, including normalisation, serialisation and network time. A fast trie cannot compensate for a slow database reload on every keystroke. Usually, the vocabulary is loaded once, updated in batches and queried in memory.
Comparing Alternatives For Auto-Complete
A trie is well suited to mutable dictionaries and repeated prefix queries, but it is not the only option. A sorted list with binary search can locate the first matching position efficiently and then scan nearby entries. A database index may be simpler when records change frequently or must be filtered by permissions.
The choice depends on vocabulary size, update frequency, ranking requirements and memory limits. A compressed radix tree reduces the number of single-character nodes by combining non-branching paths. A ternary search tree can use less pointer-heavy storage, while a directed acyclic word graph can share repeated suffixes as well as prefixes.
The relationship between a trie and other search methods is different from nearest-neighbour search: k-nearest neighbours finds similar points by distance, whereas a trie follows exact character paths. Keeping that distinction clear prevents choosing a spatial or similarity algorithm for a straightforward prefix problem.
| Approach | Prefix query | Updates | Memory profile | Suitable use |
|---|---|---|---|---|
| Trie | Fast path lookup plus subtree traversal | Simple and immediate | Can be high, reduced by shared prefixes | In-memory live suggestions |
| Sorted list | Binary search, then adjacent scan | Costly if frequently edited | Compact | Mostly static word lists |
| Database index | Depends on query and index design | Managed by database | Stored outside the application | Large shared catalogues |
| Radix tree | Fast prefix lookup | Moderate implementation complexity | Lower than a basic trie | Large vocabularies with long shared paths |
The basic Python version is a strong starting point because its operations are transparent and easy to test. As traffic grows, ranking caches, compressed nodes or an indexed database can be introduced without changing the central idea: follow the entered prefix, then retrieve the most relevant completions beneath it.