Tries
One-liner: store words letter by letter in a tree, so words that start the same share a path and any prefix question is answered by walking down from the root.
The analogy
Think of a phone's autocomplete. You type c, a, r, and it already knows car, card, care and cart without scanning every word in the dictionary. It is like a paper dictionary's thumb tabs: you jump to C, then CA, then CAR, and everything below that point shares the start. Each step narrows the choices, and the cost depends on the length of the word you type, not on how many words are stored.
Recognition signals
Reach for a trie when you see:
- Many strings stored, with questions about prefixes: starts with, autocomplete, longest common prefix.
- A dictionary of words checked many times, especially with wildcards like
.. - Replacing or grouping words by a shared beginning (roots, domains, paths).
- A hash set would answer "is this word present?" but cannot answer "does anything start with this?" quickly.
Step-by-step walkthrough
Insert car, card and cat:
- Start with an empty root node.
car: create a child forc, below ita, below thatr, and markras the end of a word.card: walkc,a,r(they already exist), addd, and markdas an end.cat: walkc,a, thenthas no edge, so create it and mark it as an end.StartsWith("ca")walks two steps and finds a node:true.Search("ca")also reaches that node, but it is not marked as an end, sofalse.
Code template
Each node holds its children and a flag for "a word ends here". Insert and lookup both walk one letter at a time.
// Trie stores words letter by letter, so words that share a beginning share a path.
type node struct {
next map[rune]*node
end bool // a word finishes exactly here
}
type Trie struct{ root *node }
func NewTrie() *Trie { return &Trie{root: &node{next: map[rune]*node{}}} }
func (t *Trie) Insert(word string) {
cur := t.root
for _, r := range word {
child, ok := cur.next[r]
if !ok { // 1. no edge for this letter yet: create it
child = &node{next: map[rune]*node{}}
cur.next[r] = child
}
cur = child // 2. walk down one letter
}
cur.end = true // 3. mark the word's last node
}
// walk follows prefix from the root and returns the node it ends on, or nil.
func (t *Trie) walk(prefix string) *node {
cur := t.root
for _, r := range prefix {
cur = cur.next[r]
if cur == nil {
return nil // 4. the path breaks: nothing stored starts with this prefix
}
}
return cur
}
// Search reports whether the exact word was inserted.
func (t *Trie) Search(word string) bool {
n := t.walk(word)
return n != nil && n.end
}
// StartsWith reports whether any inserted word begins with prefix.
func (t *Trie) StartsWith(prefix string) bool { return t.walk(prefix) != nil }Why each part exists:
if !ok { create child }Words that share a start reuse the same nodes. A new node is created only where the paths first differ, which is what saves space and time.
cur = childOne step down per letter. The work for a word is its length, whatever the dictionary size.
cur.end = trueThe flag separates "this prefix is a word" from "this prefix is only part of a longer word". Without it, app would count as a word after inserting only apple.
if cur == nil { return nil }A missing edge means nothing stored begins with this prefix, so you can stop at once.
The real solutions
Autocomplete — walk to the prefix node, then collect the words below it in alphabetical order:
// Autocomplete returns up to limit words that start with prefix, in alphabetical order.
// Walk to the prefix node, then collect words below it (depth-first, letters sorted).
func (t *Trie) Autocomplete(prefix string, limit int) []string {
start := t.walk(prefix)
if start == nil || limit <= 0 {
return nil
}
var out []string
var dfs func(n *node, path []rune)
dfs = func(n *node, path []rune) {
if len(out) >= limit {
return
}
if n.end {
out = append(out, string(path))
}
letters := make([]rune, 0, len(n.next))
for r := range n.next {
letters = append(letters, r)
}
sort.Slice(letters, func(i, j int) bool { return letters[i] < letters[j] })
for _, r := range letters {
dfs(n.next[r], append(path, r))
}
}
dfs(start, []rune(prefix))
return out
}Replace Words — for each word, the first complete root met on the way down is the shortest one:
// ReplaceWords: replace each word with the shortest root that is a prefix of it.
func ReplaceWords(roots []string, sentence string) string {
t := NewTrie()
for _, r := range roots {
t.Insert(r)
}
shortest := func(word string) string {
cur := t.root
for i, r := range word {
cur = cur.next[r]
if cur == nil {
break
}
if cur.end {
return word[:i+len(string(r))] // the first complete root on the path is the shortest
}
}
return word
}
out, start := "", 0
for i := 0; i <= len(sentence); i++ {
if i == len(sentence) || sentence[i] == ' ' {
out += shortest(sentence[start:i])
if i < len(sentence) {
out += " "
}
start = i + 1
}
}
return out
}Wildcard search — a . means "try every child". Everything else follows a single edge:
// WordDictionary: Search treats '.' as "any single letter".
type WordDictionary struct{ t *Trie }
func NewWordDictionary() *WordDictionary { return &WordDictionary{t: NewTrie()} }
func (w *WordDictionary) AddWord(word string) { w.t.Insert(word) }
func (w *WordDictionary) Search(word string) bool {
var match func(n *node, rest []rune) bool
match = func(n *node, rest []rune) bool {
if len(rest) == 0 {
return n.end
}
if rest[0] == '.' { // wildcard: try every child
for _, child := range n.next {
if match(child, rest[1:]) {
return true
}
}
return false
}
child := n.next[rest[0]]
return child != nil && match(child, rest[1:])
}
return match(w.t.root, []rune(word))
}Autocomplete also shows up in system design. The system design guides show where this data lives in a real service.
Complexity
| Approach | Time | Space |
|---|---|---|
| Scan a list of words for a prefix | O(N · L) per query | O(1) |
| Trie insert / search / prefix | O(L) | O(total letters stored) |
| Wildcard search | O(26^dots · L) worst case | O(L) recursion |
L is the word length and N the number of words. The trie's memory grows with the letters stored; a map per node is flexible, and a [26]*node array is faster for lowercase-only input.
Common mistakes
Practice ladder
Ordered Easy → Hard. For each, ask: is the question about prefixes?
- 1.Longest Common PrefixEasyA trie works, but what is the simplest approach?
- 2.Implement Trie (Prefix Tree)Medium
- 3.Replace WordsMedium
- 4.Design Add and Search Words Data StructureMediumWhat does a dot do to the walk?
- 5.Search Suggestions SystemMedium
- 6.Map Sum PairsMedium
- 7.Word Search IIHardCombine a trie with backtracking on a grid.
Which pattern? Drills
Unlabeled problems — pick the pattern, then read why.
Build a search box that, as the user types, shows up to five stored words that begin with what they have typed so far.
Which pattern?