Tries
Quick reference
| insert(word) | O(L) |
| search(word) | O(L) |
| startsWith(prefix) | O(L) |
| Total space, all inserted words | โ |
Picture a dictionary organized letter by letter instead of alphabetically on a page. Every word starting with "ca" shares the same first two branches โ c, then a โ and only splits apart where the words actually differ: "cat" and "car" share the c-a path and diverge at the third letter. A trie (short for reTRIEval, usually said "try") is exactly that shape, built for strings: every path from the root spells out a prefix, and any two words sharing a prefix share the same path for as long as they agree.
That shared-path structure is the whole point. Asking "does any stored word start with 'ca'" doesn't require checking every stored word one at a time โ it's just: 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.
The node
structure TrieNode:
// one entry per character actually 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 prefixinsert โ 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 itsearch โ 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 // path doesn't exist at all โ word was never inserted
node = node.children[ch]
return node.isEndOfWord // path exists, but only a real match if a word ends HEREstartsWith โ 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 // just reaching the end of the path is the whole answerL is the length of the word or prefix being inserted or checked โ not the number of words already stored. insert, search, and startsWith all cost O(L) regardless of whether the trie holds ten words or ten million, because each one is just a walk down a single path, one character per step. A hash set gives O(L) average for exact search too โ hashing a string still costs one pass over its characters โ but a hash set cannot answer startsWith at all without scanning every stored string to check which ones happen to begin with the prefix. That's the trie's real advantage: it isn't faster at the thing a hash set already does, it does a thing a hash set structurally can't.
| Operation | Time | Space | Why |
|---|---|---|---|
| insert(word) | O(L) | O(L) worst case | Walks or creates exactly one node per character of the word; a brand-new word with no shared prefix creates L new nodes. |
| search(word) | O(L) | O(1) | One walk down the path spelled out by word, then a single isEndOfWord check. |
| startsWith(prefix) | O(L) | O(1) | Identical walk to search โ the only difference is which question gets asked once the walk ends. |
| Total space, all inserted words | โ | O(total characters) | Worst case (no shared prefixes at all) is one node per character across every word. Real text shares prefixes constantly, so actual usage runs well under that ceiling. |
search vs. startsWith โ the classic mix-up
The path for "car" existing in a trie that has "card" stored does NOT mean "car" itself was ever inserted as a word โ walking the path only proves "car" is a valid prefix of something. search("car") must fail here unless "car" was separately inserted and its isEndOfWord flag set. This single distinction โ path exists vs. path ends in a real word โ is the one to get exactly right; almost every trie bug traces back to blurring it.
Forgetting to set isEndOfWord
Every character of the word gets a node, walking or creating the path correctly โ and then search() on that exact word still returns false, because nothing ever marked the last node as a real word ending. The path being fully built is necessary but not sufficient; the flag on the final node is what search actually reads.
Empty string as input
The loop body never runs for an empty string, so node stays at trie.root the entire 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 will mishandle this without a crash โ it'll just silently give the wrong answer for the one input that never enters the loop.
Character-set assumptions baked into the structure
A 26-slot array is faster and uses less memory than a map when the input really is guaranteed lowercase-only โ but the moment uppercase letters, digits, spaces, or Unicode characters show up, indexing 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.
Sign in to mark problems done โ progress syncs across devices.