Tries
Loading…
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.
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.
Reach for a trie when you see:
..Insert car, card and cat:
car: create a child for c, below it a, below that r, and mark r as the end of a word.card: walk c, a, r (they already exist), add d, and mark d as an end.cat: walk c, a, then t has 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, so false.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.
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 (LeetCode 648): 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 (LeetCode 211): 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.
| 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.
Ordered Easy → Hard. For each, ask: is the question about prefixes?
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?