What a trie is and why it exists
A trie (also called a prefix tree) is a way to store a collection of words so that you can look them up by spelling them out one letter at a time. The name comes from the word retrieval. Most people pronounce it “try”.
Here is the problem it solves. Suppose you are building the search box on a phone. The user types c, then a, then r, and you want to instantly know: is car a real word? And also: are there any words that start with car, so you can suggest card, care, cargo? A plain list of words would force you to scan every word to answer those questions. A trie answers them by walking down a path, one letter at a time, doing a tiny amount of work per letter.
The core idea: a path from the top of the tree down to a marked point spells a word. Words that share a beginning share the same path near the top, and only branch apart where they start to differ.
Before we go further, a few plain definitions, since this lesson assumes no prior programming knowledge:
- A string is a piece of text, for example
"car". A character (or char) is a single symbol in it, likec. - A prefix is the beginning part of a word.
cais a prefix ofcarand ofcard. Every word is a prefix of itself. - A tree is a structure made of nodes (boxes that hold information) connected by links. One node is the root (the starting point at the top). A node’s children are the nodes hanging directly below it.
- A dictionary in Python (written
{}) is a lookup table that maps a key to a value. Here we map a character to the child node that character leads to.
The shape of one node
Every node in a trie holds two things:
children: a dictionary mapping each next character to the child node it leads to. If the node has links foraando, thenchildrenhas two entries.is_end: a flag (a true/false value) that says “a word ends exactly here.” We need this because a word can be a prefix of a longer word. For examplecarandcardshare a path; the flag tells us that stopping atris itself a complete word.
Here is a trie holding cat, car, and dog. The root at the top holds nothing itself; it is just the entry point. Nodes marked (end) are where a word finishes.
flowchart TD
root((root))
c((c))
ca((a))
cat((t end))
car((r end))
d((d))
do((o))
dog((g end))
root -->|c| c
c -->|a| ca
ca -->|t| cat
ca -->|r| car
root -->|d| d
d -->|o| do
do -->|g| dog
Read it by following the arrows and collecting the letters on them. c then a then t reaches a node marked end, so cat is stored. c then a then r reaches another end node, so car is stored. Notice cat and car share the c -> a part of the path and split only at the last letter. That sharing is what makes a trie efficient: common prefixes are stored once.
Building a Trie in Python
We use two classes. A class is a blueprint for making objects that bundle data together. TrieNode is one node; Trie is the whole tree plus the operations on it.
class TrieNode:
def __init__(self):
self.children = {} # char -> TrieNode
self.is_end = False # True if a word ends at this node
class Trie:
def __init__(self):
self.root = TrieNode() # the root holds no character itself
__init__ is the setup method that runs when you create a new object. self refers to the object being set up. So each new TrieNode starts with an empty children dictionary and is_end = False.
Insert
To insert a word, start at the root and walk down one character at a time. For each character, if there is no child for it yet, create one. Then move into that child. When the word is exhausted, mark the node you landed on as the end of a word.
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() # create the missing link
node = node.children[ch] # step into the child
node.is_end = True # mark the end of the word
Let me walk the code once. node starts at the root. for ch in word visits each character in order. if ch not in node.children asks the dictionary whether a link for that character already exists; if not, we add one. Then node = node.children[ch] moves our position down into that child. After the loop, node sits on the last character of the word, and we set its flag to True.
To watch insert reshape the tree, say the trie already holds cat, car, and dog, and we add card. We walk c -> a -> r, all of which already exist, so nothing new is created along the way. Here is the c branch just before the insert:
flowchart TD
root((root))
c((c))
a((a))
t((t end))
r((r end))
root -->|c| c
c -->|a| a
a -->|t| t
a -->|r| r
At r there is no child d, so we create one and mark it as the end of a word. Only the d node is new, and r keeps its end mark because car is still a word:
flowchart TD
root((root))
c((c))
a((a))
t((t end))
r((r end))
d((d end))
root -->|c| c
c -->|a| a
a -->|t| t
a -->|r| r
r -->|d| d
The important detail: adding card did not disturb car. The r node stays marked as an end. One path can hold two words, one ending at r and a longer one ending at d. That is what the flag makes possible.
Walking that same insert one line at a time makes the reuse concrete, starting from the trie that holds cat, car, dog. The “action” column says whether each link already existed or was created:
| Step | Character | Link exists? | Action | Node after step |
|---|---|---|---|---|
| start | — | — | begin at root | root |
| 1 | c | yes | reuse existing child | c |
| 2 | a | yes | reuse existing child | a (after c) |
| 3 | r | yes | reuse existing child | r (after ca) |
| 4 | d | no | create new child | d (after car) |
| end | — | — | set is_end = True on d | d marked as word |
Only one new node was created, at step 4. The first three characters rode along the path that car had already built. This reuse is why tries are efficient for sets of words that share beginnings.
Search and startswith
Both of these walk down the tree the same way, following characters. The difference is only what happens at the end.
search(word): does this exact word exist? Walk the characters; if any character has no link, the word is not there. If you reach the end, return theis_endflag, because reaching a node is not enough. The word must actually have been marked as ending there.startswith(prefix): does any stored word begin with this prefix? Same walk, but we do not care about the flag. If the path exists at all, some word uses it.
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):
# follow the characters; return the node we land on, or None
node = self.root
for ch in text:
if ch not in node.children:
return None
node = node.children[ch]
return node
def search(self, word):
node = self._walk(word)
return node is not None and node.is_end
def startswith(self, prefix):
return self._walk(prefix) is not None
The helper _walk does the shared work: it follows the characters and returns the node it ends on, or None (Python’s “nothing here” value) if the path breaks. The leading underscore in _walk is a convention meaning “internal helper, not for outside use.”
Now a full run with expected output shown as comments:
t = Trie()
for w in ["cat", "car", "dog"]:
t.insert(w)
print(t.search("car")) # -> True (car was inserted)
print(t.search("ca")) # -> False (path exists but not marked as a word)
print(t.search("cab")) # -> False (no 'b' link after 'ca')
print(t.startswith("ca")) # -> True (cat and car both start with 'ca')
print(t.startswith("do")) # -> True (dog starts with 'do')
print(t.startswith("z")) # -> False (nothing starts with 'z')
Look closely at search("ca") returning False while startswith("ca") returns True. Both reach the same node, the one after c -> a. Search checks its is_end flag, which is False there because ca was never inserted as a word. Startswith only checks that the node exists. This is exactly why the is_end flag has to exist.
Cost in Big-O
Big-O is a way to describe how the amount of work grows as the input grows, ignoring constant factors. O(L) means “work grows in proportion to L.” Here L is the length of the word or prefix being handled, and A is the size of the alphabet (26 for lowercase English letters).
Every operation walks one node per character, and looking up or inserting one entry in a Python dictionary is O(1) on average (constant time, independent of how many entries exist). So the work is one constant-time step per character: O(L) total.
| Operation | Time | Space (extra) | Why |
|---|---|---|---|
insert(word) | O(L) | O(L) worst case | one step per character; may create up to L new nodes |
search(word) | O(L) | O(1) | one step per character; no new nodes |
startswith(prefix) | O(L) | O(1) | one step per character; no new nodes |
Notice what is not in that table: the number of words already stored. Searching a trie holding ten words and one holding ten million words both cost O(L) for a word of length L. The lookup time depends on the word, not on how full the trie is. That is the trie’s headline advantage.
Total space for the whole trie is O(total characters across all words) in the worst case, but shared prefixes are stored once, so in practice a trie over related words is more compact than that bound suggests.
Where tries are used
- Autocomplete and type-ahead search. Walk to the node for what the user typed, then explore everything below it to list the possible completions. The
startswithwalk is the first half of that. - Prefix matching and validation. Check whether a typed string is a valid prefix of any allowed word, useful in command-line tools and form fields.
- Spell checkers and word games. Fast membership tests over a fixed dictionary of valid words.
- IP routing and phone-number prefixes. The same idea works with digits or bits, not just letters.
Common pitfalls
- Forgetting the
is_endflag. Without it you cannot tell a stored word from a mere prefix.search("ca")would wrongly returnTruejust because the path toaexists. Reaching a node is necessary but not sufficient; the flag is what confirms a word ends there. - Confusing
searchwithstartswith. They walk identically but decide differently. Search checks the flag at the end; startswith only checks that the path exists. Mixing them up is the most common trie bug. - Reusing one node for two words by accident. Each
TrieNode()must be its own object. If you wrotenode.children[ch] = shared_node, different branches would corrupt each other. In the code above each missing link gets a freshTrieNode(), which is correct. - Empty string. Inserting
""runs the loop zero times and setsis_end = Trueon the root itself. Decide whether your application should allow that; it is legal but easy to overlook. - Case and characters.
Carandcarare different becauseCandcare different characters. If you want case-insensitive matching, convert the word to lowercase before inserting and searching.
Practice
- Add a method
count_words(self)that returns how many complete words the trie holds. Hint: visit every node and count the ones whereis_endisTrue. - Add a method
all_with_prefix(self, prefix)that returns a list of every stored word beginning withprefix. Walk to the prefix node first, then collect the letters along every downward path that reaches an end. - Trace on paper what the trie looks like after inserting
to,tea,ten,ted,i,in,inn. Which nodes are marked as ends, and which characters are shared between words?