Builds on Hash Map, which you haven’t finished yet.
Trie (Prefix Tree)
A trie ("try") or prefix tree is a tree structure purpose-built for storing and looking up a set of strings efficiently, and it underlies things like autocomplete and spellcheckers.
Implement a Trie class:
Trie()starts with an empty trie.void insert(String word)addswordto the trie.boolean search(String word)returnstrueonly ifworditself was previously inserted.boolean startsWith(String prefix)returnstrueif any inserted word begins withprefix.
"apple", search("apple") is true, search("app") is false (only a prefix so far), and startsWith("app") is true. After inserting "app", search("app") is true.Trie
strings / dictionary ➔ 26-ary tree of characters ➔ O(L) prefix search| 1 | from typing import Optional |
| 2 | |
| 3 | class TrieNode: |
| 4 | def __init__(self) -> None: |
| 5 | self.children: dict[str, "TrieNode"] = {} # One edge per next character |
| 6 | self.is_end = False # A whole word ends here |
| 7 | |
| 8 | class Trie: |
| 9 | """Implement Trie (Prefix Tree) with a shared _walk traversal helper. |
| 10 | Time: O(L) per operation, L = word length | Space: O(total characters) |
| 11 | """ |
| 12 | def __init__(self) -> None: |
| 13 | self.root = TrieNode() |
| 14 | |
| 15 | def _walk(self, s: str) -> Optional[TrieNode]: |
| 16 | node = self.root |
| 17 | |
| 18 | # 1. Follow One Edge Per Character: |
| 19 | # Moving to the matching child reconstructs the path s spells out one letter at a time. |
| 20 | # If a child is missing, no inserted word ever created this prefix, so the walk stops early. |
| 21 | for ch in s: |
| 22 | if ch not in node.children: |
| 23 | return None # Path breaks: no word has this prefix |
| 24 | node = node.children[ch] |
| 25 | return node |
| 26 | |
| 27 | def insert(self, word: str) -> None: |
| 28 | node = self.root |
| 29 | |
| 30 | # 1. Create Or Reuse One Edge Per Character: |
| 31 | # setdefault reuses the child already built by an earlier word that shares this prefix, |
| 32 | # and creates a new child only the first time a path through this character is needed. |
| 33 | for ch in word: |
| 34 | node = node.children.setdefault(ch, TrieNode()) # Create missing edges |
| 35 | node.is_end = True # Mark the end, or search() can't tell "app" from "apple" |
| 36 | |
| 37 | def search(self, word: str) -> bool: |
| 38 | node = self._walk(word) |
| 39 | return node is not None and node.is_end # Path exists AND a word ends here |
| 40 | |
| 41 | def startsWith(self, prefix: str) -> bool: |
| 42 | return self._walk(prefix) is not None # Any path is enough for a prefix |
root whose last node has is_end = True, and every node on any path is a prefix of some inserted word.Words that share a prefix share one path from root, so any prefix is found by walking one child per letter. insert creates missing children with setdefault and sets is_end on the last node; search and startsWith share _walk. This is correct because a path exists exactly when some inserted word has that prefix, and is_end marks exactly the nodes where a whole word stopped. The trap is skipping is_end in search: after inserting "apple", search("app") would return true.
See it run
Trie: insert, search and startsWith, ops = insert("apple"), insert("apt"), search("app"), startsWith("app"), insert("app"), search("app")
call none yet
Each circle is a node, and the letter inside is the edge that leads to it from its parent. Words that share a prefix share nodes.
herethe node the walk is atnew nodea node that insert just createdpatha node or link the walk already followedis_enda node where a whole word endshere+is_endthe walk is at a node where a word ends
The trie holds only root. root has no children and is_end is False.
Gotcha 1 of 5
- The trap
Gotcha 1: Returning
node is not NoneinsearchMistakereturn node is not NoneFixreturn node is not None and node.is_endWhat happens: Reaching a node only proves that some inserted word starts with
word. Afterinsert("apple"), the path for "app" exists. Onlynode.is_endsays that a whole word stopped at that node.Example: insert("apple"), then search("app")Mistake: returns TrueFix: returns False
- Only whole keys are ever compared, never prefixes, as in Group Anagrams (LC 49). See Hash Map
Deep Dive
Why shared prefixes share nodes
A trie stores strings as paths. Each node stands for one prefix. Each edge is one character, kept in children. The path from root to a node spells the prefix.
insert follows the word one character at a time. For each character, setdefault returns the child if it exists, and creates it if it does not. So a second word that starts the same way reuses the first word's nodes.
Example: insert("apple") creates 5 nodes, for the prefixes a, ap, app, appl and apple. Then insert("apt") reuses the nodes for a and ap, and creates only one node, for apt. The trie now has 6 character nodes for 8 letters.
After the final insert("app"), the trie still has 6 character nodes, because every prefix of "app" was on the path already. Only is_end changes, on the node for "app".
This is why the card's invariant says that every node on any path is a prefix of some inserted word. A node is created only while some word is being inserted along that path.
What is_end adds
The path tells you that some word starts with the string. It does not tell you that the string is itself a word. is_end carries that second fact.
In the example, the trie holds "apple" and "apt", and then search("app") runs. _walk reaches the node for "app", because both words pass through it. The node exists, but is_end is False there. So search returns False. startsWith("app") returns True for the same node, because it needs only the node.
After insert("app"), the node for "app" has is_end = True, and search("app") returns True.
The two methods differ by one condition. search returns node is not None and node.is_end. startsWith returns node is not None. Writing the first without and node.is_end is the card's main trap.
The cost depends on the word, not the dictionary
Each of insert, search and startsWith visits one node per character. That is O(L) time, where L is the length of the word or prefix. The number of stored words does not appear in the cost.
Compare this with a set of words. A set finds a whole word in O(L) on average, because it hashes the string. But it cannot answer startsWith. That question needs a scan over every stored word.
The space is O(total characters) in the worst case, which is when no two words share a prefix. Shared prefixes lower it. In the example, 5 + 3 + 3 = 11 letters were inserted, and the trie holds 6 character nodes.
The card stores children in a dictionary. Each node then keeps only the characters it needs. An array of 26 slots per node is faster to index, but it uses 26 slots even for a node with one child.
Where to use the same walk
The same children and is_end fields serve many problems. Design Add and Search Words Data Structure (LC 211) lets . match any letter, so the search tries every child at that character. Word Search II (LC 212) walks a trie of all target words while a DFS moves on the board, and it stops a path when the trie has no matching child.
Replace Words (LC 648) walks each word and stops at the first node with is_end = True. Search Suggestions System (LC 1268) walks to the node for the typed prefix, and then collects words below that node.
In each case, the move is the same: a missing child means that no stored word has this prefix, so the search can stop early.
60-Second Pitch
"I store the dictionary as a tree of characters. Each edge is a letter, and each node is a prefix of some word. insert walks the word and creates missing children with setdefault, then sets is_end on the last node. Words that share a prefix share nodes. _walk follows one child per character and returns None when a child is missing. startsWith needs only that node, and search also needs is_end. Each operation costs O(L) for a word of length L. The space is O(total characters) in the worst case."
Common Questions
- What is the time and space complexity of the trie?
Each operation is O(L) time, where L is the length of the string. The space is O(total characters) in the worst case, which is when no two words share a prefix. The number of words does not change the time of one operation.
- Why use a trie when a hash set stores words?
A set answers whole-word lookups well, but
startsWithneeds a scan over every word. The trie answers a prefix question by walking one path of L nodes. The trie is also the base for autocomplete and for board searches that stop early.- What are the options for
children, and how do they compare? A dictionary stores only the characters that exist, so a sparse node is small. An array of 26 slots indexes faster, but each node holds 26 slots, even with one child. The dictionary is a safe default, and it also works for non-lowercase alphabets.
- How would you list all words that start with a prefix?
Use
_walk(prefix)to reach the node. If it isNone, return an empty list. Otherwise run a DFS from that node, and collect the path wheneveris_endisTrue. The cost is O(L) for the walk plus the size of the subtree below the node.- How would you delete a word?
Walk to the last node and set
is_end = False. This is enough for correct answers. To free memory, also remove the nodes on the path that have no children and nois_end, going from the bottom up. Stop at the first node that another word still needs.
SWE Reasoning
Spot it
- Many words or prefixes are checked against one dictionary.
- The question is about a prefix: autocomplete, "does any word start with this", word search on a board.
- A
setof words answers whole-word lookups, but you also need prefix lookups. - Characters arrive one at a time, and you want to stop as soon as no word can match.
- A wildcard such as
.replaces one letter.
The invariant, step by step
Trie()createsrootwith emptychildrenandis_end = False.insert(word)starts atroot. For eachch,children.setdefault(ch, TrieNode())returns the existing child or creates a new one. Thennodemoves to that child.- After the last character,
node.is_end = True. The path fromrootnow spellsword, and its last node marks a whole word. _walk(s)starts atrootand followschildren[ch]for each character. Ifchis not innode.children, it returnsNone.- A path for
sexists exactly when some inserted word starts withs, because nodes are created only while inserting. search(word)also requiresnode.is_end, which is set only where a whole word stopped.startsWith(prefix)needs only the path.
Traps
- Skipping
is_endinsearch. Afterinsert("apple"),search("app")returnsTrue, though "app" was never inserted. - Creating nodes in
_walk. Withsetdefaultin_walk, a read adds nodes. Aftersearch("cat")on an empty trie,startsWith("ca")returnsTrue. - Not marking the end. If
insertnever setsis_end,searchreturnsFalsefor every word. - Setting
is_endbefore the loop.node.is_end = Trueplaced before theforloop marksroot. Thensearch("")returnsTrue, and no real word is marked. - Sharing one
TrieNodebetween children. A table built as[TrieNode()] * 26puts one node object in all 26 slots, so every letter leads to the same node. Create a newTrieNode()for each child. - Empty string.
_walk("")returnsroot, sostartsWith("")isTrueandsearch("")isFalseunless the empty word was inserted. LeetCode limits words to length 1 or more.
Practice
Builds on Hash Map, which you haven’t finished yet.
Trie (Prefix Tree)
A trie ("try") or prefix tree is a tree structure purpose-built for storing and looking up a set of strings efficiently, and it underlies things like autocomplete and spellcheckers.
Implement a Trie class:
Trie()starts with an empty trie.void insert(String word)addswordto the trie.boolean search(String word)returnstrueonly ifworditself was previously inserted.boolean startsWith(String prefix)returnstrueif any inserted word begins withprefix.
"apple", search("apple") is true, search("app") is false (only a prefix so far), and startsWith("app") is true. After inserting "app", search("app") is true.Trie
strings / dictionary ➔ 26-ary tree of characters ➔ O(L) prefix search| 1 | from typing import Optional |
| 2 | |
| 3 | class TrieNode: |
| 4 | def __init__(self) -> None: |
| 5 | self.children: dict[str, "TrieNode"] = {} # One edge per next character |
| 6 | self.is_end = False # A whole word ends here |
| 7 | |
| 8 | class Trie: |
| 9 | """Implement Trie (Prefix Tree) with a shared _walk traversal helper. |
| 10 | Time: O(L) per operation, L = word length | Space: O(total characters) |
| 11 | """ |
| 12 | def __init__(self) -> None: |
| 13 | self.root = TrieNode() |
| 14 | |
| 15 | def _walk(self, s: str) -> Optional[TrieNode]: |
| 16 | node = self.root |
| 17 | |
| 18 | # 1. Follow One Edge Per Character: |
| 19 | # Moving to the matching child reconstructs the path s spells out one letter at a time. |
| 20 | # If a child is missing, no inserted word ever created this prefix, so the walk stops early. |
| 21 | for ch in s: |
| 22 | if ch not in node.children: |
| 23 | return None # Path breaks: no word has this prefix |
| 24 | node = node.children[ch] |
| 25 | return node |
| 26 | |
| 27 | def insert(self, word: str) -> None: |
| 28 | node = self.root |
| 29 | |
| 30 | # 1. Create Or Reuse One Edge Per Character: |
| 31 | # setdefault reuses the child already built by an earlier word that shares this prefix, |
| 32 | # and creates a new child only the first time a path through this character is needed. |
| 33 | for ch in word: |
| 34 | node = node.children.setdefault(ch, TrieNode()) # Create missing edges |
| 35 | node.is_end = True # Mark the end, or search() can't tell "app" from "apple" |
| 36 | |
| 37 | def search(self, word: str) -> bool: |
| 38 | node = self._walk(word) |
| 39 | return node is not None and node.is_end # Path exists AND a word ends here |
| 40 | |
| 41 | def startsWith(self, prefix: str) -> bool: |
| 42 | return self._walk(prefix) is not None # Any path is enough for a prefix |
root whose last node has is_end = True, and every node on any path is a prefix of some inserted word.Words that share a prefix share one path from root, so any prefix is found by walking one child per letter. insert creates missing children with setdefault and sets is_end on the last node; search and startsWith share _walk. This is correct because a path exists exactly when some inserted word has that prefix, and is_end marks exactly the nodes where a whole word stopped. The trap is skipping is_end in search: after inserting "apple", search("app") would return true.
See it run
Trie: insert, search and startsWith, ops = insert("apple"), insert("apt"), search("app"), startsWith("app"), insert("app"), search("app")
call none yet
Each circle is a node, and the letter inside is the edge that leads to it from its parent. Words that share a prefix share nodes.
herethe node the walk is atnew nodea node that insert just createdpatha node or link the walk already followedis_enda node where a whole word endshere+is_endthe walk is at a node where a word ends
The trie holds only root. root has no children and is_end is False.
Gotcha 1 of 5
- The trap
Gotcha 1: Returning
node is not NoneinsearchMistakereturn node is not NoneFixreturn node is not None and node.is_endWhat happens: Reaching a node only proves that some inserted word starts with
word. Afterinsert("apple"), the path for "app" exists. Onlynode.is_endsays that a whole word stopped at that node.Example: insert("apple"), then search("app")Mistake: returns TrueFix: returns False
- Only whole keys are ever compared, never prefixes, as in Group Anagrams (LC 49). See Hash Map
Deep Dive
Why shared prefixes share nodes
A trie stores strings as paths. Each node stands for one prefix. Each edge is one character, kept in children. The path from root to a node spells the prefix.
insert follows the word one character at a time. For each character, setdefault returns the child if it exists, and creates it if it does not. So a second word that starts the same way reuses the first word's nodes.
Example: insert("apple") creates 5 nodes, for the prefixes a, ap, app, appl and apple. Then insert("apt") reuses the nodes for a and ap, and creates only one node, for apt. The trie now has 6 character nodes for 8 letters.
After the final insert("app"), the trie still has 6 character nodes, because every prefix of "app" was on the path already. Only is_end changes, on the node for "app".
This is why the card's invariant says that every node on any path is a prefix of some inserted word. A node is created only while some word is being inserted along that path.
What is_end adds
The path tells you that some word starts with the string. It does not tell you that the string is itself a word. is_end carries that second fact.
In the example, the trie holds "apple" and "apt", and then search("app") runs. _walk reaches the node for "app", because both words pass through it. The node exists, but is_end is False there. So search returns False. startsWith("app") returns True for the same node, because it needs only the node.
After insert("app"), the node for "app" has is_end = True, and search("app") returns True.
The two methods differ by one condition. search returns node is not None and node.is_end. startsWith returns node is not None. Writing the first without and node.is_end is the card's main trap.
The cost depends on the word, not the dictionary
Each of insert, search and startsWith visits one node per character. That is O(L) time, where L is the length of the word or prefix. The number of stored words does not appear in the cost.
Compare this with a set of words. A set finds a whole word in O(L) on average, because it hashes the string. But it cannot answer startsWith. That question needs a scan over every stored word.
The space is O(total characters) in the worst case, which is when no two words share a prefix. Shared prefixes lower it. In the example, 5 + 3 + 3 = 11 letters were inserted, and the trie holds 6 character nodes.
The card stores children in a dictionary. Each node then keeps only the characters it needs. An array of 26 slots per node is faster to index, but it uses 26 slots even for a node with one child.
Where to use the same walk
The same children and is_end fields serve many problems. Design Add and Search Words Data Structure (LC 211) lets . match any letter, so the search tries every child at that character. Word Search II (LC 212) walks a trie of all target words while a DFS moves on the board, and it stops a path when the trie has no matching child.
Replace Words (LC 648) walks each word and stops at the first node with is_end = True. Search Suggestions System (LC 1268) walks to the node for the typed prefix, and then collects words below that node.
In each case, the move is the same: a missing child means that no stored word has this prefix, so the search can stop early.
60-Second Pitch
"I store the dictionary as a tree of characters. Each edge is a letter, and each node is a prefix of some word. insert walks the word and creates missing children with setdefault, then sets is_end on the last node. Words that share a prefix share nodes. _walk follows one child per character and returns None when a child is missing. startsWith needs only that node, and search also needs is_end. Each operation costs O(L) for a word of length L. The space is O(total characters) in the worst case."
Common Questions
- What is the time and space complexity of the trie?
Each operation is O(L) time, where L is the length of the string. The space is O(total characters) in the worst case, which is when no two words share a prefix. The number of words does not change the time of one operation.
- Why use a trie when a hash set stores words?
A set answers whole-word lookups well, but
startsWithneeds a scan over every word. The trie answers a prefix question by walking one path of L nodes. The trie is also the base for autocomplete and for board searches that stop early.- What are the options for
children, and how do they compare? A dictionary stores only the characters that exist, so a sparse node is small. An array of 26 slots indexes faster, but each node holds 26 slots, even with one child. The dictionary is a safe default, and it also works for non-lowercase alphabets.
- How would you list all words that start with a prefix?
Use
_walk(prefix)to reach the node. If it isNone, return an empty list. Otherwise run a DFS from that node, and collect the path wheneveris_endisTrue. The cost is O(L) for the walk plus the size of the subtree below the node.- How would you delete a word?
Walk to the last node and set
is_end = False. This is enough for correct answers. To free memory, also remove the nodes on the path that have no children and nois_end, going from the bottom up. Stop at the first node that another word still needs.
SWE Reasoning
Spot it
- Many words or prefixes are checked against one dictionary.
- The question is about a prefix: autocomplete, "does any word start with this", word search on a board.
- A
setof words answers whole-word lookups, but you also need prefix lookups. - Characters arrive one at a time, and you want to stop as soon as no word can match.
- A wildcard such as
.replaces one letter.
The invariant, step by step
Trie()createsrootwith emptychildrenandis_end = False.insert(word)starts atroot. For eachch,children.setdefault(ch, TrieNode())returns the existing child or creates a new one. Thennodemoves to that child.- After the last character,
node.is_end = True. The path fromrootnow spellsword, and its last node marks a whole word. _walk(s)starts atrootand followschildren[ch]for each character. Ifchis not innode.children, it returnsNone.- A path for
sexists exactly when some inserted word starts withs, because nodes are created only while inserting. search(word)also requiresnode.is_end, which is set only where a whole word stopped.startsWith(prefix)needs only the path.
Traps
- Skipping
is_endinsearch. Afterinsert("apple"),search("app")returnsTrue, though "app" was never inserted. - Creating nodes in
_walk. Withsetdefaultin_walk, a read adds nodes. Aftersearch("cat")on an empty trie,startsWith("ca")returnsTrue. - Not marking the end. If
insertnever setsis_end,searchreturnsFalsefor every word. - Setting
is_endbefore the loop.node.is_end = Trueplaced before theforloop marksroot. Thensearch("")returnsTrue, and no real word is marked. - Sharing one
TrieNodebetween children. A table built as[TrieNode()] * 26puts one node object in all 26 slots, so every letter leads to the same node. Create a newTrieNode()for each child. - Empty string.
_walk("")returnsroot, sostartsWith("")isTrueandsearch("")isFalseunless the empty word was inserted. LeetCode limits words to length 1 or more.