Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Implement a Trie
The prefix tree behind autocomplete, spell-check and IP routing: a tree keyed by characters that answers "is this a word?" and "is this a prefix?" in time proportional to the word length. Replay insert/search/startsWith operations. Solve it in Python or TypeScript, with hidden tests.
The problem
Implement a trie (prefix tree) supporting insert(word), search(word) (is the exact word stored?), and startsWith(prefix) (is any stored word prefixed by this?). You are given a list of ops like ["insert","apple"], ["search","app"], ["startsWith","app"]. Return the list of boolean outputs from every search and startsWith, in order.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement trie_ops(ops): build a trie of nested nodes (one child per character) with an end-of-word marker. Collect the boolean result of each search/startsWith.
Examples and constraints 4 examples
ops = [["insert","apple"],["search","apple"],["search","app"],["startsWith","app"],["insert","app"],["search","app"]][True, False, True, True]ops = [["insert","hello"],["search","hell"],["startsWith","hell"]][False, True]ops = [["insert","app"],["search","apple"],["startsWith","apple"]][False, False]ops = [["search","a"],["startsWith","a"]][False, False]- Words and prefixes are lowercase letters.
- Each operation should run in O(length of the string), independent of how many words are stored.
searchrequires the full word to be marked as an end-of-word;startsWithonly requires the path to exist.
Hints 4 ideas
- A node is just a map from a character to a child node. The root is an empty node.
- To insert, walk/create a child per character, then mark the final node as end-of-word (e.g. a boolean flag or a sentinel key).
- search and startsWith both walk the characters; if a character is missing, the answer is False.
- The only difference: search additionally requires the final node to be an end-of-word; startsWith does not.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach stores the words in a set. search is a membership test, and startsWith loops over every stored word calling word.startswith(prefix). It is correct, and search is fast: O(L) on average to hash a string of length L. But each startsWith costs O(N·L) for N stored words, with O(C) space for C stored characters. That is exactly the wrong cost for autocomplete, which runs a prefix query on every keystroke against a large dictionary. Precomputing a set of every prefix makes startsWith O(L), but it costs O(L²) time and space per inserted word.
The approach
A trie stores each shared prefix once: a word is a path from the root, one node per character. The reference makes every node a plain dict, root = {}, with children keyed by character and the key "$" marking that the path to this node spells a complete word. insert walks with node = node.setdefault(ch, {}), creating only the missing nodes, then sets node["$"] = True.
search and startsWith share one walk: follow node[ch] per character and set ok = False at the first missing child. Only the final check differs. startsWith returns ok, since an existing path is enough; search returns ok and "$" in node, since "app" can be a path inside "apple" without being a word. The invariant: a node carries "$" exactly when the path to it was inserted as a whole word. Each operation takes one dict step per character, so its cost depends on the string’s length, not on how many words are stored.
Walk through an example
Replaying the first example shows "app" existing as a path long before it exists as a word.
| step | op | what the walk does | output |
|---|---|---|---|
| 1 | insert "apple" | creates 5 nodes a → p → p → l → e and marks e with "$" | none |
| 2 | search "apple" | walks all 5 characters and ends on a marked node | True |
| 3 | search "app" | walks 3 characters, but the second p has no "$" | False |
| 4 | startsWith "app" | walks 3 characters; the path is enough | True |
| 5 | insert "app" | creates 0 nodes and marks the second p with "$" | none |
| 6 | search "app" | walks 3 characters and now ends on a marked node | True |
Complexity
Each operation is O(L) time for a string of length L, whatever the number of stored words, so the replay is O(total characters in ops). Space is O(C) nodes for C inserted characters, fewer when words share prefixes. The set approach costs O(N·L) per startsWith for N stored words, with O(C) space.
Edge cases to test
- A prefix that is not a word: insert "hello", then
search("hell")isFalseandstartsWith("hell")isTrue. - A query longer than any stored word: insert "app", then
search("apple")andstartsWith("apple")are bothFalse, because the walk falls off at "l". - An empty trie:
[["search","a"],["startsWith","a"]]returns[False, False]. - The empty string:
startsWith("")isTrueeven on an empty trie, because the walk takes no steps, whilesearch("")staysFalseuntil""itself is inserted, which marks the root. - Nested words keep both markers: after inserting "car" and "card",
search("car")andsearch("card")are bothTrue. Inserting a word twice changes nothing. - The
"$"marker shares the key space with letters, which is safe only because words are lowercase letters: insert"a$"and the reference reportssearch("a")asTrue. A separate boolean field per node avoids the collision.
Common mistakes
- Not distinguishing “is a complete word” from “is a prefix” — that is exactly what the end-of-word flag is for.
- Conflating
search(needs the end flag) withstartsWith(path existence only). - Assuming a fixed 26-letter array when the alphabet is larger — a hash map of children is more general.
Follow-ups an interviewer asks next
- How do you return autocomplete suggestions for a prefix?
- Walk to the prefix node exactly as
startsWithdoes, then run a DFS below it, collecting every marked node along with the path that spells it. To return only the top k by popularity, store a count on each word’s node, or cache the best k completions at every node, trading memory for O(L) lookups. - How would you support delete?
- Walk to the word’s last node and remove its end marker. Then prune on the way back up: delete a child that has no marker and no children, and stop at the first node still in use. Without pruning the trie stays correct but keeps dead branches. A per-node count of words passing through makes each pruning check O(1).
- The trie uses too much memory. What are the options?
- A radix tree (compressed trie) merges each chain of single-child nodes into one edge labeled with a string, which removes most nodes in a sparse trie. A fixed 26-slot array per node is fast but wastes space on empty slots; a hash map per node is the reverse. For a static dictionary, a DAWG also shares common suffixes, not only prefixes.
- How would you count the stored words that start with a prefix?
- Keep a counter on every node and, when inserting a word that was not already present, increment it on each node along the path. The answer for a prefix is the counter on its last node: an O(L) walk instead of a DFS over the whole subtree.
Where this shows up
Tries power autocomplete and typeahead, spell-checkers, IP routing (longest-prefix match), and dictionary lookups — anywhere you query by prefix, which a hash map fundamentally cannot do because it scatters keys with no shared structure.
Reference solution
Reference solution — Python
def trie_ops(ops):
root = {} # node: char -> child dict; "$" marks a word end
out = []
for cmd, word in ops:
if cmd == "insert":
node = root
for ch in word:
node = node.setdefault(ch, {})
node["$"] = True # this path spells a complete word
elif cmd == "search":
node = root
ok = True
for ch in word:
if ch not in node:
ok = False
break
node = node[ch]
out.append(ok and "$" in node) # must end on a word
elif cmd == "startsWith":
node = root
ok = True
for ch in word:
if ch not in node:
ok = False
break
node = node[ch]
out.append(ok) # path exists is enough
return out
print(trie_ops([["insert","apple"],["search","apple"],["search","app"],
["startsWith","app"],["insert","app"],["search","app"]])) # -> [True, False, True, True]Reference solution — TypeScript
function trieOps(ops: string[][]): boolean[] {
const root: any = {}; // char -> child; "$" marks a word end
const out: boolean[] = [];
for (const [cmd, word] of ops) {
if (cmd === "insert") {
let node = root;
for (const ch of word) node = node[ch] || (node[ch] = {});
node["$"] = true; // complete word
} else if (cmd === "search") {
let node = root, ok = true;
for (const ch of word) {
if (!(ch in node)) { ok = false; break; }
node = node[ch];
}
out.push(ok && "$" in node); // must end on a word
} else if (cmd === "startsWith") {
let node = root, ok = true;
for (const ch of word) {
if (!(ch in node)) { ok = false; break; }
node = node[ch];
}
out.push(ok); // path exists is enough
}
}
return out;
}
console.log(trieOps([["insert","apple"],["search","apple"],["search","app"],
["startsWith","app"],["insert","app"],["search","app"]])); // -> [true,false,true,true]Questions about Implement a Trie
What is the difference between search and startsWith in a trie?
Both walk the trie one character at a time from the root and fail as soon as a character has no child. startsWith succeeds once the whole prefix has been walked, because every path in the trie is the start of at least one stored word. search needs one more check: the node it stops on must carry the end-of-word marker. In the first example, after inserting apple, the path a-p-p exists, so startsWith of app is True, but search of app stays False until app itself is inserted and its last node is marked.
Why use a trie instead of a hash set?
A hash set answers whether an exact word is stored in about O(L) time, but it cannot answer a prefix question without checking every stored word, because hashing scatters words that share a prefix. A trie stores each shared prefix once, so a prefix query costs O(L) whatever the dictionary size, and it supports autocomplete, counting words by prefix and longest-prefix matching. The price is memory: one node per distinct prefix, each with its own child map, usually far more than a set of strings. For exact lookups alone, a hash set is simpler and smaller.
What is the time complexity of a trie?
Insert, search and startsWith each take O(L) time for a string of length L, because they do one child lookup per character, and the number of stored words never enters the cost. Space is O(total characters inserted) in the worst case, one node per character, and less when words share prefixes. With a hash map per node each child lookup is O(1) on average; with a fixed array of 26 children it is O(1) in the worst case, at the price of 26 slots in every node whether they are used or not.
How is a trie node usually represented?
Either as a fixed array of child pointers, 26 slots for lowercase English, plus an end-of-word flag, or as a hash map from character to child. The array gives the fastest lookups and is common in interview code, but it wastes memory on sparse nodes and needs a small, known alphabet. The map handles any alphabet and stores only the children that exist. The reference uses a plain dictionary per node with the key $ as the end marker, which is compact but works only because $ can never be a character of a word here.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →