Facebook Pixel

1804. Implement Trie II (Prefix Tree) πŸ”’

MediumDesignTrieHash TableString
Leetcode Link

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:

  1. [Trie](/problems/trie_intro)(): Constructor that initializes an empty trie object.

  2. insert(word): Adds a string word to the trie. If the same word is inserted multiple times, the trie should keep track of how many times it has been inserted.

  3. countWordsEqualTo(word): Returns the count of how many times the exact string word exists in the trie. If the word has been inserted 3 times, this should return 3.

  4. countWordsStartingWith(prefix): Returns the total count of all strings in the trie that begin with the given prefix. For example, if the trie contains "apple" (inserted twice) and "application" (inserted once), then countWordsStartingWith("app") should return 3.

  5. erase(word): Removes one instance of the string word 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 node
  • pv: The total number of words that pass through this node (prefix count)
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Word count (v): How many words end exactly at this node. This directly answers countWordsEqualTo().

  2. 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 answers countWordsStartingWith().

Why do we need both? Consider inserting "app" and "apple":

  • At node 'p' (third character), pv = 2 (both words pass through) but v = 1 (only "app" ends here)
  • At node 'l', pv = 1 and v = 0 (only "apple" passes through, doesn't end)
  • At node 'e', pv = 1 and v = 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 node
  • pv: 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:

  1. Calculate its index using ord(c) - ord('a') to map 'a'β†’0, 'b'β†’1, ..., 'z'β†’25
  2. Create a child node if it doesn't exist
  3. Move to the child node and increment its pv (this word uses this node as prefix)
  4. 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:

  1. Decrement pv for each node along the path
  2. 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
  • After inserting "apple":
    • Nodes a, p, p have pv = 3 (incremented by 1)
    • Nodes l, e have pv = 1
    • Node 'e' has v = 1
  • countWordsStartingWith("app") returns 3 (from node 'p' with pv = 3)
  • countWordsEqualTo("app") returns 2 (from node 'p' with v = 2)

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example 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:

  1. The pv counter tracks all words flowing through each node
  2. The v counter tracks only words ending at that specific node
  3. Multiple insertions of the same word increase both counters appropriately
  4. 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) where n 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) where n is the length of the word. It calls the search method which traverses through the word once, then returns the count in constant time.

  • countWordsStartingWith(prefix): O(n) where n is the length of the prefix. Similar to countWordsEqualTo, it uses the search method to traverse the prefix.

  • erase(word): O(n) where n 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) where n 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, where ALPHABET_SIZE = 26, N is the total number of unique words inserted, and L 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 length n
    • 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
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

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

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More