Skip to content

Coding patternsBlind 75

Implement Trie (Prefix Tree)

A trie stores words letter by letter on shared paths, so every operation walks one path. Solved two ways, a plain list of words in O(N × L) per question and a trie in O(L) per operation, with a step-through, code in five languages and the Big O worked out from the code.

Build a class Trie that stores words and answers questions about them:

  • insert(word) stores word.
  • search(word) returns true if exactly word was stored before.
  • startsWith(prefix) returns true if any stored word begins with prefix.

A trie, also called a prefix tree, is the usual way to do this. Each example below is a list of calls on one new Trie, with what each call returns (null for insert).

Example 1

Input: insert("cat"), insert("car"), search("cat"), search("ca"), startsWith("ca"), insert("ca"), search("ca")

Output: null, null, true, false, true, null, true

“ca” is not a word until it is inserted, but it is a prefix of “cat” and “car” from the start. The picture shows the trie at the end.

rendtendaendc·

Example 2

Input: insert("dog"), search("do"), startsWith("dog")

Output: null, false, true

“do” was never inserted. A word counts as a prefix of itself, so startsWith(“dog”) is true.

Example 3

Input: startsWith("a"), insert("a"), search("a")

Output: false, null, true

An empty trie has no prefixes. After the insert, the one-letter word is found.

  • 1 ≤ word.length, prefix.length ≤ 2,000
  • Words and prefixes use only the lowercase letters a to z.
  • At most 30,000 calls in total.
Which letters can appear?
Only a to z. So each node needs at most 26 children, and an array of 26 works.
Is a word a prefix of itself?
Yes. startsWith("dog") is true after insert("dog").
Can the same word be inserted twice?
Yes. It changes nothing the second time.
Is there a delete?
No. Only insert and the two questions.

The questions are about starts of words. Words that start the same way can share the work of storing that start. A trie does exactly that: a tree where each edge is one letter, and each path from the root spells a prefix. Answering a question is a walk down one path, a small tree traversal.

This problem has two approaches. A hash set speeds up search but not startsWith, so it is no real middle step.

Trie O(L)

Store the words in a trie. Each node has up to 26 children, one per letter, and a flag that says “a word ends here”. The root stands for the empty start.

  • insert(word): walk down from the root, one letter at a time. When an edge is missing, add a new node. At the last letter, set the flag.
  • find(prefix): walk down the same way. If an edge is missing, no stored word starts like this, so return None. Otherwise return the node at the end of the prefix.
  • search(word): the walk must succeed and end on a flagged node.
  • startsWith(prefix): the walk only has to succeed.

Words with the same start share one path. So the walk depends only on the length of the word, not on how many words are stored.

Why it is correct: a path from the root exists exactly when some inserted word starts with those letters, and its last node is flagged exactly when one inserted word ends there.

·
answers
empty
  1. def insert(word):
  2. node = root
  3. for letter in word:
  4. if letter not in node.children:
  5. node.children[letter] = TrieNode()
  6. node = node.children[letter]
  7. node.is_word = True
  8. def find(prefix):
  9. node = root
  10. for letter in prefix:
  11. if letter not in node.children: return None
  12. node = node.children[letter]
  13. return node
  14. def search(word): return find(word) is not None and find(word).is_word
  15. def startsWith(prefix): return find(prefix) is not None

A trie starts as one empty root. Each edge below it will stand for one letter, and shared starts will share one path.

1 / 12
Solution.javasolution.pySolution.cssolution.gosolution.ts
// Trie: words that share a start share one path of nodes, one letter
// per edge. Every operation walks one path, so L steps for length L.
class Trie {
private static class Node {
Node[] children = new Node[26];
// Marks the end of a whole word, not just a prefix.
boolean isWord;
}
private final Node root = new Node();
public void insert(String word) {
Node node = root;
for (char letter : word.toCharArray()) {
// Reuse the shared start. Add nodes only where it ends.
if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node();
node = node.children[letter - 'a'];
}
node.isWord = true;
}
public boolean search(String word) {
Node node = find(word);
// Reaching the end is not enough: a word must end exactly here.
return node != null && node.isWord;
}
public boolean startsWith(String prefix) {
return find(prefix) != null;
}
private Node find(String prefix) {
// Follow one letter at a time. A missing edge means no stored
// word starts this way.
Node node = root;
for (char letter : prefix.toCharArray()) {
node = node.children[letter - 'a'];
if (node == null) return null;
}
return node;
}
}
Time O(L)Space O(N × L)Here is how we get these from the code, in four steps.
  1. 1 Find the work that repeats

    The lit lines take one step down the trie: one lookup, maybe one new node, one move. That is a fixed job, so each letter counts as 1 step. Each operation runs this once per letter of its word.

    1. def insert(word):
    2. node = root
    3. for letter in word:
    4. if letter not in node.children:
    5. node.children[letter] = TrieNode()
    6. node = node.children[letter]
    7. node.is_word = True
    8. def find(prefix):
    9. node = root
    10. for letter in prefix:
    11. if letter not in node.children: return None
    12. node = node.children[letter]
    13. return node
    14. def search(word): return find(word) is not None and find(word).is_word
    15. def startsWith(prefix): return find(prefix) is not None
  2. 2 Count the runs on Example 1

    The table lists the letters each call walks on Example 1. Every call walks exactly the letters of its own word: 3 for “cat” and “car”, 2 for “ca”.

    3 + 3 + 3 + 2 + 2 + 2 + 2 = 17 steps.

    operationinsert("cat")insert("car")search("cat")search("ca")startsWith("ca")insert("ca")search("ca")total
    letters walked333222217
  3. 3 Write the count for any n

    An operation on a word of length L walks at most L edges, one per letter. A missing edge only makes find stop sooner:

    at most L steps per operation

    Check it with Example 1: each call took as many steps as its word has letters.

  4. 4 Keep only the part that grows fastest

    There is nothing to drop: the count is already L. It does not depend on N, the number of words stored. A trie with a million words answers search("cat") in 3 steps.

    So the time is O(L).

  5. S Space: count the extra memory the same way

    Each insert adds at most one node per letter, and only where no shared path exists yet. So the trie holds at most N × L nodes, and fewer when words share starts. Example 1 stores 3 words with 8 letters in 4 nodes plus the root. The space is O(N × L) in the worst case.

    one node per new letternode

2,000 random call sequences of 1 to 15 calls, on words of 1 to 4 letters made of a and b, so prefixes, repeats and near misses come up all the time. The brute force and the trie gave the same answers every time.

Largest inputsResult
30,000 calls (the most allowed) on 2,000-letter words that share their first 1,990 letters: 10,000 inserts, then 20,000 searches and prefix checks. Trie.2.3 s for about 60 million letter steps, and about 20 MB of extra memory: about 100,000 trie nodes, which is the O(N × L) space.
2,000 calls of the same kind. Brute force.0.04 s. At 30,000 calls it would compare each of 20,000 questions with up to 10,000 stored words.