Implement Trie (Prefix Tree)
A trie stores words letter by letter on shared paths, so every operation walks one path. Solved two ways, a plain list of words in O(N × L) per question and a trie in O(L) per operation, with a step-through, code in five languages and the Big O worked out from the code.
The problem
Section titled “The problem”Build a class Trie that stores words and answers questions about them:
insert(word)storesword.search(word)returnstrueif exactlywordwas stored before.startsWith(prefix)returnstrueif any stored word begins withprefix.
A trie, also called a prefix tree, is the usual way to do this. Each example below is a list of calls
on one new Trie, with what each call returns (null for insert).
Example 1
Input: insert("cat"), insert("car"), search("cat"), search("ca"), startsWith("ca"), insert("ca"), search("ca")
Output: null, null, true, false, true, null, true
“ca” is not a word until it is inserted, but it is a prefix of “cat” and “car” from the start. The picture shows the trie at the end.
Example 2
Input: insert("dog"), search("do"), startsWith("dog")
Output: null, false, true
“do” was never inserted. A word counts as a prefix of itself, so startsWith(“dog”) is true.
Example 3
Input: startsWith("a"), insert("a"), search("a")
Output: false, null, true
An empty trie has no prefixes. After the insert, the one-letter word is found.
Constraints
Section titled “Constraints”- 1 ≤ word.length, prefix.length ≤ 2,000
- Words and prefixes use only the lowercase letters a to z.
- At most 30,000 calls in total.
Clarifying questions
Section titled “Clarifying questions”- Which letters can appear?
- Only a to z. So each node needs at most 26 children, and an array of 26 works.
- Is a word a prefix of itself?
- Yes. startsWith("dog") is true after insert("dog").
- Can the same word be inserted twice?
- Yes. It changes nothing the second time.
- Is there a delete?
- No. Only insert and the two questions.
Spot the pattern
Section titled “Spot the pattern”The questions are about starts of words. Words that start the same way can share the work of storing that start. A trie does exactly that: a tree where each edge is one letter, and each path from the root spells a prefix. Answering a question is a walk down one path, a small tree traversal.
Solutions
Section titled “Solutions”This problem has two approaches. A hash set speeds up search but not startsWith, so it is no real middle step.
Brute force O(N × L)
The idea
Section titled “The idea”Keep every inserted word in a plain list. To search, compare the word with each stored word. To answer
startsWith, check whether any stored word begins with the prefix.
Inserting is cheap. But each question may look at every stored word, and each comparison may look at every letter. The list knows nothing about which words share a start.
Walkthrough
Section titled “Walkthrough”- words = []
- def insert(word): words.append(word)
- def search(word):
- for stored_word in words:
- if stored_word == word: return True
- return False
- def startsWith(prefix):
- for stored_word in words:
- if stored_word.startswith(prefix): return True
- return False
The brute force keeps every word in a plain list. Run the operations of Example 1 one by one.
// Brute force: keep every word in a list and scan it on each question.// With N words of length up to L, a search costs about N * L steps.class Trie { private final List<String> words = new ArrayList<>();
public void insert(String word) { words.add(word); }
public boolean search(String word) { // Nothing is sorted or grouped, so every word must be compared. for (String storedWord : words) if (storedWord.equals(word)) return true; return false; }
public boolean startsWith(String prefix) { for (String storedWord : words) if (storedWord.startsWith(prefix)) return true; return false; }}# Brute force: keep every word in a list and scan it on each question.# With N words of length up to L, a search costs about N * L steps.class Trie: def __init__(self): self.words = []
def insert(self, word: str) -> None: self.words.append(word)
def search(self, word: str) -> bool: # Nothing is sorted or grouped, so every word must be compared. return any(stored_word == word for stored_word in self.words)
def startsWith(self, prefix: str) -> bool: return any(stored_word.startswith(prefix) for stored_word in self.words)// Brute force: keep every word in a list and scan it on each question.// With N words of length up to L, a search costs about N * L steps.public class Trie{ private readonly List<string> words = new List<string>();
public void Insert(string word) { words.Add(word); }
public bool Search(string word) { // Nothing is sorted or grouped, so every word must be compared. foreach (string storedWord in words) if (storedWord == word) return true; return false; }
public bool StartsWith(string prefix) { foreach (string storedWord in words) if (storedWord.StartsWith(prefix, System.StringComparison.Ordinal)) return true; return false; }}// Brute force: keep every word in a list and scan it on each question.// With N words of length up to L, a search costs about N * L steps.type Trie struct { words []string}
func Constructor() Trie { return Trie{} }
func (trie *Trie) Insert(word string) { trie.words = append(trie.words, word)}
func (trie *Trie) Search(word string) bool { // Nothing is sorted or grouped, so every word must be compared. for _, storedWord := range trie.words { if storedWord == word { return true } } return false}
func (trie *Trie) StartsWith(prefix string) bool { for _, storedWord := range trie.words { if strings.HasPrefix(storedWord, prefix) { return true } } return false}// Brute force: keep every word in a list and scan it on each question.// With N words of length up to L, a search costs about N * L steps.class Trie { words: string[] = [];
insert(word: string): void { this.words.push(word); }
search(word: string): boolean { // Nothing is sorted or grouped, so every word must be compared. return this.words.some((storedWord) => storedWord === word); }
startsWith(prefix: string): boolean { return this.words.some((storedWord) => storedWord.startsWith(prefix)); }}1 Find the work that repeats
The lit lines compare one stored word with the question. They sit in a loop over the list. One compare reads up to L letters, where L is the length of the longest word, so it costs up to L steps. Let N be the number of stored words.
- words = []
- def insert(word): words.append(word)
- def search(word):
- for stored_word in words:
- if stored_word == word: return True
- return False
- def startsWith(prefix):
- for stored_word in words:
- if stored_word.startswith(prefix): return True
- return False
2 Count the runs on Example 1
The table counts the word compares on Example 1. The questions that find nothing, or find the word last, compare the most.
search("ca")at the end compares all 3 stored words.1 + 2 + 1 + 3 = 7 word compares.
operation insert("cat") insert("car") search("cat") search("ca") startsWith("ca") insert("ca") search("ca") words in list 0 1 2 2 2 2 3 words compared - - 1 2 1 - 3 total1 + 2 + 1 + 3 = 7 word compares3 Write the count for any n
A question that finds nothing compares all N words, and each compare can read up to L letters. So one question costs up to:
N × L letter checksCheck it with the last call of Example 1: N = 3, L = 3, so the bound is 9. The real compares read “cat”, “car” and “ca” for 2 + 2 + 2 = 6 letters, because each stops at the shorter word.
4 Keep only the part that grows fastest
There is nothing to drop: N × L has no smaller terms. Inserting costs 1 step, which is tiny next to a question. So one question is O(N × L), and it gets slower with every word inserted.
So the time is O(N × L).
S Space: count the extra memory the same way
The list holds every word, so it holds up to N × L letters. Nothing else grows. The space is O(N × L).
words (all letters of all words)
Trie O(L)
The idea
Section titled “The idea”Store the words in a trie. Each node has up to 26 children, one per letter, and a flag that says “a word ends here”. The root stands for the empty start.
insert(word): walk down from the root, one letter at a time. When an edge is missing, add a new node. At the last letter, set the flag.find(prefix): walk down the same way. If an edge is missing, no stored word starts like this, so return None. Otherwise return the node at the end of the prefix.search(word): the walk must succeed and end on a flagged node.startsWith(prefix): the walk only has to succeed.
Words with the same start share one path. So the walk depends only on the length of the word, not on how many words are stored.
Why it is correct: a path from the root exists exactly when some inserted word starts with those letters, and its last node is flagged exactly when one inserted word ends there.
Walkthrough
Section titled “Walkthrough”- def insert(word):
- node = root
- for letter in word:
- if letter not in node.children:
- node.children[letter] = TrieNode()
- node = node.children[letter]
- node.is_word = True
- def find(prefix):
- node = root
- for letter in prefix:
- if letter not in node.children: return None
- node = node.children[letter]
- return node
- def search(word): return find(word) is not None and find(word).is_word
- def startsWith(prefix): return find(prefix) is not None
A trie starts as one empty root. Each edge below it will stand for one letter, and shared starts will share one path.
// Trie: words that share a start share one path of nodes, one letter// per edge. Every operation walks one path, so L steps for length L.class Trie { private static class Node { Node[] children = new Node[26]; // Marks the end of a whole word, not just a prefix. boolean isWord; }
private final Node root = new Node();
public void insert(String word) { Node node = root; for (char letter : word.toCharArray()) { // Reuse the shared start. Add nodes only where it ends. if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node(); node = node.children[letter - 'a']; } node.isWord = true; }
public boolean search(String word) { Node node = find(word); // Reaching the end is not enough: a word must end exactly here. return node != null && node.isWord; }
public boolean startsWith(String prefix) { return find(prefix) != null; }
private Node find(String prefix) { // Follow one letter at a time. A missing edge means no stored // word starts this way. Node node = root; for (char letter : prefix.toCharArray()) { node = node.children[letter - 'a']; if (node == null) return null; } return node; }}# Trie: words that share a start share one path of nodes, one letter# per edge. Every operation walks one path, so L steps for length L.class TrieNode: def __init__(self): self.children = {} # Marks the end of a whole word, not just a prefix. self.is_word = False
class Trie: def __init__(self): self.root = TrieNode()
def insert(self, word: str) -> None: node = self.root for letter in word: # Reuse the shared start. Add nodes only where it ends. if letter not in node.children: node.children[letter] = TrieNode() node = node.children[letter] node.is_word = True
def search(self, word: str) -> bool: node = self._find(word) # Reaching the end is not enough: a word must end exactly here. return node is not None and node.is_word
def startsWith(self, prefix: str) -> bool: return self._find(prefix) is not None
def _find(self, prefix: str) -> TrieNode | None: # Follow one letter at a time. A missing edge means no stored # word starts this way. node = self.root for letter in prefix: if letter not in node.children: return None node = node.children[letter] return node// Trie: words that share a start share one path of nodes, one letter// per edge. Every operation walks one path, so L steps for length L.public class Trie{ private class Node { public Node[] children = new Node[26]; // Marks the end of a whole word, not just a prefix. public bool isWord; }
private readonly Node root = new Node();
public void Insert(string word) { Node node = root; foreach (char letter in word) { // Reuse the shared start. Add nodes only where it ends. if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node(); node = node.children[letter - 'a']; } node.isWord = true; }
public bool Search(string word) { Node node = Find(word); // Reaching the end is not enough: a word must end exactly here. return node != null && node.isWord; }
public bool StartsWith(string prefix) { return Find(prefix) != null; }
private Node Find(string prefix) { // Follow one letter at a time. A missing edge means no stored // word starts this way. Node node = root; foreach (char letter in prefix) { node = node.children[letter - 'a']; if (node == null) return null; } return node; }}// Trie: words that share a start share one path of nodes, one letter// per edge. Every operation walks one path, so L steps for length L.type trieNode struct { children [26]*trieNode // Marks the end of a whole word, not just a prefix. isWord bool}
type Trie struct { root *trieNode}
func Constructor() Trie { return Trie{root: &trieNode{}} }
func (trie *Trie) Insert(word string) { node := trie.root for _, letter := range word { // Reuse the shared start. Add nodes only where it ends. if node.children[letter-'a'] == nil { node.children[letter-'a'] = &trieNode{} } node = node.children[letter-'a'] } node.isWord = true}
func (trie *Trie) Search(word string) bool { node := trie.find(word) // Reaching the end is not enough: a word must end exactly here. return node != nil && node.isWord}
func (trie *Trie) StartsWith(prefix string) bool { return trie.find(prefix) != nil}
func (trie *Trie) find(prefix string) *trieNode { // Follow one letter at a time. A missing edge means no stored // word starts this way. node := trie.root for _, letter := range prefix { node = node.children[letter-'a'] if node == nil { return nil } } return node}// Trie: words that share a start share one path of nodes, one letter// per edge. Every operation walks one path, so L steps for length L.class TrieNode { children = new Map<string, TrieNode>(); // Marks the end of a whole word, not just a prefix. isWord = false;}
class Trie { root = new TrieNode();
insert(word: string): void { let node = this.root; for (const letter of word) { // Reuse the shared start. Add nodes only where it ends. if (!node.children.has(letter)) node.children.set(letter, new TrieNode()); node = node.children.get(letter)!; } node.isWord = true; }
search(word: string): boolean { const node = this.find(word); // Reaching the end is not enough: a word must end exactly here. return node !== null && node.isWord; }
startsWith(prefix: string): boolean { return this.find(prefix) !== null; }
private find(prefix: string): TrieNode | null { // Follow one letter at a time. A missing edge means no stored // word starts this way. let node = this.root; for (const letter of prefix) { const child = node.children.get(letter); if (!child) return null; node = child; } return node; }}1 Find the work that repeats
The lit lines take one step down the trie: one lookup, maybe one new node, one move. That is a fixed job, so each letter counts as 1 step. Each operation runs this once per letter of its word.
- def insert(word):
- node = root
- for letter in word:
- if letter not in node.children:
- node.children[letter] = TrieNode()
- node = node.children[letter]
- node.is_word = True
- def find(prefix):
- node = root
- for letter in prefix:
- if letter not in node.children: return None
- node = node.children[letter]
- return node
- def search(word): return find(word) is not None and find(word).is_word
- def startsWith(prefix): return find(prefix) is not None
2 Count the runs on Example 1
The table lists the letters each call walks on Example 1. Every call walks exactly the letters of its own word: 3 for “cat” and “car”, 2 for “ca”.
3 + 3 + 3 + 2 + 2 + 2 + 2 = 17 steps.
operation insert("cat") insert("car") search("cat") search("ca") startsWith("ca") insert("ca") search("ca") total letters walked 3 3 3 2 2 2 2 17 3 Write the count for any n
An operation on a word of length L walks at most L edges, one per letter. A missing edge only makes
findstop sooner:at most L steps per operationCheck it with Example 1: each call took as many steps as its word has letters.
4 Keep only the part that grows fastest
There is nothing to drop: the count is already L. It does not depend on N, the number of words stored. A trie with a million words answers
search("cat")in 3 steps.So the time is O(L).
S Space: count the extra memory the same way
Each
insertadds at most one node per letter, and only where no shared path exists yet. So the trie holds at most N × L nodes, and fewer when words share starts. Example 1 stores 3 words with 8 letters in 4 nodes plus the root. The space is O(N × L) in the worst case.one node per new letternode
Tested at the limits
Section titled “Tested at the limits”2,000 random call sequences of 1 to 15 calls, on words of 1 to 4 letters made of a and b, so prefixes, repeats and near misses come up all the time. The brute force and the trie gave the same answers every time.
| Largest inputs | Result |
|---|---|
| 30,000 calls (the most allowed) on 2,000-letter words that share their first 1,990 letters: 10,000 inserts, then 20,000 searches and prefix checks. Trie. | 2.3 s for about 60 million letter steps, and about 20 MB of extra memory: about 100,000 trie nodes, which is the O(N × L) space. |
| 2,000 calls of the same kind. Brute force. | 0.04 s. At 30,000 calls it would compare each of 20,000 questions with up to 10,000 stored words. |
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.