Implement a Trie: Insert, Search and startsWith Explained
7 min readBytePatterns
How a trie (prefix tree) stores words one character per edge, why every node needs an end-of-word flag, and insert, search and startsWith in O(L) time each.
"Implement a trie with insert, search and startsWith." It is one of the few interview questions that asks you to build a data structure rather than use one, and the whole difficulty sits in a single boolean. Get the end-of-word flag right and the rest is a loop over characters.
The problem it solves
Store a set of words so that three questions are fast:
insert(word)adds a word.search(word)says whether that exact word was inserted.starts_with(prefix)says whether any inserted word begins withprefix.
A hash set answers search in expected O(L) time for a word of length L, since it has to hash the string. It has no good answer for starts_with: without extra structure, it must check every stored word. A trie answers both by walking the same path.
The intuition
A trie spells words out instead of storing them whole. Each edge carries one character, so the path from the root down to any node spells a prefix. Words that share a prefix share those nodes: car, cart and cat all go through c and then a, and only split below that.
Two consequences follow:
- Every question is a walk. To search or check a prefix, start at the root and follow one edge per character. If an edge is missing, the answer is no, and you stop early.
- The path is not enough for
search. After insertingcart, the pathc, a, rexists, butcarwas never inserted. So each node carries a flag,is_end, set only where an inserted word stops.searchneeds the path and the flag;starts_withneeds only the path.
Watch it run
The animation starts from a lone root and inserts car, creating one node per character and flagging r. Inserting cart walks the three existing nodes and builds only t. Inserting cat shares c and a and grows a second branch. The last two frames run the lesson's searches: search("car") ends on a flagged node, so it is a word; search("ca") finds the path but no flag, so it is only a prefix.
Trie Basics
Step 1 of 10
An empty trie is one root node. Every word starts its path here.
The same interactive animation as the lesson — step through it with the controls.
The code
A class-based trie. Each node holds a dictionary of children and the end-of-word flag; _walk is shared by both lookups:
class TrieNode:
__slots__ = ("children", "is_end")
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, s):
node = self.root
for ch in s:
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):
node = self._walk(prefix)
# the root exists even in an empty trie, so "" needs a real word below
return node is not None and (node.is_end or bool(node.children))
t = Trie()
for w in ["car", "cart", "cat"]:
t.insert(w)
print(t.search("car"), t.search("ca"), t.starts_with("ca"), t.starts_with("co"))
# True False True False
Autocomplete is the same walk followed by a depth-first search below the prefix node. Visiting children in sorted order returns the words alphabetically:
def words_with_prefix(trie, prefix):
node, out = trie._walk(prefix), []
def dfs(n, path):
if n.is_end:
out.append(path)
for ch in sorted(n.children):
dfs(n.children[ch], path + ch)
if node:
dfs(node, prefix)
return out
print(words_with_prefix(t, "ca")) # ['car', 'cart', 'cat']
All three operations against a plain Python set as the reference, on 500 random word sets over a two-letter alphabet, 20 queries each. A small alphabet makes shared prefixes and prefix-only paths common, and the empty string shows up as both a word and a query:
import random
random.seed(8)
ok = True
for _ in range(500):
words = {"".join(random.choice("ab") for _ in range(random.randint(0, 5)))
for _ in range(random.randint(0, 12))}
trie = Trie()
for w in words:
trie.insert(w)
for _ in range(20):
q = "".join(random.choice("ab") for _ in range(random.randint(0, 6)))
ok &= trie.search(q) == (q in words)
ok &= trie.starts_with(q) == any(w.startswith(q) for w in words)
ok &= words_with_prefix(trie, q) == sorted(w for w in words if w.startswith(q))
print(ok) # True
A common shorter starts_with returns just _walk(prefix) is not None. The random test fails on it: an empty trie still has a root, so starts_with("") says yes with no words stored. That is the kind of case a hand-picked example never reaches.
The complexity
insert,search,starts_witheach takeO(L)time for a string of lengthL: one dictionary step per character. The number of stored words never appears in the cost.- Space is at most one node per inserted character,
O(total characters), and shared prefixes make it smaller. Each node, though, is a whole object with its own dictionary, so in Python a trie usually takes more memory than a set of the same strings. - Autocomplete costs
O(P)to reach the prefix node plus time proportional to the size of the subtree below it. Sorting the children at each node adds a small factor; with a fixed alphabet, an array of 26 child slots gives the order for free.
Where it goes wrong
- No end-of-word flag. Without it,
search("car")after inserting onlycartreturns true. The flag is the difference between a word and a prefix. - Setting the flag on the wrong node. It belongs on the node reached after the last character, not on its parent.
- Treating the root as a word. The root spells the empty string. It is a word only if
""was inserted, and it is a prefix of something only if the trie is non-empty. - Deleting by removing nodes blindly. Deleting
carmust only clear its flag whilecartstill needs the path; nodes can be pruned only when they have no children and no flag. - Assuming the trie is always the better tool. For exact-match lookups alone, a hash set is simpler and usually lighter. The trie earns its memory when prefixes matter.
How to say it in an interview
"A trie stores words one character per edge, so the path from the root spells a prefix and shared prefixes share nodes. Each node has a child map and an end-of-word flag. Insert walks the word, creating missing children, and sets the flag on the last node. Search walks the word and needs both the path and the flag; startsWith needs only the path. All three are O(L) in the length of the string, independent of how many words are stored. Memory is at most one node per inserted character, less when prefixes are shared."
When to reach for a trie over a set is weighed in Trie vs hash set for prefixes, and the autocomplete walk is its own lesson, prefix search and autocomplete.