LearnAbout

Tries

On this page
Quick reference
insert(word)O(L)
search(word)O(L)
startsWith(prefix)O(L)
Total space, all inserted wordsโ€”
The Idea

Imagine a dictionary organized letter by letter instead of alphabetically on a page. Every word that starts with "ca" shares the same first two branches (c, then a). The words split apart only where they actually differ. "cat" and "car" share the c-a path and split at the third letter. A trie (short for reTRIEval, usually said "try") is this exact shape, built for strings. Every path from the root spells out a prefix. Any two words that share a prefix share the same path for as long as they agree.

This shared-path structure is the whole point. To ask "does any stored word start with 'ca'" does not need a check of every stored word one at a time. The question is only this: does the path c, then a, exist in the tree? If it does, every word down that branch starts with "ca", whether there are two of them or two thousand.

A trie node looks like the Node in Binary Trees, with one change scaled up. Instead of two fixed slots (left, right), it has one slot per possible next character. It is the same branching idea (a node points to more nodes), but it is no longer limited to two children.
Build It
The node
structure TrieNode:
    // one entry per character that is present, not one slot per possible character
    children: map from character -> TrieNode
    isEndOfWord: boolean                        // true only if some inserted word ends exactly at this node

structure Trie:
    root: TrieNode (empty children, isEndOfWord = false)   // the root represents the empty prefix
insert โ€” walk or create, one character at a time โ€” O(L)
function insert(trie, word):
    node = trie.root
    for ch in word:
        if ch not in node.children:
            node.children[ch] = new TrieNode()
        node = node.children[ch]
    node.isEndOfWord = true       // mark the END of the path, not every node along it
search โ€” exact word, must end exactly here โ€” O(L)
function search(trie, word):
    node = trie.root
    for ch in word:
        if ch not in node.children:
            return false          // the path does not exist: the word was never inserted
        node = node.children[ch]
    return node.isEndOfWord       // the path exists, but it is a real match only if a word ends HERE
startsWith โ€” prefix only, path existing is enough โ€” O(L)
function startsWith(trie, prefix):
    node = trie.root
    for ch in prefix:
        if ch not in node.children:
            return false
        node = node.children[ch]
    return true                   // to reach the end of the path is the whole answer
search and startsWith are the same walk with one different last line. search asks "is node.isEndOfWord true". startsWith asks only "did I reach here at all". To confuse which question each one answers is the most common trie bug. See Break It.
Know It

L is the length of the word or prefix that you insert or check, not the number of words already stored. insert, search, and startsWith all cost O(L), whether the trie holds ten words or ten million, because each one is a walk down a single path, one character per step. A hash set gives O(L) average for exact search too (to hash a string still costs one pass over its characters). But a hash set cannot answer startsWith at all without a scan of every stored string to check which ones begin with the prefix. This is the real advantage of the trie. It is not faster at the thing a hash set already does. It does a thing that a hash set structurally cannot do.

OperationTimeSpaceWhy
insert(word)O(L)O(L) worst caseIt walks or creates exactly one node per character of the word. A new word with no shared prefix creates L new nodes.
search(word)O(L)O(1)This is one walk down the path spelled out by the word, then one isEndOfWord check.
startsWith(prefix)O(L)O(1)This is the same walk as search. The only difference is which question is asked once the walk ends.
Total space, all inserted wordsโ€”O(total characters)The worst case (no shared prefixes at all) is one node per character across every word. Real text shares prefixes often, so actual usage is well below that ceiling.
Break It

search vs. startsWith โ€” the classic mix-up

code checks only "does the path exist" when it needed "does a word end exactly here", or the reverse

If the path for "car" exists in a trie that has "card" stored, this does not mean that "car" itself was ever inserted as a word. To walk the path only proves that "car" is a valid prefix of something. search("car") must fail here unless "car" was inserted separately and its isEndOfWord flag was set. This one distinction (the path exists versus the path ends in a real word) is the one to get exactly right. Almost every trie bug comes from a blur of this distinction.

Forgetting to set isEndOfWord

insert() builds the whole character path but the final line never runs, or runs on the wrong node

Every character of the word gets a node, so the path is walked or created correctly. But then search() on that exact word still returns false, because nothing marked the last node as a real word ending. A fully built path is necessary but not sufficient. The flag on the final node is what search actually reads.

Empty string as input

insert("") or search("") โ€” a word or query with zero characters

The loop body never runs for an empty string, so node stays at trie.root the whole time. insert("") should mark the root itself as isEndOfWord = true (the empty string was "inserted"). search("") should then return true. Code that assumes every word touches at least one child node handles this wrong with no crash. It gives the wrong answer for the one input that never enters the loop.

Character-set assumptions baked into the structure

children is built as a fixed-size array (for example, 26 slots for lowercase a-z) instead of a map

A 26-slot array is faster and uses less memory than a map when the input really is guaranteed to be lowercase only. But when uppercase letters, digits, spaces, or Unicode characters appear, an index by (char - 'a') either writes to the wrong slot or throws on a negative or out-of-range index. A children map has no such ceiling. It costs a little more per node, but it never has to know the alphabet in advance.

Use It
Implement Trie (Prefix Tree)
Use insert, search, and startsWith from Build It, exactly. This is the reference exercise for the structure itself.
Medium
Design Add and Search Words Data Structure
Use search() from Build It, but a '.' character means "try every child at this position". Branch into all of node.children instead of one, the same DFS-over-a-tree shape as Binary Trees.
Medium
Longest Word in Dictionary
insert() every word. Then DFS from the root, but step only into a child whose isEndOfWord is already true. A word counts only if every prefix that builds it was also inserted as a complete word.
Medium
Replace Words
insert() every root word into a trie. For each word in the sentence, walk down the trie until you reach isEndOfWord (the shortest matching root) or the path runs out (no root, so keep the original word).
Medium
Map Sum Pairs
insert() as usual, but store a value at the end node of each word. sum(prefix) walks to the end of the path of the prefix. Then it does a DFS of the subtree below it and sums every isEndOfWord value found.
Medium
Search Suggestions System
insert() every product. For each growing prefix of the search word, walk to the node of that prefix. Then do a DFS of its subtree to collect complete words, and keep the 3 lexicographically smallest words.
Medium
Index Pairs of a String
insert() every word into a trie. For each start index in text, walk the trie as far as the characters match. Record [start, current] every time isEndOfWord is true during the walk.
Easy
Camelcase Matching
This is not a trie build, but it needs the same discipline as a walk of one. For each query, walk the pattern and the word together. Every pattern character must appear in order, and every skipped word character must be lowercase.
Medium