Tries, also known as prefix trees, are a special kind of tree used to store collections of strings, such as words or sequences. A trie is often used for searching, auto-completion, spell checking, and other applications where prefixes are involved. In a trie, each node represents a character of a word, and the path from the root to a node forms a word (or part of a word). Let's explore trie techniques, key operations, time complexity, and common use cases.
A trie node typically contains:
- Children: A dictionary (or an array for fixed-size alphabets) that maps characters to child nodes.
- End of Word Marker: A boolean flag to indicate if a node represents the end of a valid word.
Here's the basic structure of a trie node in Python:
class TrieNode:
def __init__(self):
self.children = {}
self.is_end_of_word = False
class Trie:
def __init__(self):
self.root = TrieNode()To insert a word into a trie, you follow each character of the word, moving down the tree, and create nodes as necessary. After the last character of the word, you mark the node as the end of the word.
def insert(self, word):
current_node = self.root
for char in word:
if char not in current_node.children:
current_node.children[char] = TrieNode()
current_node = current_node.children[char]
current_node.is_end_of_word = TrueTime Complexity: O(m), where m is the length of the word.
- Each character is inserted one at a time, and creating a new node takes constant time.
To search for a word, you traverse the trie from the root, following each character in the word. If you reach the end of the word and the corresponding node is marked as the end of a valid word, the word exists in the trie.
def search(self, word):
current_node = self.root
for char in word:
if char not in current_node.children:
return False
current_node = current_node.children[char]
return current_node.is_end_of_wordTime Complexity: O(m), where m is the length of the word.
- Each character is searched one at a time, and finding a child node takes constant time in a hash map or array.
One of the powerful features of a trie is efficiently checking whether any word in the trie starts with a given prefix. This can be done by traversing the trie for the prefix, similar to searching for a word.
def starts_with(self, prefix):
current_node = self.root
for char in prefix:
if char not in current_node.children:
return False
current_node = current_node.children[char]
return True # Found all characters of the prefixTime Complexity: O(m), where m is the length of the prefix.
- Similar to word search, you just need to traverse the trie for the characters in the prefix.
Trie deletion is trickier than insertion and search. When deleting a word, you need to ensure that no other words in the trie share the prefix of the word you're deleting. If they do, you cannot remove those nodes.
There are three main cases for deletion:
- The word exists, and it is not a prefix for any other word: In this case, you can safely remove nodes corresponding to the word.
- The word exists, but other words share its prefix: You should only unmark the
is_end_of_wordflag but keep the nodes intact. - The word doesn’t exist: Do nothing.
def delete(self, word):
def _delete(node, word, depth):
if depth == len(word):
if not node.is_end_of_word:
return False # Word not found
node.is_end_of_word = False
return len(node.children) == 0 # If no children, delete node
char = word[depth]
if char not in node.children:
return False # Word not found
should_delete_child = _delete(node.children[char], word, depth + 1)
if should_delete_child:
del node.children[char]
return len(node.children) == 0 # Check if current node should be deleted
return False
_delete(self.root, word, 0)Time Complexity: O(m), where m is the length of the word.
- In the worst case, you traverse the entire word, and in the best case, only part of it (if shared prefixes exist).
One of the most powerful use cases of tries is auto-complete or providing suggestions for a given prefix. Once you have traversed the trie to the node corresponding to the prefix, you can collect all the words that start with that prefix by performing a DFS or BFS from that node.
def find_words_with_prefix(self, prefix):
def dfs(node, path, result):
if node.is_end_of_word:
result.append(path) # A valid word is found
for char, next_node in node.children.items():
dfs(next_node, path + char, result)
# First, find the node that matches the prefix
current_node = self.root
for char in prefix:
if char not in current_node.children:
return [] # No words with the given prefix
current_node = current_node.children[char]
# Perform DFS from that node to collect all words
result = []
dfs(current_node, prefix, result)
return resultTime Complexity:
- O(m) to find the node corresponding to the prefix, where
mis the length of the prefix. - O(k) to collect all words with the given prefix, where
kis the total number of characters in all the words starting with the prefix.
To count the number of words that start with a given prefix, we traverse the trie to find the node corresponding to the last character of the prefix and then count the words in the subtree rooted at that node.
def count_words_with_prefix(self, prefix):
def dfs_count(node):
count = 1 if node.is_end_of_word else 0
for next_node in node.children.values():
count += dfs_count(next_node)
return count
current_node = self.root
for char in prefix:
if char not in current_node.children:
return 0 # No words with the given prefix
current_node = current_node.children[char]
return dfs_count(current_node)Time Complexity:
- O(m) to find the node corresponding to the prefix, where
mis the length of the prefix. - O(k) to count the words, where
kis the number of words starting with the prefix.
While hash maps (dictionaries) are often used for storing words and performing lookups, tries provide some distinct advantages:
- Prefix-based operations (like auto-complete) are much more efficient in tries. With hash maps, you would have to scan all keys to find matching prefixes, which is O(n). In tries, prefix searches are O(m), where
mis the length of the prefix. - Memory consumption: In tries, space can be reduced if many words share the same prefix. However, tries can use more space in cases with sparse data.
- String comparison: In a hash map, each lookup involves computing the hash of the string, while in a trie, lookup depends on the length of the string but doesn’t require full-string comparisons.
- Auto-completion: Finding all valid words based on a given prefix.
- Spell Checking: Quickly verifying if a word is valid or finding suggestions based on a misspelling.
- Longest Common Prefix: Finding the longest common prefix in a set of words.
- Word Search on a Grid: Given a matrix of characters, use a trie to efficiently search for valid words (used in problems like "Word Search II").
- Dictionary/Phonebook Applications: Fast lookups based on partial inputs (e.g., searching for contacts by prefix).
| Operation | Time Complexity |
|---|---|
| Insert | O(m) |
| Search | O(m) |
| Starts With (Prefix) | O(m) |
| Delete | O(m) |
| Auto-complete | O(m + k) |
| Count Words with Prefix | O(m + k) |
mis the length of the word or prefix.kis the total number of characters in all words that match the prefix.
Tries are highly efficient for prefix-based operations, making them valuable in applications like search