Tries
When you type "Lu" into a railway booking site and it suggests Lucknow, Ludhiana and Lumding, the site is answering a prefix question: which words start with these letters? A hash table cannot answer that directly, because it only finds exact keys. A trie (from "retrieval", usually pronounced "try"), also called a prefix tree, is built for exactly this job.
In this lesson you will build a trie for city names, use it for exact search and prefix search, write an autocomplete that ranks suggestions, keep prefix counts and delete words. You will also see a simpler alternative using a sorted list.
How a trie stores words
A trie is a tree where each edge is labelled with one character. Each path from the root spells out the beginning of one or more words. Words that share a prefix share the same path, so "Pune" and "Puri" share the nodes for "P" and "Pu", and only split at the third letter.
Each node needs two things: a dictionary from characters to child nodes, and a flag that says "a word ends here". The flag is needed because one word can be a prefix of another, such as "Goa" and "Goalpara".
class TrieNode:
def __init__(self):
self.children = {} # character -> TrieNode
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word.lower():
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def _walk(self, text):
node = self.root
for ch in text.lower():
node = node.children.get(ch)
if node is None:
return None
return node
def contains(self, word):
node = self._walk(word)
return node is not None and node.is_word
def starts_with(self, prefix):
return self._walk(prefix) is not None
cities = Trie()
for city in ["Pune", "Puri", "Patna", "Panaji", "Goa", "Goalpara", "Gorakhpur"]:
cities.insert(city)
print(cities.contains("Puri"), cities.contains("Pur"))
print(cities.starts_with("Pur"), cities.starts_with("Pul"))
print(cities.contains("Goa"), cities.contains("goalpara"))True False True False True True
"Pur" is a valid prefix (of "Puri") but not a word, because the node for "r" is not marked. Each operation walks one node per character, so insert, search and prefix search all cost O(L) for a word or prefix of length L, no matter how many words the trie holds.
Autocomplete
To suggest completions, walk to the node for the prefix, then explore everything below it with a depth-first search, collecting every complete word. Visiting the children in sorted order gives alphabetical suggestions.
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word.lower():
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def suggest(self, prefix, limit=5):
node = self.root
for ch in prefix.lower():
if ch not in node.children:
return []
node = node.children[ch]
results = []
def collect(n, path):
if len(results) == limit:
return # stop early
if n.is_word:
results.append(path)
for ch in sorted(n.children):
collect(n.children[ch], path + ch)
collect(node, prefix.lower())
return results
stations = Trie()
for s in ["Lucknow", "Ludhiana", "Lumding", "Lonavala", "Latur",
"Lalitpur", "Lucknow Junction", "Lunkaransar"]:
stations.insert(s)
print(stations.suggest("Lu"))
print(stations.suggest("Luc"))
print(stations.suggest("La"))
print(stations.suggest("Lx"))['lucknow', 'lucknow junction', 'ludhiana', 'lumding', 'lunkaransar'] ['lucknow', 'lucknow junction'] ['lalitpur', 'latur'] []
Finding the prefix node costs O(L); collecting costs time proportional to the size of that subtree, and the limit stops the search as soon as there are enough suggestions. The space character in "Lucknow Junction" is just another character in the trie.
Ranking suggestions by popularity
Real autocomplete shows the most searched words first. Store a count on each word's end node, and also a prefix_count on every node: the number of words passing through it. That second number answers "how many words start with this prefix?" in O(L) without exploring anything.
import heapq
class TrieNode:
def __init__(self):
self.children = {}
self.searches = 0 # > 0 means a word ends here
self.prefix_count = 0 # words that pass through this node
class Trie:
def __init__(self):
self.root = TrieNode()
def add(self, word, searches):
node = self.root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.prefix_count += 1
node.searches += searches
def _find(self, prefix):
node = self.root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return None
return node
def count_prefix(self, prefix):
node = self._find(prefix)
return node.prefix_count if node else 0
def top(self, prefix, k):
node = self._find(prefix)
if node is None:
return []
found, stack = [], [(node, prefix)]
while stack: # iterative DFS
n, word = stack.pop()
if n.searches:
found.append((n.searches, word))
for ch, child in n.children.items():
stack.append((child, word + ch))
return [w for _, w in heapq.nlargest(k, found)]
dishes = Trie()
for dish, n in [("paneer tikka", 940), ("paneer butter masala", 1210),
("pav bhaji", 1530), ("pani puri", 870), ("paratha", 660),
("pasta", 410)]:
dishes.add(dish, n)
print(dishes.top("pa", 3))
print(dishes.top("paneer", 3))
print(dishes.count_prefix("pa"), dishes.count_prefix("pan"), dishes.count_prefix("pi"))['pav bhaji', 'paneer butter masala', 'paneer tikka'] ['paneer butter masala', 'paneer tikka'] 6 3 0
This version uses an explicit stack instead of recursion, so very long words cannot hit the recursion limit, and heapq.nlargest() from Heaps & Priority Queues picks the top k.
Deleting a word
To delete, unmark the end node. Then, on the way back up, remove any node that is no longer part of any word: it is not marked and has no children. Recursion makes the "on the way back up" part natural.
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
def insert(root, word):
node = root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def delete(node, word, i=0):
"""Remove word; return True if this node can now be removed too."""
if i == len(word):
node.is_word = False
else:
child = node.children.get(word[i])
if child and delete(child, word, i + 1):
del node.children[word[i]]
return not node.is_word and not node.children
def count_nodes(node):
return 1 + sum(count_nodes(c) for c in node.children.values())
root = TrieNode()
for w in ["goa", "goalpara", "gonda"]:
insert(root, w)
print("nodes:", count_nodes(root))
delete(root, "goalpara")
print("nodes after deleting goalpara:", count_nodes(root))
delete(root, "goa")
print("nodes after deleting goa:", count_nodes(root))nodes: 12 nodes after deleting goalpara: 7 nodes after deleting goa: 6
The trie starts with 12 nodes: the root, eight nodes for "goalpara" (the first three are shared with "goa") and three more for "gonda", which shares "go". Deleting "goalpara" removed its five private nodes ("l", "p", "a", "r", "a") and stopped at the "a" of "goa", because that node still marks a word. Deleting "goa" unmarked that "a"; it then had no children either, so it was removed too. The "g" and "o" nodes stayed, because "gonda" still needs them.
A simpler alternative: sorted list and bisect
For a fixed list of words, you can get prefix search without a trie. In a sorted list, all words that start with a prefix sit next to each other. bisect_left() finds the first one in O(log n) (see Binary Search & Its Variants):
import bisect
words = sorted(["lucknow", "ludhiana", "lumding", "lonavala", "latur", "lalitpur"])
def with_prefix(prefix):
start = bisect.bisect_left(words, prefix)
out = []
for w in words[start:]:
if not w.startswith(prefix):
break
out.append(w)
return out
print(with_prefix("lu"))
print(with_prefix("la"))['lucknow', 'ludhiana', 'lumding'] ['lalitpur', 'latur']
| Need | Best choice |
|---|---|
| Exact lookups only | set or dict |
| Prefix search on a fixed word list | Sorted list with bisect |
| Prefix search with frequent inserts and deletes, prefix counts or per-letter logic | Trie |
Tries use a lot of memory, one dictionary per node. Production systems often use a compressed trie (radix tree), which merges chains of single-child nodes into one edge labelled with a whole string.
Common mistakes
- Forgetting the end-of-word flag. Without it, every prefix looks like a word.
- Mixing letter cases. Normalise with
lower()on both insert and search. - Using a trie when a set would do. For exact lookups, a set is simpler and uses far less memory.
- Deleting nodes that other words still use. Only remove nodes that are unmarked and have no children.
Key points
- A trie stores words along root-to-node paths, so words with a common prefix share nodes.
- Each node holds a children dictionary and an end-of-word flag.
- Insert, search and prefix check cost O(L) for a word of length L, independent of the number of words.
- Autocomplete walks to the prefix node, then collects words below it; counts on nodes allow ranking and prefix counting.
- A sorted list with
bisecthandles prefix search on fixed data; a trie shines when words change often.
Exercise
Write longest_common_prefix(words) using a trie: insert all the words, then walk down from the root while the current node has exactly one child and is not the end of a word. Test it with ["interview", "internet", "interval", "internal"] and ["dsa", "java"].
Show solution
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
def longest_common_prefix(words):
root = TrieNode()
for w in words:
node = root
for ch in w:
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
prefix, node = "", root
while len(node.children) == 1 and not node.is_word:
ch, node = next(iter(node.children.items()))
prefix += ch
return prefix
print(repr(longest_common_prefix(["interview", "internet", "interval", "internal"])))
print(repr(longest_common_prefix(["dsa", "java"])))'inter' ''
The common prefix ends where the trie first branches or where a word ends. For "dsa" and "java" the root already has two children, so the prefix is empty.
Quick Quiz
1. What does the end-of-word flag in a trie node tell you?
Without the flag, prefixes such as "Pur" would be mistaken for words.
2. What is the cost of checking whether a prefix of length L exists in a trie of n words?
The walk follows one node per character and never looks at other words.
3. Which is the simplest good choice when you only need exact word lookups?
Sets give O(1) average exact lookups with much less memory than a trie.
4. In a sorted list, why are all words with the same prefix next to each other?
Words that share a prefix sort into one consecutive block.
Learn DSA with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
