Algorithm

Implement Trie (Prefix Tree)

Trie (Prefix Tree) Pattern

Implement Trie (Prefix Tree)

Design a data structure storing a set of lowercase words, supporting three operations.

insert(word) adds a word to the set. search(word) returns true only if that exact word was inserted. startsWith(prefix) returns true if any stored word begins with the given prefix.

The distinction between the last two is the whole point: after inserting only "apple", search("app") is false because "app" was never added, while startsWith("app") is true because a stored word begins with it. A word counts as a prefix of itself.

CONSTRAINTS
  • 1 <= word.length, prefix.length <= 2000
  • word and prefix consist only of lowercase English letters
  • At most 3 × 10⁴ calls in total to insert, search and startsWith
  • The same word may be inserted more than once; duplicates change nothing
  • search and startsWith may be called before any insert
EXAMPLE 1
Input: insert("apple"), search("apple"), search("app"), startsWith("app")
Output: true, false, true
"apple" was inserted so an exact search finds it. "app" was never inserted, so search returns false — but it does begin a stored word, so startsWith returns true. These two calls take the identical route through the structure and disagree, which is why storing where a word ends is unavoidable.
EXAMPLE 2
Input: insert("apple"), insert("app"), search("app")
Output: true
Inserting "app" adds no new characters — its whole path already exists inside "apple". The only change is marking that the path may now stop there. Insertion is not only about creating structure; it is about recording an endpoint.
EXAMPLE 3
Input: insert("car"), startsWith("card")
Output: false
The stored word runs out before the prefix does. Following c, a, r succeeds, then there is no d to follow, so no stored word begins with "card". A prefix must be fully consumed, and being longer than everything stored means failure.
EXAMPLE 4
Input: search("a"), startsWith("a") on an empty structure
Output: false, false
With nothing inserted, no path exists past the root and both queries fail immediately. Worth checking explicitly — the root always exists, so an implementation that only tests for the presence of a node can wrongly return true.
What is the difference between search and startsWith?
search demands the exact word was inserted; startsWith only demands some stored word begins with the given characters. They walk the identical path and differ solely in what they check on arrival, so the structure must record which nodes end a real word.
Which characters can appear? Is the input restricted to lowercase letters?
Here, lowercase a–z only, which permits a fixed 26-slot array per node. If the alphabet were unicode or unbounded, a hash map per node would be necessary — same logic, different constant factor and very different memory.
Can the same word be inserted twice, and can I be asked to delete words?
Duplicate inserts are harmless — the second simply re-walks an existing path and re-sets a flag already set. Deletion is not required here, and it is genuinely harder: you must remove nodes only when no other word depends on them, which usually means keeping a reference count per node.
Can a prefix be longer than every word stored?
Yes, and it must return false. The walk simply runs off the end of the structure. Worth confirming, since it is easy to write a loop that returns true after successfully matching everything it could reach.

Before designing anything, notice which of the three operations is actually hard. Exact insertion and exact search are things a hash set already does well. The awkward one is startsWith, and every design decision below exists to serve it.

Why a hash set cannot help

Put a million words in a hash set. Now answer: does any stored word begin with "pre"?

A hash set finds a key by running it through a hash function to get a storage slot. That is what makes lookup fast, and it is also what makes this question impossible to answer quickly. Hashing deliberately scrambles — "pre", "pref" and "prefix" land in three unrelated slots with no relationship between them. Knowing where "pre" would live tells you nothing about where words starting with "pre" live.

So the only option is to examine every stored word:

python
def starts_with(prefix):
    for word in words:                 # every single word
        if word.startswith(prefix):
            return True
    return False

That is O(N × L) per query — a million comparisons to answer one prefix question, repeated for every query.

Name the waste precisely: the structure throws away the relationship between a word and its prefixes, then pays to rediscover it on every query. And the relationship is not incidental — "apple", "apply" and "app" genuinely share their first three characters, a fact the set records three separate times and can never exploit.

The reframe: make the string a path, not a payload

Here is the change in viewpoint. Stop storing strings as values sitting in slots. Store each string as a route through a tree, one edge per character.

Build a tree whose root means "the empty prefix". Each edge is labelled with a character, and following a route from the root spells out a string. So a node is not a character — a node is a prefix, namely the string spelled by the route that reaches it.

Insert "cat": from the root take the c edge, then a, then t, creating nodes as needed. Insert "car": the c and a edges already exist, so reuse them and branch only at the final letter. Shared prefixes are stored exactly once and become shared structure.

Now look at what the hard operation costs. To answer startsWith("ca"), walk c then a from the root. If you arrive somewhere, at least one word passes through that node, so the answer is true. If any edge is missing, the answer is false. That is two steps regardless of whether the tree holds ten words or ten million.

Key Insightthe cost of every operation is the length of the query string, never the number of words stored. Adding a million more words does not slow down a single lookup — it only adds structure elsewhere in the tree. This is the property nothing else gives you, and it is worth stating in exactly those terms.

This structure is a trie, from retrieval, also called a prefix tree.

The flag that makes search different from startsWith

There is one problem the tree alone does not solve. Insert only "cat", then ask search("ca"). Walking c then a succeeds — the node exists. But "ca" was never inserted, so the answer must be false.

The node exists because it lies on the way to "cat", not because "ca" is stored. Existing as a prefix and existing as a word are different facts, and the structure so far records only the first.

So each node carries a boolean: is this node the end of a word that was actually inserted? Then the two queries diverge at the last moment:

- startsWith returns true if the walk completes at all.
- search returns true only if the walk completes and the final node has its flag set.

That single flag is the entire difference between the two operations, and forgetting it is the classic implementation bug — everything passes except the cases where one word is a prefix of another.

The implementation
python
class TrieNode:
    def __init__(self):
        self.children = {}          # character -> TrieNode
        self.is_word = False        # does an inserted word END here?

class Trie:
    def __init__(self):
        self.root = TrieNode()      # the empty prefix; always exists

    def insert(self, word):
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()    # create only what is missing
            node = node.children[ch]
        node.is_word = True                        # mark the endpoint

    def _walk(self, s):
        """Follow s from the root; return the node reached, or None."""
        node = self.root
        for ch in s:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_word   # arrived AND it ends a word

    def starts_with(self, prefix):
        return self._walk(prefix) is not None      # merely arriving is enough

Factoring the shared walk into one helper makes the point visible in the code: the two queries are the same traversal with different acceptance conditions.

Crucial Noteis_word is set on the final node after the loop, never inside it. Setting it as you go would mark every prefix of the word as a stored word, so inserting "cat" would make search("c") and search("ca") both return true.
Crucial Notethe root is a real node representing the empty prefix, and it exists before anything is inserted. So startsWith must not simply check that a node was reached — on an empty trie with an empty query it would reach the root and wrongly succeed. Walking each query character and failing on a missing edge handles this correctly.

Costs. All three operations touch one node per query character, so each is O(L) where L is the query length — with no dependence on N, the number of stored words. Insertion allocates at most L new nodes, so total space is proportional to the number of characters inserted, which is at most N × L and considerably less when prefixes are shared.

Worked example

Insert "cat", insert "car", then run three queries.

insert("cat"). The root has no c, so create one and step in. That node has no a, so create and step. No t, so create and step. Set is_word on the final node. Three nodes exist beyond the root, representing the prefixes "c", "ca" and "cat".

insert("car"). From the root, c exists — reuse it, create nothing. From "c", a exists — reuse. From "ca", there is no r, so create one node and step in. Set is_word. Only one node was added; the first two characters cost nothing because the structure already encoded them.

search("car"). Walk c, a, r. The final node has is_word set. Return true.

search("ca"). Walk c, a. The node exists, but it was created as part of building "cat" and no one ever inserted "ca", so its is_word is false. Return false. This is the case the flag exists for.

startsWith("ca"). Walk c, a. Arrived. Return true — two stored words do begin with "ca", and note the walk was character-for-character identical to the previous query.

startsWith("cab"). Walk c, a, then look for b under "ca". The children there are t and r only. Return false.

Trade-offs and where this goes

The gain is prefix operations in time proportional to the query rather than the dictionary. The cost is memory, and it is real: every node carries a container of children. With a fixed 26-slot array per node, a trie over a large sparse dictionary allocates enormous numbers of null pointers — a hash map per node uses less space but has a worse constant factor per lookup. Which to choose depends on how dense the alphabet usage is, and it is a good thing to raise unprompted.

Recognition signals for an unseen problem: autocomplete, prefix matching, "does any word start with", longest common prefix across many strings, spell-checking against a dictionary, word games on a board, IP routing tables. The common thread is many strings sharing structure, queried by their beginnings.

The two natural extensions both appear next. Store something other than a boolean at each node — a count of words passing through, or the word itself — and you can answer "how many words share this prefix" or collect every completion. Allow the query to branch rather than following one path, and you get wildcard matching, which is the Add and Search Words problem. Walk the trie in step with something else, such as a grid, and you get Word Search II.

Train the reflex: when a problem asks about the beginnings of many strings, do not store the strings. Store the paths, and let shared prefixes become shared structure.

Interactive Strategy Visualization

Initialization

Trie starts with just a Root node.

ROOT
O(N × L) Scan Every Word Per Prefix Query
O(L) Trie Path Walk
O(L) With Fixed 26-Slot Arrays