Tries: Insert and Search
26 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A trie stores words letter by letter so that words with the same start share the same path. That makes prefix questions, such as which words start with ca, fast. Here you build one with insert, search and a prefix check.
A tree of letters
A trie is a tree where each edge is one character, so every path from the root spells a prefix. A flag on a node marks the end of a stored word. Words with the same start share the same nodes.
Many words start the same way: car, cart, cat. A trie stores each shared start once. Every step down the tree adds one letter. A mark on a node says that a word ends there.
root = {}for word in ["car", "cat", "dog"]: node = root for ch in word: node = node.setdefault(ch, {}) node["end"] = Trueprint(sorted(root))Example 1 · The nested-dictionary trie
root = {}for word in ["car", "cart", "cat"]: node = root for ch in word: node = node.setdefault(ch, {}) node["end"] = Trueprint(root){'c': {'a': {'r': {'end': True, 't': {'end': True}}, 't': {'end': True}}}}What this code does
- The path c, a, r has an end mark, and it continues to t for cart. Both words share c and a.
- This short form is good for contests. An interview usually wants a node class.
Each node is a dictionary from a letter to the next node. setdefault returns the child if it exists, and creates an empty one if not. The key "end" can never clash with a letter, because letters are one character long.
Insert with a node class
Interviews expect a small node class. A node has children, a dictionary from letter to node, and is_end, a flag. To insert, walk down the letters, create missing nodes, and set the flag on the last node.
class TrieNode: def __init__(self): self.children = {} self.is_end = False
def insert(root, word): node = root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_end = TrueExample 2 · A Trie class
class TrieNode: def __init__(self): self.children = {} self.is_end = False
class Trie: def __init__(self): self.root = TrieNode()
def insert(self, word): node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_end = True
def _walk(self, text): node = self.root for ch in text: node = node.children.get(ch) if node is None: return None 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
trie = Trie()for word in ["car", "cart", "cat", "dog"]: trie.insert(word)print(trie.search("car"), trie.search("ca"), trie.search("care"))print(trie.starts_with("ca"), trie.starts_with("do"), trie.starts_with("x"))True False False True True False
What this code does
- "ca" is a prefix but not a stored word, so
searchsays False andstarts_withsays True. - "care" fails at the last letter because no node follows r with an e.
Insert takes one dictionary step per letter, so it costs O(L) time for a word of length L, no matter how many words are stored. New nodes add at most L nodes of space.
Search and starts_with
Search and prefix check share one walk: follow the letters down. If a letter is missing, stop with None. Search then asks if the last node has is_end set. A prefix check only asks if the walk reached a node.
def walk(root, text): node = root for ch in text: node = node.children.get(ch) if node is None: return None return node
def search(root, word): node = walk(root, word) return node is not None and node.is_endExample 3 · Search against prefix
class TrieNode: def __init__(self): self.children = {} self.is_end = False
class Trie: def __init__(self): self.root = TrieNode()
def insert(self, word): node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_end = True
def _walk(self, text): node = self.root for ch in text: node = node.children.get(ch) if node is None: return None 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
trie = Trie()trie.insert("net")for text in ["n", "ne", "net", "nets"]: print(text, trie.search(text), trie.starts_with(text))n False True ne False True net True True nets False False
What this code does
- Only "net" is a stored word. The first three are prefixes, and "nets" is neither.
- Remember the pair:
searchneedsis_end,starts_withdoes not.
With car and cart stored, search("ca") is False, since no word ends at a, but the prefix check for "ca" is True. Both cost O(L).
A trie against a list and a set
A set finds an exact word in O(L), since hashing reads every letter. So a trie is not faster for exact search. It wins for prefix questions. A list needs a scan of all N words to answer them.
words = ["car", "cart", "cat", "dog"]print("cat" in set(words))print(any(w.startswith("ca") for w in words))Example 4 · Shared prefixes save nodes
class TrieNode: def __init__(self): self.children = {} self.is_end = False
class Trie: def __init__(self): self.root = TrieNode()
def insert(self, word): node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_end = True
def _walk(self, text): node = self.root for ch in text: node = node.children.get(ch) if node is None: return None 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_nodes(node): return 1 + sum(count_nodes(child) for child in node.children.values())
words = ["car", "cart", "cat", "dog"]trie = Trie()for word in words: trie.insert(word)print(sum(len(w) for w in words), count_nodes(trie.root) - 1)13 8
What this code does
- There are 13 letters in the four words, but only 8 letter nodes.
candaare stored once, andcaris reused insidecart. count_nodescalls itself on every child, which is a depth-first walk.
Example 5 · A trie and a plain scan agree
class TrieNode: def __init__(self): self.children = {} self.is_end = False
class Trie: def __init__(self): self.root = TrieNode()
def insert(self, word): node = self.root for ch in word: if ch not in node.children: node.children[ch] = TrieNode() node = node.children[ch] node.is_end = True
def _walk(self, text): node = self.root for ch in text: node = node.children.get(ch) if node is None: return None 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
import random
random.seed(5)words = ["".join(random.choice("abc") for _ in range(random.randint(1, 5))) for _ in range(300)]trie = Trie()for word in words: trie.insert(word)ok = Truefor prefix in ["a", "ab", "bca", "cc", "abcab", "z"]: by_scan = any(w.startswith(prefix) for w in words) ok = ok and by_scan == trie.starts_with(prefix)print(ok)True
What this code does
- The simple scan is the checker. It is slow but obviously right.
- On 300 random words both methods give the same answer for every prefix.
The scan with startswith costs O(N times L). A trie answers the same prefix question in O(L), and can also list or count every word under that prefix. The price is more memory, since each letter is a node.
Next step
Check your understanding
10 quick questions