DSA Implementing a Trie
A trie is a tree of letter nodes that stores words one letter per level. Building the class means adding insert, search, prefix and prefix-count operations on that tree.
The problem
A trie is a tree of letter nodes that stores words one letter per level. Implementing a trie means building one class that supports four operations on that tree.
The operations are insert, search, starts_with and count_prefix. Search answers whether a full word was inserted. Starts_with answers whether any inserted word opens with a prefix. Count_prefix answers how many inserted words open with a prefix.
Each node carries a counter for the words passing through it. That counter is what makes count_prefix a single walk instead of a scan. The operation script below is the contract the class must meet.
The operation script used in this article
script = [
("insert", "cat"),
("insert", "car"),
("insert", "card"),
("insert", "dog"),
]
checks = [
("search", "cat", True),
("search", "ca", False),
("search", "card", True),
("search", "cards", False),
("starts_with", "ca", True),
("starts_with", "do", True),
("starts_with", "z", False),
("count_prefix", "ca", 3),
("count_prefix", "car", 2),
("count_prefix", "dog", 1),
]
for action, word in script:
print(action, word)
for kind, text, expected in checks:
print(kind, text, "->", expected)Read the checks in groups. The search rows separate full words from prefixes and from words that were never inserted. The count rows read 3, 2 and 1 for three prefixes of shrinking reach.
Intuition: keep the words in a plain list
The first design stores each word in a list. Search compares the target against stored words one by one. A prefix count checks every stored word for the prefix.
The design is correct. Its cost is the trap. Every query pays for a scan of the whole list, and the list only grows.
A prefix count that scans every word
words = []
def count_prefix_list(prefix):
checks = 0
total = 0
for word in words:
checks += 1
if word.startswith(prefix):
total += 1
return total, checks
for word in ["cat", "car", "card", "dog"]:
words.append(word)
total, checks = count_prefix_list("ca")
print("words starting with ca:", total)
print("words checked:", checks)Four stored words cost four checks for one answer. Four thousand stored words would cost four thousand checks for the same kind of answer. The stored words share long openings, and the list design uses none of that sharing.
Brute force: the list design, counted
The brute force is the list design written as a class. Each query loops over the stored words. A counter records every word comparison the queries make.
The list design runs the script queries
class ListDictionary:
def __init__(self):
self.words = []
self.checks = 0
def insert(self, word):
self.words.append(word)
def search(self, word):
for stored in self.words:
self.checks += 1
if stored == word:
return True
return False
def starts_with(self, prefix):
for stored in self.words:
self.checks += 1
if stored.startswith(prefix):
return True
return False
def count_prefix(self, prefix):
total = 0
for stored in self.words:
self.checks += 1
if stored.startswith(prefix):
total += 1
return total
d = ListDictionary()
for word in ["cat", "car", "card", "dog"]:
d.insert(word)
print("search card:", d.search("card"))
print("search ca:", d.search("ca"))
print("starts_with do:", d.starts_with("do"))
print("count_prefix ca:", d.count_prefix("ca"))
print("word checks so far:", d.checks)Four small queries spend 15 word checks between them. A failed search is the most expensive query, because only a full scan proves the word is absent. The count query always pays the full scan, even when the answer is small.
The optimal solution: one node per letter, a counter per node
The state is a tree where each node holds a dict of child nodes keyed by letter, a word-end flag, and a counter of the words passing through. Every operation walks one node per letter, so its cost follows the word length and never the number of stored words.
Insert creates missing nodes on the path and raises the counter of every node it touches. Search walks the word and reads the end flag on the final node. Count_prefix walks the prefix and reads the counter on the final node.
The Trie class on the full script
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
self.prefix_count = 0
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
node.prefix_count += 1
for letter in word:
if letter not in node.children:
node.children[letter] = TrieNode()
node = node.children[letter]
node.prefix_count += 1
node.is_end = True
def walk(self, text):
node = self.root
for letter in text:
if letter not in node.children:
return None
node = node.children[letter]
return node
def search(self, word):
node = self.walk(word)
return node is not None and node.is_end
def starts_with(self, prefix):
return self.walk(prefix) is not None
def count_prefix(self, prefix):
node = self.walk(prefix)
if node is None:
return 0
return node.prefix_count
trie = Trie()
for word in ["cat", "car", "card", "dog"]:
trie.insert(word)
checks = [
("search", "cat", True),
("search", "ca", False),
("search", "card", True),
("search", "cards", False),
("starts_with", "ca", True),
("starts_with", "do", True),
("starts_with", "z", False),
("count_prefix", "ca", 3),
("count_prefix", "car", 2),
("count_prefix", "dog", 1),
]
passed = 0
for kind, text, expected in checks:
if kind == "search":
got = trie.search(text)
elif kind == "starts_with":
got = trie.starts_with(text)
else:
got = trie.count_prefix(text)
ok = got == expected
passed += 1 if ok else 0
print(kind, text, ": got", got, ", expected", expected, ", pass", ok)
print("Passed", passed, "of", len(checks))All ten checks from the start of the article pass. The word ca fails search but passes starts_with, and that split is the end flag doing its job. The path for ca exists, but no inserted word ends there.
Walkthrough: inserting car, then card
The walk inserts car first and reads the nodes along its path. It then inserts card and reads the same path again. Shared nodes keep one identity, and their counters rise.
Node counters along the path, before and after card
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
self.prefix_count = 0
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
node.prefix_count += 1
for letter in word:
if letter not in node.children:
node.children[letter] = TrieNode()
node = node.children[letter]
node.prefix_count += 1
node.is_end = True
def walk(self, text):
node = self.root
for letter in text:
if letter not in node.children:
return None
node = node.children[letter]
return node
def search(self, word):
node = self.walk(word)
return node is not None and node.is_end
def starts_with(self, prefix):
return self.walk(prefix) is not None
def count_prefix(self, prefix):
node = self.walk(prefix)
if node is None:
return 0
return node.prefix_count
trie = Trie()
trie.insert("car")
print("after inserting car:")
for label in ["", "c", "ca", "car"]:
node = trie.walk(label)
print("path", repr(label), "- count", node.prefix_count, "- end", node.is_end)
trie.insert("card")
print("after inserting card:")
for label in ["", "c", "ca", "car", "card"]:
node = trie.walk(label)
print("path", repr(label), "- count", node.prefix_count, "- end", node.is_end)The node for car ends both readings with its flag set. Its counter rises from 1 to 2, because card also passes through it. The new node for d starts at 1, since only card reaches it.
Complexity
Each operation walks one node per letter, so insert and search take time proportional to the word length, written O(L) for a word of L letters. The number of stored words does not appear in that cost.
The tree stores one node per distinct letter position across all words, so its space grows with the total distinct prefixes. Shared openings are stored once.
Counting the work instead of timing it
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
self.prefix_count = 0
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
node.prefix_count += 1
for letter in word:
if letter not in node.children:
node.children[letter] = TrieNode()
node = node.children[letter]
node.prefix_count += 1
node.is_end = True
def walk(self, text):
node = self.root
for letter in text:
if letter not in node.children:
return None
node = node.children[letter]
return node
def search(self, word):
node = self.walk(word)
return node is not None and node.is_end
def starts_with(self, prefix):
return self.walk(prefix) is not None
def count_prefix(self, prefix):
node = self.walk(prefix)
if node is None:
return 0
return node.prefix_count
def count_nodes(node):
total = 1
for child in node.children.values():
total += count_nodes(child)
return total
trie = Trie()
letters = 0
for word in ["cat", "car", "card", "dog"]:
trie.insert(word)
letters += len(word)
print("letters inserted:", letters)
print("nodes stored:", count_nodes(trie.root))
for n in [4, 40, 400]:
print("stored words", n, "- list scan checks up to", n, "- trie steps for a 3-letter word:", 3)The four script words carry 13 letters but need only 9 nodes. The shared ca opening is stored once, and the saving grows as more words share openings. A list scan grows with the stored word count, while a trie walk stays at the word length.
Edge cases and pitfalls
An empty trie answers every query with the failure value. The walk finds no first letter, so search is False and the count is 0.
The empty prefix is a real case. Its walk ends at the root, so starts_with answers True. The root counter holds every inserted word, so count_prefix for the empty prefix is the word total.
Inserting the same word twice is the classic pitfall. The end flag changes nothing the second time, but every counter on the path rises again. The counts then describe inserts, not distinct words.
Edge cases, run
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
self.prefix_count = 0
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
node.prefix_count += 1
for letter in word:
if letter not in node.children:
node.children[letter] = TrieNode()
node = node.children[letter]
node.prefix_count += 1
node.is_end = True
def walk(self, text):
node = self.root
for letter in text:
if letter not in node.children:
return None
node = node.children[letter]
return node
def search(self, word):
node = self.walk(word)
return node is not None and node.is_end
def starts_with(self, prefix):
return self.walk(prefix) is not None
def count_prefix(self, prefix):
node = self.walk(prefix)
if node is None:
return 0
return node.prefix_count
trie = Trie()
print("search on empty trie:", trie.search("cat"))
print("count on empty trie:", trie.count_prefix("ca"))
for word in ["cat", "car", "card", "dog"]:
trie.insert(word)
print("search empty word:", trie.search(""))
print("starts_with empty prefix:", trie.starts_with(""))
print("count empty prefix:", trie.count_prefix(""))
print("search cards:", trie.search("cards"))
print("count cards:", trie.count_prefix("cards"))
trie.insert("car")
print("count car after a duplicate insert:", trie.count_prefix("car"))The duplicate insert lifts the car count from 2 to 3, even though the stored word set did not change. A design that needs distinct-word counts must reject a duplicate before walking, or check the end flag first.
Exercise
After inserting cat, car and card, what does count_prefix return for ca?