A trie stores words in a tree where each edge is one letter. So the path from the root down to a node spells a prefix, the start of a word. Words that start the same way share the same path. This means you answer any question about a prefix by walking that prefix once, letter by letter. It doesn't matter how many words you stored.
Think of it like this: a phone's contact search. You type “m” and the list jumps to the M section. You type “ma” and it narrows to Mark, Maria and Matt. You never read through the whole address book. Each letter you type takes you one step further down. Everyone who shares those letters is already grouped together underneath.
How to spot it
Words and constraints in a problem that point to this pattern:
- “Starts with”, “prefix”, “begins with”: many questions about the start of words
- Many words stored once, then many lookups: “add a word”, then “search” or “suggest”
- “As the user types each letter”: autocomplete and search suggestions
- A wildcard such as
.that matches any one letter, where a plainSetcan't look up the pattern - A dictionary of words to match against a long text or a grid of letters
Watch it work
Words you'll need:
- A prefix of a word is any run of letters from its start. "c", "ca" and "car" are the prefixes of "car". A whole word counts as a prefix of itself.
- A trie is a tree, like the ones in the trees lesson. You say it like "try", and the name comes from re*trie*val. In a trie, each node can have many children. Each link from a parent to a child is labelled with one letter.
- The root is an empty starting node. Reading the letters on the way down from the root to any node spells a prefix. So the node you reach by following c, then a, is the prefix "ca".
- Each node keeps its children in a
Mapfrom letter to child node, the sameMapfrom the hash map lesson.children.get("a")finds the "a" child in one step. - Each node also has an end-of-word flag,
isEnd. It istruewhen a stored word finishes exactly at that node. In the drawings below a*marks it.
Why the flag? After inserting "car" and "cat", the node for "ca" exists, but "ca" was never inserted. The flag is how the trie tells "a word ends here" apart from "words pass through here".
The trace inserts "car", "cat" and "do", then searches for "ca". Brackets show the node the code is standing on. The row shows the word, with a pointer i on the current letter.
The template
A trie is two small classes:
- a node, which holds a
Mapof children and an end-of-word flag - the trie itself, which holds the root and walks it
All three operations are the same loop: start at the root and follow one letter at a time.
- insert creates a child whenever one is missing. Then it marks the last node as an end.
- search follows the letters and returns
falseas soon as a child is missing. At the end, it checks the flag. - startsWith is
searchwithout the flag check. Reaching the end of the prefix is enough.
search and startsWith share the walk, so it lives in one helper, walk. It returns the node a prefix leads to, or null if the path breaks.
class TrieNode {
children = new Map<string, TrieNode>(); // letter → the child node for that letter
isEnd = false; // true when a stored word ends exactly here
}
class Trie {
private root = new TrieNode(); // the empty prefix: every walk starts here
insert(word: string): void {
let node = this.root;
for (const ch of word) { // one step down per letter
let next = node.children.get(ch);
if (next === undefined) { // no path for this letter yet
next = new TrieNode(); // so build it
node.children.set(ch, next);
}
node = next; // step down
}
node.isEnd = true; // mark where the word finishes
}
search(word: string): boolean {
const node = this.walk(word);
return node !== null && node.isEnd; // the path exists AND a word ends here
}
startsWith(prefix: string): boolean {
return this.walk(prefix) !== null; // the path exists: some word continues from here
}
// Follow the letters of text from the root. Return the node reached, or null if a letter is missing.
private walk(text: string): TrieNode | null {
let node = this.root;
for (const ch of text) {
const next = node.children.get(ch);
if (next === undefined) return null; // the path breaks: nothing stored starts like this
node = next;
}
return node;
}
}
// const t = new Trie();
// t.insert("car"); t.insert("cat"); t.insert("do");
// t.search("ca") → false (a path, but no word ends there)
// t.startsWith("ca") → true
// t.search("cat") → true
Worked example
Counting prefixes. You get a list of words and a list of queries. For each query, return how many words start with it. With words = ["car", "cat", "cart", "dog"] and queries = ["ca", "car", "d", "x"], the answer is [3, 2, 1, 0].
The first idea: for each query, loop over every word and count the ones where
word.startsWith(query). It's correct, and with 4 words it's instant. With 20,000 words and 20,000 queries, it's 400 millionstartsWithcalls. That takes seconds.function countPrefixesSlow(words: string[], queries: string[]): number[] { return queries.map((q) => words.filter((w) => w.startsWith(q)).length); }Notice the waste. The queries "ca" and "car" both re-read "car", "cat" and "cart" from the first letter. Both also check "dog", even though its first letter already rules it out. The words that share a start get compared again and again.
In a trie, every word that starts with "ca" passes through the "ca" node on its way down. So let each node keep a counter of how many words passed through it during insert. Then the answer for a query is the counter on the node the query leads to. That is one walk per query, and no loop over the words.
Add
countto the node. Add 1 to it on every node the insert steps into. Then answer each query with a walk. A query whose path breaks has 0 words.class CountNode { children = new Map<string, CountNode>(); count = 0; // how many inserted words pass through this node } function countPrefixes(words: string[], queries: string[]): number[] { const root = new CountNode(); for (const word of words) { let node = root; for (const ch of word) { let next = node.children.get(ch); if (next === undefined) { next = new CountNode(); node.children.set(ch, next); } next.count++; // this word passes through next node = next; } } return queries.map((q) => { let node: CountNode | undefined = root; for (const ch of q) { node = node.children.get(ch); if (node === undefined) return 0; // the path breaks: no word starts like this } return node.count; }); } // countPrefixes(["car", "cat", "cart", "dog"], ["ca", "car", "d", "x"]) → [3, 2, 1, 0]Check it by hand. Inserting the four words leaves c with count 3, "ca" with 3, "car" with 2 (from "car" and "cart") and d with 1. The query "x" breaks at the first letter and returns 0. Note that
countsits on the child we step into, never on the root. The root would count every word, which is the answer for the empty query "".Edge cases: a word inserted twice counts twice, which is what the question asks for. If it should count once, keep a
Setof inserted words and skip a word that's already in it. A query longer than every word breaks partway down and returns 0.
Common mistakes
- Forgetting the end-of-word flag. Then
search("ca")returnstrueafter inserting only "car", because the path exists even though no word ends there. - Setting
isEndon every node you pass, or inside the loop instead of after it. Then every prefix of every word looks like a stored word. - Creating children in
searchorstartsWithby reusing the insert loop. Every failed lookup quietly adds nodes. LaterstartsWithcalls then returntruefor words never inserted. - Forgetting to step down (
node = next) after creating a child. Every letter gets attached to the root, so "car" is stored as three one-letter branches. Lookups of longer words then fail. - Checking
isEndinstartsWithtoo. It then answersfalsefor "ca", even though "car" and "cat" start with it. Onlysearchneeds the flag.
Cost
Every operation walks one node per letter of its input. So insert, search and startsWith each cost O(L), where L is the length of the word or prefix. That doesn't depend on how many words are stored. Building the trie from n words of average length L costs O(n · L) time. Memory is the downside. In the worst case every letter of every word is its own node, so the trie takes O(n · L) space. Each node also carries a whole Map. That can be several times the size of the words themselves. Shared prefixes are what bring it down.
In interviews
Say why a trie fits before you build one. For example: “I'll be asked about prefixes many times, so I'll pay O(total letters) once to build a trie, and then each query costs only its own length.” Write the node class first, then the walk. Point out the end-of-word flag out loud. Tries come up as “implement a trie” and as word search with wildcards. They also come up as autocomplete or search suggestions, and as finding many dictionary words in a grid. A common follow-up is “how much memory does this use?”.