Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°35

Implement a Trie

Medium TrieStringsDesign

The prefix tree behind autocomplete, spell-check and IP routing: a tree keyed by characters that answers "is this a word?" and "is this a prefix?" in time proportional to the word length. Replay insert/search/startsWith operations. Solve it in Python or TypeScript, with hidden tests.

The problem

Implement a trie (prefix tree) supporting insert(word), search(word) (is the exact word stored?), and startsWith(prefix) (is any stored word prefixed by this?). You are given a list of ops like ["insert","apple"], ["search","app"], ["startsWith","app"]. Return the list of boolean outputs from every search and startsWith, in order.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement trie_ops(ops): build a trie of nested nodes (one child per character) with an end-of-word marker. Collect the boolean result of each search/startsWith.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input ops = [["insert","apple"],["search","apple"],["search","app"],["startsWith","app"],["insert","app"],["search","app"]]
Output [True, False, True, True]
"app" is a prefix before it is inserted as a word; searching it becomes True only after insertion
EXAMPLE 2
Input ops = [["insert","hello"],["search","hell"],["startsWith","hell"]]
Output [False, True]
a stored prefix is not a stored word
EXAMPLE 3
Input ops = [["insert","app"],["search","apple"],["startsWith","apple"]]
Output [False, False]
edge case: the query runs past the end of every stored word
EXAMPLE 4
Input ops = [["search","a"],["startsWith","a"]]
Output [False, False]
edge case: queries on an empty trie
CONSTRAINTS
  • Words and prefixes are lowercase letters.
  • Each operation should run in O(length of the string), independent of how many words are stored.
  • search requires the full word to be marked as an end-of-word; startsWith only requires the path to exist.
Hints 4 ideas
HINTS — 4 IDEAS
  1. A node is just a map from a character to a child node. The root is an empty node.
  2. To insert, walk/create a child per character, then mark the final node as end-of-word (e.g. a boolean flag or a sentinel key).
  3. search and startsWith both walk the characters; if a character is missing, the answer is False.
  4. The only difference: search additionally requires the final node to be an end-of-word; startsWith does not.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach stores the words in a set. search is a membership test, and startsWith loops over every stored word calling word.startswith(prefix). It is correct, and search is fast: O(L) on average to hash a string of length L. But each startsWith costs O(N·L) for N stored words, with O(C) space for C stored characters. That is exactly the wrong cost for autocomplete, which runs a prefix query on every keystroke against a large dictionary. Precomputing a set of every prefix makes startsWith O(L), but it costs O(L²) time and space per inserted word.

The approach

A trie stores each shared prefix once: a word is a path from the root, one node per character. The reference makes every node a plain dict, root = {}, with children keyed by character and the key "$" marking that the path to this node spells a complete word. insert walks with node = node.setdefault(ch, {}), creating only the missing nodes, then sets node["$"] = True.

search and startsWith share one walk: follow node[ch] per character and set ok = False at the first missing child. Only the final check differs. startsWith returns ok, since an existing path is enough; search returns ok and "$" in node, since "app" can be a path inside "apple" without being a word. The invariant: a node carries "$" exactly when the path to it was inserted as a whole word. Each operation takes one dict step per character, so its cost depends on the string’s length, not on how many words are stored.

Walk through an example

Replaying the first example shows "app" existing as a path long before it exists as a word.

stepopwhat the walk doesoutput
1insert "apple"creates 5 nodes a → p → p → l → e and marks e with "$"none
2search "apple"walks all 5 characters and ends on a marked nodeTrue
3search "app"walks 3 characters, but the second p has no "$"False
4startsWith "app"walks 3 characters; the path is enoughTrue
5insert "app"creates 0 nodes and marks the second p with "$"none
6search "app"walks 3 characters and now ends on a marked nodeTrue

Complexity

Each operation is O(L) time for a string of length L, whatever the number of stored words, so the replay is O(total characters in ops). Space is O(C) nodes for C inserted characters, fewer when words share prefixes. The set approach costs O(N·L) per startsWith for N stored words, with O(C) space.

Edge cases to test

  • A prefix that is not a word: insert "hello", then search("hell") is False and startsWith("hell") is True.
  • A query longer than any stored word: insert "app", then search("apple") and startsWith("apple") are both False, because the walk falls off at "l".
  • An empty trie: [["search","a"],["startsWith","a"]] returns [False, False].
  • The empty string: startsWith("") is True even on an empty trie, because the walk takes no steps, while search("") stays False until "" itself is inserted, which marks the root.
  • Nested words keep both markers: after inserting "car" and "card", search("car") and search("card") are both True. Inserting a word twice changes nothing.
  • The "$" marker shares the key space with letters, which is safe only because words are lowercase letters: insert "a$" and the reference reports search("a") as True. A separate boolean field per node avoids the collision.

Common mistakes

  • Not distinguishing “is a complete word” from “is a prefix” — that is exactly what the end-of-word flag is for.
  • Conflating search (needs the end flag) with startsWith (path existence only).
  • Assuming a fixed 26-letter array when the alphabet is larger — a hash map of children is more general.

Follow-ups an interviewer asks next

How do you return autocomplete suggestions for a prefix?
Walk to the prefix node exactly as startsWith does, then run a DFS below it, collecting every marked node along with the path that spells it. To return only the top k by popularity, store a count on each word’s node, or cache the best k completions at every node, trading memory for O(L) lookups.
How would you support delete?
Walk to the word’s last node and remove its end marker. Then prune on the way back up: delete a child that has no marker and no children, and stop at the first node still in use. Without pruning the trie stays correct but keeps dead branches. A per-node count of words passing through makes each pruning check O(1).
The trie uses too much memory. What are the options?
A radix tree (compressed trie) merges each chain of single-child nodes into one edge labeled with a string, which removes most nodes in a sparse trie. A fixed 26-slot array per node is fast but wastes space on empty slots; a hash map per node is the reverse. For a static dictionary, a DAWG also shares common suffixes, not only prefixes.
How would you count the stored words that start with a prefix?
Keep a counter on every node and, when inserting a word that was not already present, increment it on each node along the path. The answer for a prefix is the counter on its last node: an O(L) walk instead of a DFS over the whole subtree.

Where this shows up

Tries power autocomplete and typeahead, spell-checkers, IP routing (longest-prefix match), and dictionary lookups — anywhere you query by prefix, which a hash map fundamentally cannot do because it scatters keys with no shared structure.

Reference solution

Reference solution — Python
def trie_ops(ops):
    root = {}                       # node: char -> child dict; "$" marks a word end
    out = []
    for cmd, word in ops:
        if cmd == "insert":
            node = root
            for ch in word:
                node = node.setdefault(ch, {})
            node["$"] = True        # this path spells a complete word
        elif cmd == "search":
            node = root
            ok = True
            for ch in word:
                if ch not in node:
                    ok = False
                    break
                node = node[ch]
            out.append(ok and "$" in node)   # must end on a word
        elif cmd == "startsWith":
            node = root
            ok = True
            for ch in word:
                if ch not in node:
                    ok = False
                    break
                node = node[ch]
            out.append(ok)          # path exists is enough
    return out


print(trie_ops([["insert","apple"],["search","apple"],["search","app"],
                ["startsWith","app"],["insert","app"],["search","app"]]))  # -> [True, False, True, True]
Reference solution — TypeScript
function trieOps(ops: string[][]): boolean[] {
  const root: any = {};             // char -> child; "$" marks a word end
  const out: boolean[] = [];
  for (const [cmd, word] of ops) {
    if (cmd === "insert") {
      let node = root;
      for (const ch of word) node = node[ch] || (node[ch] = {});
      node["$"] = true;             // complete word
    } else if (cmd === "search") {
      let node = root, ok = true;
      for (const ch of word) {
        if (!(ch in node)) { ok = false; break; }
        node = node[ch];
      }
      out.push(ok && "$" in node);  // must end on a word
    } else if (cmd === "startsWith") {
      let node = root, ok = true;
      for (const ch of word) {
        if (!(ch in node)) { ok = false; break; }
        node = node[ch];
      }
      out.push(ok);                 // path exists is enough
    }
  }
  return out;
}

console.log(trieOps([["insert","apple"],["search","apple"],["search","app"],
  ["startsWith","app"],["insert","app"],["search","app"]])); // -> [true,false,true,true]

Questions about Implement a Trie

What is the difference between search and startsWith in a trie?

Both walk the trie one character at a time from the root and fail as soon as a character has no child. startsWith succeeds once the whole prefix has been walked, because every path in the trie is the start of at least one stored word. search needs one more check: the node it stops on must carry the end-of-word marker. In the first example, after inserting apple, the path a-p-p exists, so startsWith of app is True, but search of app stays False until app itself is inserted and its last node is marked.

Why use a trie instead of a hash set?

A hash set answers whether an exact word is stored in about O(L) time, but it cannot answer a prefix question without checking every stored word, because hashing scatters words that share a prefix. A trie stores each shared prefix once, so a prefix query costs O(L) whatever the dictionary size, and it supports autocomplete, counting words by prefix and longest-prefix matching. The price is memory: one node per distinct prefix, each with its own child map, usually far more than a set of strings. For exact lookups alone, a hash set is simpler and smaller.

What is the time complexity of a trie?

Insert, search and startsWith each take O(L) time for a string of length L, because they do one child lookup per character, and the number of stored words never enters the cost. Space is O(total characters inserted) in the worst case, one node per character, and less when words share prefixes. With a hash map per node each child lookup is O(1) on average; with a fixed array of 26 children it is O(1) in the worst case, at the price of 26 slots in every node whether they are used or not.

How is a trie node usually represented?

Either as a fixed array of child pointers, 26 slots for lowercase English, plus an end-of-word flag, or as a hash map from character to child. The array gives the fastest lookups and is common in interview code, but it wastes memory on sparse nodes and needs a small, known alphabet. The map handles any alphabet and stores only the children that exist. The reference uses a plain dictionary per node with the key $ as the end marker, which is compact but works only because $ can never be a character of a word here.

▶  Watch it explained
Finished this one? 0 / 76 Challenges done
NextBloom Filter Next on the “Structures you build” ladder · rung 3 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges