Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 19

Tries: Autocomplete and Prefix Search

Recommended resources

Introduction

In our last lesson, we delved into finding the Lowest Common Ancestor (LCA), mastering techniques for both general binary trees and the more optimized case of Binary Search Trees. You saw how a data structure's inherent properties can dramatically change the optimal algorithm.

Today, we shift our focus to a highly specialized tree designed explicitly for string-related problems: the Trie, also known as a prefix tree. Your learning outcome is to implement a Trie and apply it to solve prefix-based search problems like autocomplete. This data structure is a cornerstone of search engines, spell checkers, and IP routing tables. It's a favorite in elite interviews because it elegantly solves a class of problems that are inefficient to handle with general-purpose structures like hash maps or lists.



Concept 1: What is a Trie?

Let's start by understanding the fundamental structure and the problem it's designed to solve. At its core, a Trie optimizes "starts-with" or prefix queries.

Technical Explanation

A Trie is a tree-like data structure where each node represents a single character. A path from the root to any node represents a prefix. Unlike a binary tree, a node in a Trie can have multiple children—one for each possible next character (e.g., 26 for lowercase English letters).

The key principles are:

  1. Shared Prefixes: Words with common prefixes (like "car", "card", and "care") share the same initial path of nodes in the tree. This makes Tries very memory-efficient for datasets with many common prefixes.
  2. End-of-Word Marker: A special flag (e.g., a boolean is_end_of_word) on a node indicates that the path from the root to this node forms a complete word.

This structure allows us to answer prefix queries in time proportional to the length of the prefix, not the number of words in the dictionary.

To see this visually, let's explore this short introduction.

Implement Trie (Prefix Tree) - Leetcode 208

This video from NeetCode provides a concise explanation of what a Trie is, its common applications, and the core operations we'll be implementing.

Watch from the beginning to 01:53. Focus on the distinction between the search and startsWith operations, as this is fundamental to a Trie's purpose.

Metaphor: The Ultimate Address Book

Imagine an old-fashioned Rolodex, but hyper-organized. Instead of having one card per contact, you have a chain of cards. To find "Anna", you first flip to the 'A' tab. Behind it, you find a smaller set of tabs, one of which is 'n'. You flip to that 'n' tab, and behind it, another 'n' tab, and finally an 'a' tab. The card for 'a' has a star on it, marking it as a complete name. If you wanted to find "Anne", you'd follow the same path until the second 'n', then flip to the 'e' tab instead. The shared "An" prefix is only stored once.

This is exactly how a Trie works. The children of a node are typically stored in a hash map, where keys are characters and values are pointers to the next node.

Trie and its Hashmap Implementation
This image shows how the conceptual idea of a Trie (left) is implemented using nodes that contain a hash map (right). Each hash map entry points to a child node. The `isEnd=true` flag marks the completion of a word like "ana" or "anne".


Concept 2: Implementing a Basic Trie

Let's translate the concept into code. A Trie implementation requires two classes: one for the node and one for the Trie itself, which will manage the nodes starting from the root.

Technical Explanation

  1. TrieNode: This class is simple. It needs two attributes:

    • children: A hash map (dictionary in Python) to store child nodes. children['a'] would point to the TrieNode for the character 'a'.
    • is_end_of_word: A boolean that is True only if this node represents the end of an inserted word.
  2. Trie: This class will have a root (an empty TrieNode) and the main methods:

    • insert(word): Traverse the Trie character by character. If a character's node doesn't exist, create it. At the end of the word, set is_end_of_word to True.
    • search(word): Traverse the Trie. If any character path is missing, the word doesn't exist. If the path exists, the method returns True only if the final node's is_end_of_word flag is True.
    • startsWith(prefix): Traverse the Trie. If the entire prefix path exists, return True. We don't care about the is_end_of_word flag.

The time complexity for all these operations is , where is the length of the word or prefix. This is incredibly efficient because the performance is independent of the number of words stored. The space complexity is in the worst case, where is the number of words and is their average length, but it's much better in practice due to prefix sharing.

Mastering the Trie: A Powerful Data Structure for String Operations

The article 'Mastering the Trie' provides a very clear and simple Python implementation, followed by a step-by-step walkthrough of inserting and searching.

Read the section 'Building Our First Trie: The “Hello World” Version' and pay close attention to the code for SimpleTrieNode and SimpleTrie. Then, study the 'How It Works: Step-by-Step Walkthrough' to see exactly how inserting 'car' and then 'card' reuses the 'c-a-r' path.

Python Code Snippet: Basic Trie

Here is a clean implementation based on the concepts you've just reviewed.

class TrieNode:
    """A node in the Trie structure."""
    def __init__(self):
        self.children = {}  # Mapping from character -> TrieNode
        self.is_end_of_word = False

class Trie:
    """Trie data structure for prefix-based search."""
    def __init__(self):
        """Initializes the Trie with an empty root node."""
        self.root = TrieNode()

    def insert(self, word: str) -> None:
        """Inserts a word into the Trie."""
        current = self.root
        for char in word:
            if char not in current.children:
                current.children[char] = TrieNode()
            current = current.children[char]
        current.is_end_of_word = True

    def search(self, word: str) -> bool:
        """Returns True if the word is in the Trie."""
        current = self.root
        for char in word:
            if char not in current.children:
                return False
            current = current.children[char]
        return current.is_end_of_word

    def startsWith(self, prefix: str) -> bool:
        """Returns True if there is any word in the Trie that starts with the given prefix."""
        current = self.root
        for char in prefix:
            if char not in current.children:
                return False
            current = current.children[char]
        return True

# Example Usage
trie = Trie()
trie.insert("apple")
print(trie.search("apple"))      # True
print(trie.search("app"))        # False
print(trie.startsWith("app"))    # True
trie.insert("app")
print(trie.search("app"))        # True


Concept 3: Application - Building a Simple Autocomplete

Now for the main event: using our Trie to build an autocomplete system. The goal is, given a prefix, to return all words that start with it.

Technical Explanation

This is a two-step process:

  1. Navigate to the Prefix Node: Traverse the Trie to the node that corresponds to the last character of the input prefix. If this path doesn't exist, no words match, so return an empty list.
  2. Collect Words from Subtree: From this prefix node, perform a search (a recursive Depth-First Search is a natural fit) through all of its descendant paths. Every time you encounter a node with is_end_of_word = True, you've found a valid word. Add it to your list of suggestions.

Metaphor: Finding All Files in a Folder

Think of your computer's file system. To find all files inside /users/me/documents/, you first navigate to that specific folder. Once you're "in" the documents folder, you then recursively list its entire contents and the contents of all its subfolders. The initial navigation is step 1, and the recursive listing is step 2.

Auto complete feature using trie

The video 'Auto complete feature using trie' by Techdose demonstrates exactly how to implement this collection logic.

Watch from 03:35 to 09:10. The video explains how to traverse from a prefix node to find all possible word endings. Pay attention to how it uses a pre-order traversal to find suggestions in alphabetical order.

Python Code Snippet: Adding Autocomplete

Let's add the suggestion functionality to our Trie class.

class Trie(Trie): # Inheriting from our previous class to add functionality
    
    def _find_prefix_node(self, prefix: str):
        """Helper to find the node corresponding to the end of a prefix."""
        current = self.root
        for char in prefix:
            if char not in current.children:
                return None
            current = current.children[char]
        return current

    def get_suggestions(self, prefix: str) -> list[str]:
        """Returns a list of all words in the Trie that start with the given prefix."""
        prefix_node = self._find_prefix_node(prefix)
        if not prefix_node:
            return []

        suggestions = []
        self._collect_words(prefix_node, prefix, suggestions)
        return suggestions

    def _collect_words(self, node: TrieNode, current_prefix: str, suggestions: list[str]):
        """Recursively collects words from a given node."""
        if node.is_end_of_word:
            suggestions.append(current_prefix)

        for char, child_node in sorted(node.children.items()): # sorted() for alphabetical order
            self._collect_words(child_node, current_prefix + char, suggestions)

# Example Usage
trie = Trie()
words = ["cat", "car", "card", "care", "careful", "egg"]
for word in words:
    trie.insert(word)

print(f"Suggestions for 'car': {trie.get_suggestions('car')}") 
# Output: Suggestions for 'car': ['car', 'card', 'care', 'careful']
print(f"Suggestions for 'c': {trie.get_suggestions('c')}")
# Output: Suggestions for 'c': ['car', 'card', 'care', 'careful', 'cat']


Concept 4: Building a Production-Ready Autocomplete

In a real-world system like Google Search, suggestions aren't just alphabetical; they're ranked by relevance or popularity. We can build this feature into our Trie by storing frequency counts at each word-ending node.

Technical Explanation

We'll upgrade our TrieNode to include a frequency counter and store the full word itself for easier retrieval.

  • TrieNode enhancements: Add frequency (int) and word (str) attributes.
  • insert(word, frequency=1): When inserting a word, if it's new, we store the word and its frequency. If it already exists, we simply increment its frequency.
  • autocomplete(prefix): After collecting all suggestions from the prefix node, we sort them in descending order of frequency before returning them.

This transforms the Trie from a simple set of strings into a dynamic data structure that learns from usage patterns.

Mastering the Trie: A Powerful Data Structure for String Operations

The 'AdvancedTrie' implementation in the 'Mastering the Trie' article is a perfect blueprint for a production-ready system.

Study the 'AdvancedTrie' class in section 'Leveling Up Our Trie'. Note the use of __slots__ for memory optimization, the frequency and word attributes in the TrieNode, and how the autocomplete method now sorts suggestions by frequency. Then, read the 'How the Advanced Trie Works: Deep Dive' section to solidify your understanding of how frequency tracking and sorting work.

Real-world System Example: Search Engine Suggestions

When you type "py" into Google, it suggests "python", "pypi", and "pygame" not because they come first alphabetically, but because they are overwhelmingly the most frequent and relevant queries starting with "py". The underlying data structure maintains these popularity scores, which are constantly updated based on trillions of searches. The AdvancedTrie you just reviewed is a small-scale model of this exact principle.



Concept 5: Tries in Scalable Systems

Your goal is to work on large-scale systems. A Trie holding all of Google's search queries can't fit on one machine. So how do elite companies scale this data structure?

Technical Explanation

The solution is sharding, or partitioning the Trie across many servers.

  1. Prefix-Based Sharding: The simplest way is to assign ranges of the alphabet to different servers. Server 1 gets a-f, Server 2 gets g-m, and so on. A router sends a query for "python" to the server responsible for 'p'.

    • Pro: Simple routing logic.
    • Con: Leads to "hotspots". The server for 's' will be overwhelmed, while the server for 'x' sits idle.
  2. Hash-Based Sharding: To solve the hotspot problem, we can use consistent hashing. We hash the prefix (or its first few characters) to determine which server it belongs to.

    • Pro: Evenly distributes load across all servers.
    • Con: Naively hashing hash("py") and hash("pyt") could send them to different servers, breaking the autocomplete logic.
    • Solution: A hybrid approach. Hash only the first 2-3 characters (e.g., hash("py")). This ensures all words starting with "py" go to the same server, while still providing good load distribution.
High-Level Design for Google's Search Autocomplete
This high-level diagram shows how a real system like Google's uses distributed Trie shards. A load balancer directs requests to a suggestion service, which queries the appropriate Trie shard based on the prefix. These Tries are updated periodically from a central database.

Design Search Autocomplete System

To go deeper, the 'Design Search Autocomplete System' article provides an excellent overview of how Tries are scaled in a distributed environment.

Read section 4.1 'The Trie (Prefix Tree)' for a quick recap. Then, focus on section 4.2 'Scaling Trie in a Distributed Environment'. Understand the pros and cons of 'Partitioning by Prefix' and 'Hash-Based Partitioning'.

Test your understanding! (Interview Style Question)

You've built an autocomplete system using a Trie for a social media app. The product manager now wants to filter out suggestions that contain blocked keywords. For example, if "spam" is a blocked word, a search for "s" should not suggest "spammy_user".

How would you modify your Trie or your autocomplete algorithm to handle this efficiently? Describe the trade-offs of your approach.

Show answer

There are two main approaches:

Approach 1: Post-filtering (Simple but less efficient)

  • Algorithm: Generate all suggestions as normal using get_suggestions. Then, iterate through the list of suggestions and remove any that contain a blocked keyword.
  • Pros: Easy to implement. No changes needed to the Trie structure.
  • Cons: Inefficient. You might generate thousands of suggestions only to throw most of them away. If the prefix is short (e.g., 's'), the intermediate list could be huge, wasting computation and memory.

Approach 2: Pruning during Traversal (More efficient)

  • Algorithm: During the recursive _collect_words traversal, as you build the current_prefix string, check if it contains any blocked keywords. If it does, stop traversing down that path immediately (prune the branch). This prevents you from ever generating invalid suggestions.
  • Pros: Much more efficient. It avoids exploring entire subtrees that will only lead to invalid results. This is the "under the hood" optimization an interviewer would be looking for.
  • Cons: Slightly more complex to implement. The check for blocked keywords happens at every step of the recursion, which adds a small overhead to each step. However, this is usually outweighed by the savings from pruning. To make the keyword check fast, the list of blocked words could itself be stored in a separate hash set for lookups.

The second approach is superior for a scalable system as it avoids unnecessary work.



Conclusion

Today, you've added the Trie to your arsenal of data structures. It is a perfect example of a specialized tool that vastly outperforms general-purpose ones for a specific task—prefix matching.

Key Takeaways:

  • Trie Structure: A tree where nodes represent characters and paths represent prefixes. Common prefixes are shared, making it memory-efficient.
  • Core Operations: insert, search, and startsWith all have a time complexity of , where is the length of the string, making lookups extremely fast.
  • Autocomplete Logic: Implemented by navigating to the prefix node and then performing a DFS to collect all words in the descendant subtree.
  • Production-Ready Tries: Ranking suggestions by frequency is crucial for real-world applications and is achieved by storing metadata in the Trie nodes.
  • Scalability: For massive datasets, Tries are sharded across multiple servers using strategies like prefix or hash-based partitioning to handle the load.

Preview of the Next Lesson

While Tries are great, their height grows with the length of words. In our next lesson, we will return to the world of BSTs and address a key weakness: they can become unbalanced and degenerate into a linked list, making operations . You will learn about self-balancing BSTs, using AVL trees and their rotation mechanism as a concrete example of how to guarantee performance for insertions, deletions, and searches.