1804. Implement Trie II (Prefix Tree) π
Problem Description
This problem asks you to implement a Trie (also known as a prefix tree) data structure with enhanced functionality. A Trie is a tree-based data structure that efficiently stores and retrieves strings, commonly used in applications like autocomplete and spell checkers.
You need to implement a [Trie](/problems/trie_intro)
class with the following methods:
-
[Trie](/problems/trie_intro)()
: Constructor that initializes an empty trie object. -
insert(word)
: Adds a stringword
to the trie. If the same word is inserted multiple times, the trie should keep track of how many times it has been inserted. -
countWordsEqualTo(word)
: Returns the count of how many times the exact stringword
exists in the trie. If the word has been inserted 3 times, this should return 3. -
countWordsStartingWith(prefix)
: Returns the total count of all strings in the trie that begin with the givenprefix
. For example, if the trie contains "apple" (inserted twice) and "application" (inserted once), thencountWordsStartingWith("app")
should return 3. -
erase(word)
: Removes one instance of the stringword
from the trie. This assumes the word exists in the trie before calling erase.
The key difference from a standard Trie implementation is that this version needs to:
- Track multiple occurrences of the same word (a word can be inserted multiple times)
- Count words with specific prefixes considering their multiplicity
- Support deletion of words while maintaining accurate counts
Each node in the solution maintains two counters:
v
: The number of times a word ends at this nodepv
: The total number of words that pass through this node (prefix count)
Intuition
The core challenge here is that we need to track not just whether words exist, but how many times each word appears and how many words share common prefixes. This means a standard Trie where nodes simply mark word endings isn't sufficient.
Think about what happens when we insert "apple" three times and "application" twice. When we ask for words starting with "app", we need to return 5 (not 2). This tells us we need to maintain running counts as we traverse the Trie.
The key insight is to maintain two separate counters at each node:
-
Word count (
v
): How many words end exactly at this node. This directly answerscountWordsEqualTo()
. -
Prefix count (
pv
): How many words pass through this node on their way to completion. This is the sum of all words that have this node's path as their prefix, which directly answerscountWordsStartingWith()
.
Why do we need both? Consider inserting "app" and "apple":
- At node 'p' (third character),
pv = 2
(both words pass through) butv = 1
(only "app" ends here) - At node 'l',
pv = 1
andv = 0
(only "apple" passes through, doesn't end) - At node 'e',
pv = 1
andv = 1
(only "apple" passes through and ends)
When we insert a word, we increment pv
for every node along the path (since this word uses all these nodes as prefixes), but only increment v
at the final node where the word ends.
For deletion, we simply reverse this process: decrement pv
along the entire path and decrement v
at the word's ending node. We don't need to physically remove nodes because the counters tell us everything - a node with pv = 0
is effectively "not used" even if it still exists in memory.
This design elegantly handles multiple insertions of the same word - inserting "apple" three times means the final 'e' node will have v = 3
, and all nodes along the path will have their pv
increased by 3 total.
Learn more about Trie patterns.
Solution Approach
The implementation uses an array-based Trie where each node contains:
children
: An array of 26 pointers (for lowercase letters a-z)v
: Counter for words ending at this nodepv
: Counter for words passing through this node (prefix count)
1. Insert String
Starting from the root, we traverse the Trie character by character:
def insert(self, word: str) -> None:
node = self
for c in word:
idx = ord(c) - ord('a') # Convert char to index (0-25)
if node.children[idx] is None:
node.children[idx] = [Trie](/problems/trie_intro)() # Create new node if needed
node = node.children[idx]
node.pv += 1 # Increment prefix count for this node
node.v += 1 # Increment word count at final node
For each character, we:
- Calculate its index using
ord(c) - ord('a')
to map 'a'β0, 'b'β1, ..., 'z'β25 - Create a child node if it doesn't exist
- Move to the child node and increment its
pv
(this word uses this node as prefix) - After processing all characters, increment
v
at the final node
Time complexity: O(n)
where n
is the word length.
2. Search Helper Function
The search
method is a utility function that navigates to a specific node:
def search(self, word):
node = self
for c in word:
idx = ord(c) - ord('a')
if node.children[idx] is None:
return None # Path doesn't exist
node = node.children[idx]
return node # Return the node where word/prefix ends
This returns the node where the given string ends, or None
if the path doesn't exist.
Time complexity: O(n)
where n
is the word length.
3. Count Words Equal To
def countWordsEqualTo(self, word: str) -> int:
node = self.search(word)
return 0 if node is None else node.v
Uses the search helper to find the node where word
ends, then returns its v
value (number of times this exact word was inserted).
Time complexity: O(n)
where n
is the word length.
4. Count Words Starting With
def countWordsStartingWith(self, prefix: str) -> int:
node = self.search(prefix)
return 0 if node is None else node.pv
Uses the search helper to find the node where prefix
ends, then returns its pv
value (total count of all words passing through this node).
Time complexity: O(n)
where n
is the prefix length.
5. Erase String
def erase(self, word: str) -> None:
node = self
for c in word:
idx = ord(c) - ord('a')
node = node.children[idx]
node.pv -= 1 # Decrement prefix count
node.v -= 1 # Decrement word count at final node
Assumes the word exists (as per problem constraints). We traverse the path and:
- Decrement
pv
for each node along the path - Decrement
v
at the final node
Note that we don't physically delete nodes even if their counters reach 0. This simplifies the implementation and doesn't affect correctness since we check counters, not node existence.
Time complexity: O(n)
where n
is the word length.
Example Walkthrough
If we insert "app" twice and "apple" once:
- After inserting "app" twice:
- Nodes a, p, p have
pv = 2
- Last 'p' node has
v = 2
- Nodes a, p, p have
- After inserting "apple":
- Nodes a, p, p have
pv = 3
(incremented by 1) - Nodes l, e have
pv = 1
- Node 'e' has
v = 1
- Nodes a, p, p have
countWordsStartingWith("app")
returns 3 (from node 'p' withpv = 3
)countWordsEqualTo("app")
returns 2 (from node 'p' withv = 2
)
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example to understand how the enhanced Trie works with its dual-counter system.
Scenario: We'll insert "cat" twice, "cats" once, and "dog" once, then perform various operations.
Step 1: Insert "cat" (first time)
Starting from root, traverse c β a β t
- At 'c': pv = 1 (one word passes through)
- At 'a': pv = 1
- At 't': pv = 1, v = 1 (word ends here)
Step 2: Insert "cat" (second time)
Traverse the same path c β a β t
- At 'c': pv = 2 (two words pass through now)
- At 'a': pv = 2
- At 't': pv = 2, v = 2 (two instances of "cat" end here)
Step 3: Insert "cats"
Traverse c β a β t β s - At 'c': pv = 3 (incremented, three words total pass through) - At 'a': pv = 3 - At 't': pv = 3 (incremented, "cats" passes through here) - At 's': pv = 1, v = 1 (only "cats" reaches and ends here)
Step 4: Insert "dog"
Create new path d β o β g - At 'd': pv = 1 - At 'o': pv = 1 - At 'g': pv = 1, v = 1
Current Trie State:
root βββ c (pv=3) β βββ a (pv=3) β βββ t (pv=3, v=2) β βββ s (pv=1, v=1) βββ d (pv=1) βββ o (pv=1) βββ g (pv=1, v=1)
Query Operations:
countWordsEqualTo("cat")
β Returns 2 (node 't' has v=2)countWordsStartingWith("ca")
β Returns 3 (node 'a' has pv=3, counting both "cat" instances and "cats")countWordsStartingWith("cat")
β Returns 3 (node 't' has pv=3)countWordsEqualTo("cats")
β Returns 1 (node 's' has v=1)
Step 5: Erase "cat" (removes one instance)
Traverse c β a β t and decrement counters - At 'c': pv = 2 (decremented from 3) - At 'a': pv = 2 - At 't': pv = 2, v = 1 (decremented from v=2) - Node 's' remains unchanged (pv=1, v=1)
After erasure:
countWordsEqualTo("cat")
β Returns 1 (one instance remains)countWordsStartingWith("ca")
β Returns 2 (one "cat" + one "cats")
This example demonstrates how:
- The
pv
counter tracks all words flowing through each node - The
v
counter tracks only words ending at that specific node - Multiple insertions of the same word increase both counters appropriately
- Erasure decrements counters without removing nodes, maintaining the Trie structure
Solution Implementation
1class Trie:
2 def __init__(self):
3 """Initialize the Trie node with children array and counters."""
4 # Array to store 26 possible child nodes (for lowercase letters a-z)
5 self.children = [None] * 26
6 # Counter for words ending at this node
7 self.word_count = 0
8 # Counter for words passing through this node (prefix count)
9 self.prefix_count = 0
10
11 def insert(self, word: str) -> None:
12 """
13 Insert a word into the trie.
14
15 Args:
16 word: The word to insert (lowercase letters only)
17 """
18 node = self
19 for char in word:
20 # Calculate index for the character (0-25 for a-z)
21 index = ord(char) - ord('a')
22 # Create new node if path doesn't exist
23 if node.children[index] is None:
24 node.children[index] = Trie()
25 # Move to the child node
26 node = node.children[index]
27 # Increment prefix count for all nodes in the path
28 node.prefix_count += 1
29 # Increment word count at the final node
30 node.word_count += 1
31
32 def countWordsEqualTo(self, word: str) -> int:
33 """
34 Count the number of instances of the word in the trie.
35
36 Args:
37 word: The word to search for
38
39 Returns:
40 Number of times the word appears in the trie
41 """
42 node = self.search(word)
43 return 0 if node is None else node.word_count
44
45 def countWordsStartingWith(self, prefix: str) -> int:
46 """
47 Count the number of words in the trie that start with the given prefix.
48
49 Args:
50 prefix: The prefix to search for
51
52 Returns:
53 Number of words with the given prefix
54 """
55 node = self.search(prefix)
56 return 0 if node is None else node.prefix_count
57
58 def erase(self, word: str) -> None:
59 """
60 Remove one instance of the word from the trie.
61 Assumes the word exists in the trie.
62
63 Args:
64 word: The word to remove
65 """
66 node = self
67 for char in word:
68 # Calculate index for the character
69 index = ord(char) - ord('a')
70 # Move to the child node
71 node = node.children[index]
72 # Decrement prefix count along the path
73 node.prefix_count -= 1
74 # Decrement word count at the final node
75 node.word_count -= 1
76
77 def search(self, word: str) -> 'Trie':
78 """
79 Search for a word/prefix in the trie and return the final node.
80
81 Args:
82 word: The word or prefix to search for
83
84 Returns:
85 The node at the end of the word path, or None if not found
86 """
87 node = self
88 for char in word:
89 # Calculate index for the character
90 index = ord(char) - ord('a')
91 # Return None if path doesn't exist
92 if node.children[index] is None:
93 return None
94 # Move to the child node
95 node = node.children[index]
96 return node
97
98
99# Your Trie object will be instantiated and called as such:
100# obj = Trie()
101# obj.insert(word)
102# param_2 = obj.countWordsEqualTo(word)
103# param_3 = obj.countWordsStartingWith(prefix)
104# obj.erase(word)
105
1class Trie {
2 // Array to store child nodes for each letter (a-z)
3 private Trie[] children;
4
5 // Count of words ending at this node
6 private int wordCount;
7
8 // Count of words passing through this node (prefix count)
9 private int prefixCount;
10
11 /**
12 * Initialize the Trie data structure
13 */
14 public Trie() {
15 this.children = new Trie[26];
16 this.wordCount = 0;
17 this.prefixCount = 0;
18 }
19
20 /**
21 * Insert a word into the Trie
22 * @param word The word to insert
23 */
24 public void insert(String word) {
25 Trie currentNode = this;
26
27 // Traverse through each character in the word
28 for (char ch : word.toCharArray()) {
29 int index = ch - 'a';
30
31 // Create new node if path doesn't exist
32 if (currentNode.children[index] == null) {
33 currentNode.children[index] = new Trie();
34 }
35
36 // Move to the child node
37 currentNode = currentNode.children[index];
38
39 // Increment prefix count for this node
40 currentNode.prefixCount++;
41 }
42
43 // Increment word count at the final node
44 currentNode.wordCount++;
45 }
46
47 /**
48 * Count the number of instances of a specific word in the Trie
49 * @param word The word to count
50 * @return Number of times the word appears
51 */
52 public int countWordsEqualTo(String word) {
53 Trie targetNode = search(word);
54 return targetNode == null ? 0 : targetNode.wordCount;
55 }
56
57 /**
58 * Count the number of words that start with the given prefix
59 * @param prefix The prefix to search for
60 * @return Number of words with the given prefix
61 */
62 public int countWordsStartingWith(String prefix) {
63 Trie targetNode = search(prefix);
64 return targetNode == null ? 0 : targetNode.prefixCount;
65 }
66
67 /**
68 * Remove one instance of a word from the Trie
69 * Assumes the word exists in the Trie
70 * @param word The word to remove
71 */
72 public void erase(String word) {
73 Trie currentNode = this;
74
75 // Traverse through each character in the word
76 for (char ch : word.toCharArray()) {
77 int index = ch - 'a';
78
79 // Move to the child node
80 currentNode = currentNode.children[index];
81
82 // Decrement prefix count for this node
83 currentNode.prefixCount--;
84 }
85
86 // Decrement word count at the final node
87 currentNode.wordCount--;
88 }
89
90 /**
91 * Helper method to search for a word/prefix in the Trie
92 * @param word The word or prefix to search for
93 * @return The node at the end of the word/prefix, or null if not found
94 */
95 private Trie search(String word) {
96 Trie currentNode = this;
97
98 // Traverse through each character in the word
99 for (char ch : word.toCharArray()) {
100 int index = ch - 'a';
101
102 // If path doesn't exist, return null
103 if (currentNode.children[index] == null) {
104 return null;
105 }
106
107 // Move to the child node
108 currentNode = currentNode.children[index];
109 }
110
111 return currentNode;
112 }
113}
114
115/**
116 * Your Trie object will be instantiated and called as such:
117 * Trie obj = new Trie();
118 * obj.insert(word);
119 * int param_2 = obj.countWordsEqualTo(word);
120 * int param_3 = obj.countWordsStartingWith(prefix);
121 * obj.erase(word);
122 */
123
1class Trie {
2public:
3 // Constructor: Initialize the Trie with 26 child pointers (for lowercase letters)
4 Trie()
5 : children(26, nullptr),
6 wordCount(0),
7 prefixCount(0) {
8 }
9
10 // Insert a word into the Trie
11 void insert(string word) {
12 Trie* currentNode = this;
13
14 for (char ch : word) {
15 int index = ch - 'a'; // Convert character to index (0-25)
16
17 // Create new node if path doesn't exist
18 if (!currentNode->children[index]) {
19 currentNode->children[index] = new Trie();
20 }
21
22 currentNode = currentNode->children[index];
23 currentNode->prefixCount++; // Increment prefix count for this node
24 }
25
26 currentNode->wordCount++; // Increment word count at the end node
27 }
28
29 // Count the number of words equal to the given word
30 int countWordsEqualTo(string word) {
31 Trie* targetNode = search(word);
32 return targetNode ? targetNode->wordCount : 0;
33 }
34
35 // Count the number of words starting with the given prefix
36 int countWordsStartingWith(string prefix) {
37 Trie* targetNode = search(prefix);
38 return targetNode ? targetNode->prefixCount : 0;
39 }
40
41 // Remove a word from the Trie (assumes the word exists)
42 void erase(string word) {
43 Trie* currentNode = this;
44
45 for (char ch : word) {
46 int index = ch - 'a'; // Convert character to index (0-25)
47 currentNode = currentNode->children[index];
48 currentNode->prefixCount--; // Decrement prefix count along the path
49 }
50
51 currentNode->wordCount--; // Decrement word count at the end node
52 }
53
54private:
55 vector<Trie*> children; // Pointers to child nodes (26 for each letter)
56 int wordCount; // Number of words ending at this node
57 int prefixCount; // Number of words passing through this node
58
59 // Helper function: Search for a word/prefix and return the ending node
60 Trie* search(string& word) {
61 Trie* currentNode = this;
62
63 for (char ch : word) {
64 int index = ch - 'a'; // Convert character to index (0-25)
65
66 // Return null if path doesn't exist
67 if (!currentNode->children[index]) {
68 return nullptr;
69 }
70
71 currentNode = currentNode->children[index];
72 }
73
74 return currentNode;
75 }
76};
77
78/**
79 * Your Trie object will be instantiated and called as such:
80 * Trie* obj = new Trie();
81 * obj->insert(word);
82 * int param_2 = obj->countWordsEqualTo(word);
83 * int param_3 = obj->countWordsStartingWith(prefix);
84 * obj->erase(word);
85 */
86
1// Node structure for Trie
2interface TrieNode {
3 children: (TrieNode | null)[]; // Array of 26 child pointers (for lowercase letters a-z)
4 wordCount: number; // Number of words ending at this node
5 prefixCount: number; // Number of words passing through this node
6}
7
8// Global root node of the Trie
9let root: TrieNode;
10
11// Initialize the Trie with a root node
12function Trie(): void {
13 root = {
14 children: new Array(26).fill(null),
15 wordCount: 0,
16 prefixCount: 0
17 };
18}
19
20// Insert a word into the Trie
21function insert(word: string): void {
22 let currentNode = root;
23
24 for (const char of word) {
25 const index = char.charCodeAt(0) - 'a'.charCodeAt(0); // Convert character to index (0-25)
26
27 // Create new node if path doesn't exist
28 if (!currentNode.children[index]) {
29 currentNode.children[index] = {
30 children: new Array(26).fill(null),
31 wordCount: 0,
32 prefixCount: 0
33 };
34 }
35
36 currentNode = currentNode.children[index]!;
37 currentNode.prefixCount++; // Increment prefix count for this node
38 }
39
40 currentNode.wordCount++; // Increment word count at the end node
41}
42
43// Count the number of words equal to the given word
44function countWordsEqualTo(word: string): number {
45 const targetNode = search(word);
46 return targetNode ? targetNode.wordCount : 0;
47}
48
49// Count the number of words starting with the given prefix
50function countWordsStartingWith(prefix: string): number {
51 const targetNode = search(prefix);
52 return targetNode ? targetNode.prefixCount : 0;
53}
54
55// Remove a word from the Trie (assumes the word exists)
56function erase(word: string): void {
57 let currentNode = root;
58
59 for (const char of word) {
60 const index = char.charCodeAt(0) - 'a'.charCodeAt(0); // Convert character to index (0-25)
61 currentNode = currentNode.children[index]!;
62 currentNode.prefixCount--; // Decrement prefix count along the path
63 }
64
65 currentNode.wordCount--; // Decrement word count at the end node
66}
67
68// Helper function: Search for a word/prefix and return the ending node
69function search(word: string): TrieNode | null {
70 let currentNode = root;
71
72 for (const char of word) {
73 const index = char.charCodeAt(0) - 'a'.charCodeAt(0); // Convert character to index (0-25)
74
75 // Return null if path doesn't exist
76 if (!currentNode.children[index]) {
77 return null;
78 }
79
80 currentNode = currentNode.children[index]!;
81 }
82
83 return currentNode;
84}
85
Time and Space Complexity
Time Complexity:
-
insert(word)
:O(n)
wheren
is the length of the word. The method traverses through each character of the word once, performing constant time operations (array access, arithmetic) at each step. -
countWordsEqualTo(word)
:O(n)
wheren
is the length of the word. It calls thesearch
method which traverses through the word once, then returns the count in constant time. -
countWordsStartingWith(prefix)
:O(n)
wheren
is the length of the prefix. Similar tocountWordsEqualTo
, it uses thesearch
method to traverse the prefix. -
erase(word)
:O(n)
wheren
is the length of the word. The method traverses through each character of the word once, decrementing counters at each node. -
search(word)
:O(n)
wheren
is the length of the word. It traverses through each character exactly once.
Space Complexity:
-
For the Trie structure itself:
O(ALPHABET_SIZE Γ N Γ L)
in the worst case, whereALPHABET_SIZE = 26
,N
is the total number of unique words inserted, andL
is the average length of words. However, in practice, due to prefix sharing, the space usage is often much less. -
For individual operations:
insert
:O(n)
in the worst case when creating new nodes for a word of lengthn
countWordsEqualTo
,countWordsStartingWith
,search
:O(1)
additional space (only using variables for traversal)erase
:O(1)
additional space (only traversing existing nodes)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Not Handling Multiple Insertions of the Same Word
Pitfall: A common mistake is treating the Trie like a set where each word can only exist once. Developers might use a boolean flag (isEndOfWord
) instead of a counter, which fails when the same word needs to be tracked multiple times.
Incorrect Implementation:
class Trie:
def __init__(self):
self.children = [None] * 26
self.isEndOfWord = False # Wrong! Can't track multiple insertions
self.prefix_count = 0
def insert(self, word):
# ...
node.isEndOfWord = True # This doesn't count duplicates
Solution: Use integer counters (word_count
and prefix_count
) instead of boolean flags to track the frequency of words and prefixes.
2. Forgetting to Update Prefix Counts Along the Path
Pitfall: Only updating the counter at the final node without incrementing prefix_count
for all nodes along the path. This breaks the countWordsStartingWith
functionality.
Incorrect Implementation:
def insert(self, word):
node = self
for char in word:
index = ord(char) - ord('a')
if node.children[index] is None:
node.children[index] = Trie()
node = node.children[index]
# Forgot to update node.prefix_count here!
node.word_count += 1
node.prefix_count += 1 # Wrong! Only updating at the end
Solution: Increment prefix_count
for every node visited during insertion, not just the final node.
3. Physical Node Deletion in Erase Method
Pitfall: Attempting to physically remove nodes when erasing a word, which complicates the implementation and can break the tree structure if other words share the same prefix.
Incorrect Implementation:
def erase(self, word):
def _erase_helper(node, word, index):
if index == len(word):
node.word_count -= 1
# Trying to delete the node if empty
return node.word_count == 0 and not any(node.children)
char_index = ord(word[index]) - ord('a')
should_delete = _erase_helper(node.children[char_index], word, index + 1)
if should_delete:
node.children[char_index] = None # Dangerous! Other words might use this path
node.prefix_count -= 1
return node.prefix_count == 0 and not any(node.children)
Solution: Simply decrement the counters without physically removing nodes. Nodes with zero counts are effectively "empty" and don't affect the correctness of operations.
4. Not Checking for None in Search Before Accessing Properties
Pitfall: Directly accessing properties of the search result without checking if it's None first, leading to AttributeError.
Incorrect Implementation:
def countWordsEqualTo(self, word):
node = self.search(word)
return node.word_count # AttributeError if node is None!
Solution: Always check if the search result is None before accessing its properties:
def countWordsEqualTo(self, word):
node = self.search(word)
return 0 if node is None else node.word_count
5. Assuming Word Exists in Erase Without Validation
Pitfall: While the problem states that erase assumes the word exists, in production code, not validating this can lead to negative counters or incorrect state.
Potential Issue:
# If someone calls erase("word") when "word" doesn't exist: # - prefix_count values become negative # - word_count becomes negative # - Future operations give incorrect results
Solution for Production Code: Add validation to ensure the word exists before erasing:
def erase(self, word):
# First check if word exists
if self.countWordsEqualTo(word) == 0:
return # Or raise an exception
# Then proceed with normal erase logic
node = self
for char in word:
index = ord(char) - ord('a')
node = node.children[index]
node.prefix_count -= 1
node.word_count -= 1
A person thinks of a number between 1 and 1000. You may ask any number questions to them, provided that the question can be answered with either "yes" or "no".
What is the minimum number of questions you needed to ask so that you are guaranteed to know the number that the person is thinking?
Recommended Readings
Trie Introduction Imagine you have a magic dictionary where as soon as you start spelling a word it immediately tells you how many words that start with those letters How cool would that be This is exactly the kind of magic a data structure called trie performs What's a Trie
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Donβt Miss This!