CodePark/ lessons / Tries: share the start of every word
START PRACTICINGPRACTICE
Data structures and algorithms / Tries (prefix trees) / lesson

Tries: share the start of every word

The idea

A trie stores words in a tree where each edge is one letter. So the path from the root down to a node spells a prefix, the start of a word. Words that start the same way share the same path. This means you answer any question about a prefix by walking that prefix once, letter by letter. It doesn't matter how many words you stored.

Think of it like this: a phone's contact search. You type “m” and the list jumps to the M section. You type “ma” and it narrows to Mark, Maria and Matt. You never read through the whole address book. Each letter you type takes you one step further down. Everyone who shares those letters is already grouped together underneath.

How to spot it

Words and constraints in a problem that point to this pattern:

  • “Starts with”, “prefix”, “begins with”: many questions about the start of words
  • Many words stored once, then many lookups: “add a word”, then “search” or “suggest”
  • “As the user types each letter”: autocomplete and search suggestions
  • A wildcard such as . that matches any one letter, where a plain Set can't look up the pattern
  • A dictionary of words to match against a long text or a grid of letters

Watch it work

Words you'll need:

  • A prefix of a word is any run of letters from its start. "c", "ca" and "car" are the prefixes of "car". A whole word counts as a prefix of itself.
  • A trie is a tree, like the ones in the trees lesson. You say it like "try", and the name comes from re*trie*val. In a trie, each node can have many children. Each link from a parent to a child is labelled with one letter.
  • The root is an empty starting node. Reading the letters on the way down from the root to any node spells a prefix. So the node you reach by following c, then a, is the prefix "ca".
  • Each node keeps its children in a Map from letter to child node, the same Map from the hash map lesson. children.get("a") finds the "a" child in one step.
  • Each node also has an end-of-word flag, isEnd. It is true when a stored word finishes exactly at that node. In the drawings below a * marks it.

Why the flag? After inserting "car" and "cat", the node for "ca" exists, but "ca" was never inserted. The flag is how the trie tells "a word ends here" apart from "words pass through here".

The trace inserts "car", "cat" and "do", then searches for "ca". Brackets show the node the code is standing on. The row shows the word, with a pointer i on the current letter.

The template

A trie is two small classes:

  • a node, which holds a Map of children and an end-of-word flag
  • the trie itself, which holds the root and walks it

All three operations are the same loop: start at the root and follow one letter at a time.

  • insert creates a child whenever one is missing. Then it marks the last node as an end.
  • search follows the letters and returns false as soon as a child is missing. At the end, it checks the flag.
  • startsWith is search without the flag check. Reaching the end of the prefix is enough.

search and startsWith share the walk, so it lives in one helper, walk. It returns the node a prefix leads to, or null if the path breaks.

class TrieNode {
  children = new Map<string, TrieNode>(); // letter → the child node for that letter
  isEnd = false;                          // true when a stored word ends exactly here
}

class Trie {
  private root = new TrieNode();          // the empty prefix: every walk starts here

  insert(word: string): void {
    let node = this.root;
    for (const ch of word) {              // one step down per letter
      let next = node.children.get(ch);
      if (next === undefined) {           // no path for this letter yet
        next = new TrieNode();            // so build it
        node.children.set(ch, next);
      }
      node = next;                        // step down
    }
    node.isEnd = true;                    // mark where the word finishes
  }

  search(word: string): boolean {
    const node = this.walk(word);
    return node !== null && node.isEnd;   // the path exists AND a word ends here
  }

  startsWith(prefix: string): boolean {
    return this.walk(prefix) !== null;    // the path exists: some word continues from here
  }

  // Follow the letters of text from the root. Return the node reached, or null if a letter is missing.
  private walk(text: string): TrieNode | null {
    let node = this.root;
    for (const ch of text) {
      const next = node.children.get(ch);
      if (next === undefined) return null; // the path breaks: nothing stored starts like this
      node = next;
    }
    return node;
  }
}

// const t = new Trie();
// t.insert("car"); t.insert("cat"); t.insert("do");
// t.search("ca")      → false (a path, but no word ends there)
// t.startsWith("ca")  → true
// t.search("cat")     → true

Worked example

Counting prefixes. You get a list of words and a list of queries. For each query, return how many words start with it. With words = ["car", "cat", "cart", "dog"] and queries = ["ca", "car", "d", "x"], the answer is [3, 2, 1, 0].

  1. The first idea: for each query, loop over every word and count the ones where word.startsWith(query). It's correct, and with 4 words it's instant. With 20,000 words and 20,000 queries, it's 400 million startsWith calls. That takes seconds.

    function countPrefixesSlow(words: string[], queries: string[]): number[] {
      return queries.map((q) => words.filter((w) => w.startsWith(q)).length);
    }
    
  2. Notice the waste. The queries "ca" and "car" both re-read "car", "cat" and "cart" from the first letter. Both also check "dog", even though its first letter already rules it out. The words that share a start get compared again and again.

  3. In a trie, every word that starts with "ca" passes through the "ca" node on its way down. So let each node keep a counter of how many words passed through it during insert. Then the answer for a query is the counter on the node the query leads to. That is one walk per query, and no loop over the words.

  4. Add count to the node. Add 1 to it on every node the insert steps into. Then answer each query with a walk. A query whose path breaks has 0 words.

    class CountNode {
      children = new Map<string, CountNode>();
      count = 0;                               // how many inserted words pass through this node
    }
    
    function countPrefixes(words: string[], queries: string[]): number[] {
      const root = new CountNode();
      for (const word of words) {
        let node = root;
        for (const ch of word) {
          let next = node.children.get(ch);
          if (next === undefined) {
            next = new CountNode();
            node.children.set(ch, next);
          }
          next.count++;                         // this word passes through next
          node = next;
        }
      }
      return queries.map((q) => {
        let node: CountNode | undefined = root;
        for (const ch of q) {
          node = node.children.get(ch);
          if (node === undefined) return 0;     // the path breaks: no word starts like this
        }
        return node.count;
      });
    }
    
    // countPrefixes(["car", "cat", "cart", "dog"], ["ca", "car", "d", "x"]) → [3, 2, 1, 0]
    
  5. Check it by hand. Inserting the four words leaves c with count 3, "ca" with 3, "car" with 2 (from "car" and "cart") and d with 1. The query "x" breaks at the first letter and returns 0. Note that count sits on the child we step into, never on the root. The root would count every word, which is the answer for the empty query "".

  6. Edge cases: a word inserted twice counts twice, which is what the question asks for. If it should count once, keep a Set of inserted words and skip a word that's already in it. A query longer than every word breaks partway down and returns 0.

Common mistakes

  • Forgetting the end-of-word flag. Then search("ca") returns true after inserting only "car", because the path exists even though no word ends there.
  • Setting isEnd on every node you pass, or inside the loop instead of after it. Then every prefix of every word looks like a stored word.
  • Creating children in search or startsWith by reusing the insert loop. Every failed lookup quietly adds nodes. Later startsWith calls then return true for words never inserted.
  • Forgetting to step down (node = next) after creating a child. Every letter gets attached to the root, so "car" is stored as three one-letter branches. Lookups of longer words then fail.
  • Checking isEnd in startsWith too. It then answers false for "ca", even though "car" and "cat" start with it. Only search needs the flag.

Cost

Every operation walks one node per letter of its input. So insert, search and startsWith each cost O(L), where L is the length of the word or prefix. That doesn't depend on how many words are stored. Building the trie from n words of average length L costs O(n · L) time. Memory is the downside. In the worst case every letter of every word is its own node, so the trie takes O(n · L) space. Each node also carries a whole Map. That can be several times the size of the words themselves. Shared prefixes are what bring it down.

In interviews

Say why a trie fits before you build one. For example: “I'll be asked about prefixes many times, so I'll pay O(total letters) once to build a trie, and then each query costs only its own length.” Write the node class first, then the walk. Point out the end-of-word flag out loud. Tries come up as “implement a trie” and as word search with wildcards. They also come up as autocomplete or search suggestions, and as finding many dictionary words in a grid. A common follow-up is “how much memory does this use?”.