Tries

A tree of characters where every path from the root spells a prefix, so word and prefix lookups cost O(length of the word).

IntermediateRead first: Binary Trees, Arrays & Hash Maps

How It Works

A trie (also called a prefix tree, and usually pronounced "try") stores strings one character per level. The root stands for the empty string and each edge adds one letter, so every node stands for the prefix spelled on the path down to it. Words that start the same way share the same path, and a flag on each node records whether a whole word ends there.

words: cat, car, card, dog

          (root)
          /    \
         c      d
         |      |
         a      o
        / \     |
       t*  r*   g*
           |
           d*

* marks a node where a word ends
cat, car and card share the path c, a. The flag on r is what makes car a word rather than just a prefix of card.

Inserting or looking up a word walks one node per character, so it costs O(L) for a word of length L, however many words the trie holds. A hash set can also check a whole word in O(L), but it can't answer does any word start with this prefix? without looking at every word. A trie walks the prefix, and the words below the node where it stops are exactly the words with that prefix.

Building and Searching a Trie

Step 1 of 15

root

  • Node just created
  • Path being followed
  • Lookup succeeds here
  • Lookup fails here

An empty trie is just the root, which stands for the empty string "". Each edge adds one letter, so every node stands for the prefix spelled on the way down to it.

All 15 steps as text
  1. An empty trie is just the root, which stands for the empty string "". Each edge adds one letter, so every node stands for the prefix spelled on the way down to it.
  2. Insert cat. The root has no c child, so create the node for c.
  3. c has no a child, so create the node for ca.
  4. ca has no t child, so create the node for cat. That's the last letter, so mark it (the second ring): a whole word ends here.
  5. Insert car. It shares ca with the words already stored, so follow those nodes instead of creating new ones.
  6. ca has no r child, so create the node for car. Mark it, because car ends here.
  7. Insert card. It shares car with the words already stored, so follow those nodes instead of creating new ones.
  8. car has no d child, so create the node for card. Mark it, because card ends here.
  9. Insert dog. The root has no d child, so create the node for d.
  10. d has no o child, so create the node for do.
  11. do has no g child, so create the node for dog. Mark it, because dog ends here.
  12. search("car") follows c, a and r to the node for car. It's marked, so a whole word ends there and search returns true.
  13. search("ca") follows c and a to the node for ca, but that node isn't marked: ca is only a prefix of car, card and cat, so search returns false.
  14. startsWith("ca") follows c and a to the node for ca. Any path at all means some word continues it, so it returns true: car, card and cat all sit below.
  15. search("cow"): c has no o child, so the walk falls off the trie. No stored word starts with co, so it returns false without looking further.

Each node needs a way to find its children. A fixed array of 26 slots, one per lowercase letter, is fast and simple: the child for ch lives at index ch - 'a'. A hash map from character to child saves memory when nodes have few children and works for any alphabet. The examples here use the 26-slot array, because every input is lowercase English letters.

  • Prefix queries. Autocomplete, startsWith, or counting the words under a prefix.
  • Pattern search. A wildcard such as . turns the walk into a depth-first search over every branch that could match.
  • Prefix matching along a word. Walk a word through the trie and stop at the first (or last) node where a stored word ends, as in Replace Words.
  • Many words at once. Build a trie of the dictionary, then search a grid or a stream for all of the words in one pass, as in Word Search II.

When to Reach for It

  • The problem is about prefixes: does any word start with this, or what's the shortest root of this word?
  • You check many strings against the same dictionary, one character at a time.
  • The feature is autocomplete, spell check or search as you type.
  • A wildcard such as . can stand for any single letter.
  • You search a grid or a stream for any of many dictionary words at once.

Complexity

OperationTimeSpace
Insert a word of length LO(L)O(L) new nodes at most
Search or startsWithO(L)O(1)
Wildcard searchO(L) without dots, up to O(N) with themO(L) recursion
Store words with N characters in totalO(N)O(N) nodes, 26 slots each

The Template

A Prefix Tree

Every operation walks down from the root one character at a time. insert creates any missing nodes along the way and marks the last one as the end of a word. search and startsWith share a walk helper that follows a string's path and returns the node where it ends, or null if the path runs out first.

The two lookups differ only in the end-of-word flag. After inserting "apple", the path for "app" exists, so startsWith("app") is true. But search("app") stays false until "app" is inserted too. That insert creates no new nodes. It only sets the flag on the second p.

Time O(L) per callSpace O(L) per insert

class TrieNode:
    def __init__(self) -> None:
        # One slot per lowercase letter. None means no stored word continues that way.
        self.children: list[TrieNode | None] = [None] * 26
        self.is_word = False  # Whether a stored word ends exactly here.


class Trie:
    """A prefix tree over lowercase words."""

    def __init__(self) -> None:
        self.root = TrieNode()

    def insert(self, word: str) -> None:
        node = self.root
        for ch in word:
            i = ord(ch) - ord("a")
            # Follow the shared prefix. Only create nodes where this word branches off.
            if node.children[i] is None:
                node.children[i] = TrieNode()
            node = node.children[i]
        node.is_word = True

    def search(self, word: str) -> bool:
        # The path has to exist and end on a node marked as a word.
        node = self._walk(word)
        return node is not None and node.is_word

    def starts_with(self, prefix: str) -> bool:
        # Any path at all means some stored word continues it.
        return self._walk(prefix) is not None

    def _walk(self, prefix: str) -> TrieNode | None:
        """The node at the end of `prefix`, or None if no stored word starts with it."""
        node = self.root
        for ch in prefix:
            node = node.children[ord(ch) - ord("a")]
            if node is None:
                return None  # Fell off the trie: nothing continues this way.
        return node


if __name__ == "__main__":
    trie = Trie()
    trie.insert("apple")
    print(trie.search("apple"))  # True
    print(trie.search("app"))  # False
    print(trie.starts_with("app"))  # True
    trie.insert("app")
    print(trie.search("app"))  # True
    print(trie.starts_with("apx"))  # False
class TrieNode {
  // One slot per lowercase letter. null means no stored word continues that way.
  children: (TrieNode | null)[] = new Array<TrieNode | null>(26).fill(null);
  isWord = false; // Whether a stored word ends exactly here.
}

// A prefix tree over lowercase words.
export class Trie {
  root = new TrieNode();

  insert(word: string): void {
    let node = this.root;
    for (const ch of word) {
      const i = ch.charCodeAt(0) - "a".charCodeAt(0);
      // Follow the shared prefix. Only create nodes where this word branches off.
      node = node.children[i] ??= new TrieNode();
    }
    node.isWord = true;
  }

  search(word: string): boolean {
    // The path has to exist and end on a node marked as a word.
    const node = this.walk(word);
    return node !== null && node.isWord;
  }

  startsWith(prefix: string): boolean {
    // Any path at all means some stored word continues it.
    return this.walk(prefix) !== null;
  }

  // The node at the end of `prefix`, or null if no stored word starts with it.
  private walk(prefix: string): TrieNode | null {
    let node: TrieNode | null = this.root;
    for (const ch of prefix) {
      node = node.children[ch.charCodeAt(0) - "a".charCodeAt(0)];
      if (node === null) return null; // Fell off the trie: nothing continues this way.
    }
    return node;
  }
}

const trie = new Trie();
trie.insert("apple");
console.log(trie.search("apple")); // true
console.log(trie.search("app")); // false
console.log(trie.startsWith("app")); // true
trie.insert("app");
console.log(trie.search("app")); // true
console.log(trie.startsWith("apx")); // false
package main

import "fmt"

// TrieNode is one node of a trie.
type TrieNode struct {
	// One slot per lowercase letter. nil means no stored word continues that way.
	children [26]*TrieNode
	isWord   bool // Whether a stored word ends exactly here.
}

// Trie is a prefix tree over lowercase words.
type Trie struct {
	root *TrieNode
}

func newTrie() *Trie {
	return &Trie{root: &TrieNode{}}
}

func (t *Trie) insert(word string) {
	node := t.root
	for _, ch := range word {
		i := ch - 'a'
		// Follow the shared prefix. Only create nodes where this word branches off.
		if node.children[i] == nil {
			node.children[i] = &TrieNode{}
		}
		node = node.children[i]
	}
	node.isWord = true
}

func (t *Trie) search(word string) bool {
	// The path has to exist and end on a node marked as a word.
	node := t.walk(word)
	return node != nil && node.isWord
}

func (t *Trie) startsWith(prefix string) bool {
	// Any path at all means some stored word continues it.
	return t.walk(prefix) != nil
}

// walk returns the node at the end of prefix, or nil if no stored word starts
// with it.
func (t *Trie) walk(prefix string) *TrieNode {
	node := t.root
	for _, ch := range prefix {
		node = node.children[ch-'a']
		if node == nil {
			return nil // Fell off the trie: nothing continues this way.
		}
	}
	return node
}

func main() {
	trie := newTrie()
	trie.insert("apple")
	fmt.Println(trie.search("apple"))   // true
	fmt.Println(trie.search("app"))     // false
	fmt.Println(trie.startsWith("app")) // true
	trie.insert("app")
	fmt.Println(trie.search("app"))     // true
	fmt.Println(trie.startsWith("apx")) // false
}
using System;

public class Solution
{
    public class TrieNode
    {
        // One slot per lowercase letter. null means no stored word continues that way.
        public TrieNode?[] Children = new TrieNode?[26];
        public bool IsWord; // Whether a stored word ends exactly here.
    }

    // A prefix tree over lowercase words.
    public class Trie
    {
        readonly TrieNode root = new TrieNode();

        public void Insert(string word)
        {
            var node = root;
            foreach (char ch in word)
            {
                int i = ch - 'a';
                // Follow the shared prefix. Only create nodes where this word branches off.
                node = node.Children[i] ??= new TrieNode();
            }
            node.IsWord = true;
        }

        public bool Search(string word)
        {
            // The path has to exist and end on a node marked as a word.
            var node = Walk(word);
            return node != null && node.IsWord;
        }

        public bool StartsWith(string prefix)
        {
            // Any path at all means some stored word continues it.
            return Walk(prefix) != null;
        }

        // The node at the end of prefix, or null if no stored word starts with it.
        TrieNode? Walk(string prefix)
        {
            TrieNode? node = root;
            foreach (char ch in prefix)
            {
                node = node.Children[ch - 'a'];
                if (node == null) return null; // Fell off the trie: nothing continues this way.
            }
            return node;
        }
    }

    public static void Main()
    {
        var trie = new Trie();
        trie.Insert("apple");
        Console.WriteLine(trie.Search("apple")); // True
        Console.WriteLine(trie.Search("app")); // False
        Console.WriteLine(trie.StartsWith("app")); // True
        trie.Insert("app");
        Console.WriteLine(trie.Search("app")); // True
        Console.WriteLine(trie.StartsWith("apx")); // False
    }
}
public class Solution {
    static class TrieNode {
        // One slot per lowercase letter. null means no stored word continues that way.
        TrieNode[] children = new TrieNode[26];
        boolean isWord; // Whether a stored word ends exactly here.
    }

    // A prefix tree over lowercase words.
    static class Trie {
        private final TrieNode root = new TrieNode();

        void insert(String word) {
            TrieNode node = root;
            for (char ch : word.toCharArray()) {
                int i = ch - 'a';
                // Follow the shared prefix. Only create nodes where this word branches off.
                if (node.children[i] == null) node.children[i] = new TrieNode();
                node = node.children[i];
            }
            node.isWord = true;
        }

        boolean search(String word) {
            // The path has to exist and end on a node marked as a word.
            TrieNode node = walk(word);
            return node != null && node.isWord;
        }

        boolean startsWith(String prefix) {
            // Any path at all means some stored word continues it.
            return walk(prefix) != null;
        }

        // The node at the end of prefix, or null if no stored word starts with it.
        private TrieNode walk(String prefix) {
            TrieNode node = root;
            for (char ch : prefix.toCharArray()) {
                node = node.children[ch - 'a'];
                if (node == null) return null; // Fell off the trie: nothing continues this way.
            }
            return node;
        }
    }

    public static void main(String[] args) {
        Trie trie = new Trie();
        trie.insert("apple");
        System.out.println(trie.search("apple")); // true
        System.out.println(trie.search("app")); // false
        System.out.println(trie.startsWith("app")); // true
        trie.insert("app");
        System.out.println(trie.search("app")); // true
        System.out.println(trie.startsWith("apx")); // false
    }
}

Worked Problems

Each solution stays folded until you open it, so you can try the problem first. Every one is a complete program in Python, TypeScript, Go, C# and Java, run and checked against its expected output.

Design Add and Search Words Data Structure

MEDIUMLeetCode 211

Problem

Design a word dictionary with two operations. addWord(word) stores a word. search(pattern) returns whether any stored word matches the pattern, where each . matches any one letter and every other character must match exactly. Words and patterns use lowercase letters.

Example: after adding "bad", "dad" and "mad", searching for "pad" returns false, while "bad", ".ad" and "b.." all return true.

Show Solution

Approach

addWord is the trie's insert. In search, letters work just as before: follow the one matching child, or fail if it's missing. A dot is where it branches. It could be any letter, so try every child that exists and succeed if any of them leads to a match. That turns the lookup into a depth-first search over the trie, steered by the pattern.

When the pattern runs out, the match only counts if a word ends at that node. That's why "b." fails even though "bad" is stored: the node for ba exists, but no word ends there.

A pattern with no dots costs O(L), like a normal lookup. Each dot can fan out to 26 branches, so dots make it slower, but there's a ceiling. A node sits at exactly one depth, and the search only reaches it at the matching position in the pattern, so one search visits each node at most once: O(N) for a trie with N nodes.

Time O(L) to add, up to O(N) to searchSpace O(L) recursion

class TrieNode:
    def __init__(self) -> None:
        # One slot per lowercase letter. None means no stored word continues that way.
        self.children: list[TrieNode | None] = [None] * 26
        self.is_word = False  # Whether a stored word ends exactly here.


class WordDictionary:
    """Stores words and finds them by pattern, where "." matches any one letter."""

    def __init__(self) -> None:
        self.root = TrieNode()

    def add_word(self, word: str) -> None:
        node = self.root
        for ch in word:
            i = ord(ch) - ord("a")
            if node.children[i] is None:
                node.children[i] = TrieNode()
            node = node.children[i]
        node.is_word = True

    def search(self, pattern: str) -> bool:
        return self._match(self.root, pattern, 0)

    def _match(self, node: TrieNode, pattern: str, i: int) -> bool:
        """Whether some stored word below `node` matches `pattern` from index `i` on."""
        # The pattern is used up, so it only matches if a word ends right here.
        if i == len(pattern):
            return node.is_word
        if pattern[i] == ".":
            # A dot can be any letter, so try every branch that exists.
            for branch in node.children:
                if branch is not None and self._match(branch, pattern, i + 1):
                    return True
            return False
        child = node.children[ord(pattern[i]) - ord("a")]
        return child is not None and self._match(child, pattern, i + 1)


if __name__ == "__main__":
    dictionary = WordDictionary()
    for word in ["bad", "dad", "mad"]:
        dictionary.add_word(word)
    print(dictionary.search("pad"))  # False
    print(dictionary.search("bad"))  # True
    print(dictionary.search(".ad"))  # True
    print(dictionary.search("b.."))  # True
    print(dictionary.search("b."))  # False
class TrieNode {
  // One slot per lowercase letter. null means no stored word continues that way.
  children: (TrieNode | null)[] = new Array<TrieNode | null>(26).fill(null);
  isWord = false; // Whether a stored word ends exactly here.
}

// Stores words and finds them by pattern, where "." matches any one letter.
export class WordDictionary {
  root = new TrieNode();

  addWord(word: string): void {
    let node = this.root;
    for (const ch of word) {
      const i = ch.charCodeAt(0) - "a".charCodeAt(0);
      node = node.children[i] ??= new TrieNode();
    }
    node.isWord = true;
  }

  search(pattern: string): boolean {
    return this.match(this.root, pattern, 0);
  }

  // Whether some stored word below `node` matches `pattern` from index `i` on.
  private match(node: TrieNode, pattern: string, i: number): boolean {
    // The pattern is used up, so it only matches if a word ends right here.
    if (i === pattern.length) return node.isWord;
    if (pattern[i] === ".") {
      // A dot can be any letter, so try every branch that exists.
      for (const branch of node.children) {
        if (branch !== null && this.match(branch, pattern, i + 1)) return true;
      }
      return false;
    }
    const child = node.children[pattern.charCodeAt(i) - "a".charCodeAt(0)];
    return child !== null && this.match(child, pattern, i + 1);
  }
}

const dictionary = new WordDictionary();
for (const word of ["bad", "dad", "mad"]) dictionary.addWord(word);
console.log(dictionary.search("pad")); // false
console.log(dictionary.search("bad")); // true
console.log(dictionary.search(".ad")); // true
console.log(dictionary.search("b..")); // true
console.log(dictionary.search("b.")); // false
package main

import "fmt"

// TrieNode is one node of a trie.
type TrieNode struct {
	// One slot per lowercase letter. nil means no stored word continues that way.
	children [26]*TrieNode
	isWord   bool // Whether a stored word ends exactly here.
}

// WordDictionary stores words and finds them by pattern, where "." matches
// any one letter.
type WordDictionary struct {
	root *TrieNode
}

func newWordDictionary() *WordDictionary {
	return &WordDictionary{root: &TrieNode{}}
}

func (d *WordDictionary) addWord(word string) {
	node := d.root
	for _, ch := range word {
		i := ch - 'a'
		if node.children[i] == nil {
			node.children[i] = &TrieNode{}
		}
		node = node.children[i]
	}
	node.isWord = true
}

func (d *WordDictionary) search(pattern string) bool {
	return d.match(d.root, pattern, 0)
}

// match reports whether some stored word below node matches pattern from
// index i on.
func (d *WordDictionary) match(node *TrieNode, pattern string, i int) bool {
	// The pattern is used up, so it only matches if a word ends right here.
	if i == len(pattern) {
		return node.isWord
	}
	if pattern[i] == '.' {
		// A dot can be any letter, so try every branch that exists.
		for _, branch := range node.children {
			if branch != nil && d.match(branch, pattern, i+1) {
				return true
			}
		}
		return false
	}
	child := node.children[pattern[i]-'a']
	return child != nil && d.match(child, pattern, i+1)
}

func main() {
	dictionary := newWordDictionary()
	for _, word := range []string{"bad", "dad", "mad"} {
		dictionary.addWord(word)
	}
	fmt.Println(dictionary.search("pad")) // false
	fmt.Println(dictionary.search("bad")) // true
	fmt.Println(dictionary.search(".ad")) // true
	fmt.Println(dictionary.search("b..")) // true
	fmt.Println(dictionary.search("b."))  // false
}
using System;

public class Solution
{
    public class TrieNode
    {
        // One slot per lowercase letter. null means no stored word continues that way.
        public TrieNode?[] Children = new TrieNode?[26];
        public bool IsWord; // Whether a stored word ends exactly here.
    }

    // Stores words and finds them by pattern, where "." matches any one letter.
    public class WordDictionary
    {
        readonly TrieNode root = new TrieNode();

        public void AddWord(string word)
        {
            var node = root;
            foreach (char ch in word)
            {
                int i = ch - 'a';
                node = node.Children[i] ??= new TrieNode();
            }
            node.IsWord = true;
        }

        public bool Search(string pattern) => Match(root, pattern, 0);

        // Whether some stored word below node matches pattern from index i on.
        bool Match(TrieNode node, string pattern, int i)
        {
            // The pattern is used up, so it only matches if a word ends right here.
            if (i == pattern.Length) return node.IsWord;
            if (pattern[i] == '.')
            {
                // A dot can be any letter, so try every branch that exists.
                foreach (var branch in node.Children)
                {
                    if (branch != null && Match(branch, pattern, i + 1)) return true;
                }
                return false;
            }
            var child = node.Children[pattern[i] - 'a'];
            return child != null && Match(child, pattern, i + 1);
        }
    }

    public static void Main()
    {
        var dictionary = new WordDictionary();
        foreach (var word in new[] { "bad", "dad", "mad" }) dictionary.AddWord(word);
        Console.WriteLine(dictionary.Search("pad")); // False
        Console.WriteLine(dictionary.Search("bad")); // True
        Console.WriteLine(dictionary.Search(".ad")); // True
        Console.WriteLine(dictionary.Search("b..")); // True
        Console.WriteLine(dictionary.Search("b.")); // False
    }
}
public class Solution {
    static class TrieNode {
        // One slot per lowercase letter. null means no stored word continues that way.
        TrieNode[] children = new TrieNode[26];
        boolean isWord; // Whether a stored word ends exactly here.
    }

    // Stores words and finds them by pattern, where "." matches any one letter.
    static class WordDictionary {
        private final TrieNode root = new TrieNode();

        void addWord(String word) {
            TrieNode node = root;
            for (char ch : word.toCharArray()) {
                int i = ch - 'a';
                if (node.children[i] == null) node.children[i] = new TrieNode();
                node = node.children[i];
            }
            node.isWord = true;
        }

        boolean search(String pattern) {
            return match(root, pattern, 0);
        }

        // Whether some stored word below node matches pattern from index i on.
        private boolean match(TrieNode node, String pattern, int i) {
            // The pattern is used up, so it only matches if a word ends right here.
            if (i == pattern.length()) return node.isWord;
            if (pattern.charAt(i) == '.') {
                // A dot can be any letter, so try every branch that exists.
                for (TrieNode branch : node.children) {
                    if (branch != null && match(branch, pattern, i + 1)) return true;
                }
                return false;
            }
            TrieNode child = node.children[pattern.charAt(i) - 'a'];
            return child != null && match(child, pattern, i + 1);
        }
    }

    public static void main(String[] args) {
        WordDictionary dictionary = new WordDictionary();
        for (String word : new String[] {"bad", "dad", "mad"}) dictionary.addWord(word);
        System.out.println(dictionary.search("pad")); // false
        System.out.println(dictionary.search("bad")); // true
        System.out.println(dictionary.search(".ad")); // true
        System.out.println(dictionary.search("b..")); // true
        System.out.println(dictionary.search("b.")); // false
    }
}

Replace Words

MEDIUMLeetCode 648

Problem

You're given a list of roots and a sentence of lowercase words separated by single spaces. Replace every word that starts with a root by that root. If several roots match, use the shortest. Words that don't start with any root stay as they are.

Example: with the roots ["cat", "bat", "rat"], the sentence "the cattle was rattled by the battery" becomes "the cat was rat by the bat".

Show Solution

Approach

Put every root in a trie. For each word, walk the trie along its letters and stop at the first node where a root ends. That's the shortest matching root, because the walk meets the word's prefixes in order of length. If the walk falls off the trie first, no root is a prefix of the word, so it stays.

Without a trie, you'd test every prefix of every word against a hash set of roots. Building and hashing a prefix costs up to O(L), so a word of length L costs O(L²). The trie walk checks all of a word's prefixes in one O(L) pass.

With D the total length of the roots and S the length of the sentence, building the trie takes O(D) and the walks take O(S). The trie holds at most D nodes, and splitting the sentence into words and joining the result take O(S) space.

Time O(D + S)Space O(D + S)

class TrieNode:
    def __init__(self) -> None:
        # One slot per lowercase letter. None means no stored word continues that way.
        self.children: list[TrieNode | None] = [None] * 26
        self.is_word = False  # Whether a stored root ends exactly here.


def insert(trie: TrieNode, word: str) -> None:
    """Adds `word` to the trie rooted at `trie`."""
    node = trie
    for ch in word:
        i = ord(ch) - ord("a")
        if node.children[i] is None:
            node.children[i] = TrieNode()
        node = node.children[i]
    node.is_word = True


def shortest_root(trie: TrieNode, word: str) -> str:
    """The shortest stored root that `word` starts with, or `word` itself if none does."""
    node = trie
    for i in range(len(word)):
        child = node.children[ord(word[i]) - ord("a")]
        if child is None:
            return word  # No root continues this way.
        # Prefixes come up shortest first, so the first root found is the answer.
        if child.is_word:
            return word[: i + 1]
        node = child
    return word  # The whole word is a path, but no root ends on it.


def replace_words(roots: list[str], sentence: str) -> str:
    """Replaces each word in `sentence` with the shortest root it starts with.
    Words that start with no root stay as they are."""
    trie = TrieNode()
    for root in roots:
        insert(trie, root)
    words = sentence.split(" ")
    for i in range(len(words)):
        words[i] = shortest_root(trie, words[i])
    return " ".join(words)


if __name__ == "__main__":
    print(replace_words(["cat", "bat", "rat"], "the cattle was rattled by the battery"))  # the cat was rat by the bat
    print(replace_words(["a", "b", "c"], "aadsfasf absbs bbab cadsfafs"))  # a a b c
    print(replace_words(["car", "ca", "dog"], "cart cab dot"))  # ca ca dot
class TrieNode {
  // One slot per lowercase letter. null means no stored word continues that way.
  children: (TrieNode | null)[] = new Array<TrieNode | null>(26).fill(null);
  isWord = false; // Whether a stored root ends exactly here.
}

// Adds `word` to the trie rooted at `trie`.
function insert(trie: TrieNode, word: string): void {
  let node = trie;
  for (const ch of word) {
    const i = ch.charCodeAt(0) - "a".charCodeAt(0);
    node = node.children[i] ??= new TrieNode();
  }
  node.isWord = true;
}

// The shortest stored root that `word` starts with, or `word` itself if none does.
function shortestRoot(trie: TrieNode, word: string): string {
  let node = trie;
  for (let i = 0; i < word.length; i++) {
    const child = node.children[word.charCodeAt(i) - "a".charCodeAt(0)];
    if (child === null) return word; // No root continues this way.
    // Prefixes come up shortest first, so the first root found is the answer.
    if (child.isWord) return word.slice(0, i + 1);
    node = child;
  }
  return word; // The whole word is a path, but no root ends on it.
}

// Replaces each word in `sentence` with the shortest root it starts with.
// Words that start with no root stay as they are.
export function replaceWords(roots: string[], sentence: string): string {
  const trie = new TrieNode();
  for (const root of roots) insert(trie, root);
  const words = sentence.split(" ");
  for (let i = 0; i < words.length; i++) words[i] = shortestRoot(trie, words[i]);
  return words.join(" ");
}

console.log(replaceWords(["cat", "bat", "rat"], "the cattle was rattled by the battery")); // the cat was rat by the bat
console.log(replaceWords(["a", "b", "c"], "aadsfasf absbs bbab cadsfafs")); // a a b c
console.log(replaceWords(["car", "ca", "dog"], "cart cab dot")); // ca ca dot
package main

import (
	"fmt"
	"strings"
)

// TrieNode is one node of a trie.
type TrieNode struct {
	// One slot per lowercase letter. nil means no stored word continues that way.
	children [26]*TrieNode
	isWord   bool // Whether a stored root ends exactly here.
}

// insert adds word to the trie rooted at trie.
func insert(trie *TrieNode, word string) {
	node := trie
	for _, ch := range word {
		i := ch - 'a'
		if node.children[i] == nil {
			node.children[i] = &TrieNode{}
		}
		node = node.children[i]
	}
	node.isWord = true
}

// shortestRoot returns the shortest stored root that word starts with, or
// word itself if none does.
func shortestRoot(trie *TrieNode, word string) string {
	node := trie
	for i := 0; i < len(word); i++ {
		child := node.children[word[i]-'a']
		if child == nil {
			return word // No root continues this way.
		}
		// Prefixes come up shortest first, so the first root found is the answer.
		if child.isWord {
			return word[:i+1]
		}
		node = child
	}
	return word // The whole word is a path, but no root ends on it.
}

// replaceWords replaces each word in sentence with the shortest root it
// starts with. Words that start with no root stay as they are.
func replaceWords(roots []string, sentence string) string {
	trie := &TrieNode{}
	for _, root := range roots {
		insert(trie, root)
	}
	words := strings.Split(sentence, " ")
	for i := range words {
		words[i] = shortestRoot(trie, words[i])
	}
	return strings.Join(words, " ")
}

func main() {
	fmt.Println(replaceWords([]string{"cat", "bat", "rat"}, "the cattle was rattled by the battery")) // the cat was rat by the bat
	fmt.Println(replaceWords([]string{"a", "b", "c"}, "aadsfasf absbs bbab cadsfafs"))                // a a b c
	fmt.Println(replaceWords([]string{"car", "ca", "dog"}, "cart cab dot"))                           // ca ca dot
}
using System;

public class Solution
{
    public class TrieNode
    {
        // One slot per lowercase letter. null means no stored word continues that way.
        public TrieNode?[] Children = new TrieNode?[26];
        public bool IsWord; // Whether a stored root ends exactly here.
    }

    // Adds word to the trie rooted at trie.
    static void Insert(TrieNode trie, string word)
    {
        var node = trie;
        foreach (char ch in word)
        {
            int i = ch - 'a';
            node = node.Children[i] ??= new TrieNode();
        }
        node.IsWord = true;
    }

    // The shortest stored root that word starts with, or word itself if none does.
    static string ShortestRoot(TrieNode trie, string word)
    {
        var node = trie;
        for (int i = 0; i < word.Length; i++)
        {
            var child = node.Children[word[i] - 'a'];
            if (child == null) return word; // No root continues this way.
            // Prefixes come up shortest first, so the first root found is the answer.
            if (child.IsWord) return word.Substring(0, i + 1);
            node = child;
        }
        return word; // The whole word is a path, but no root ends on it.
    }

    // Replaces each word in sentence with the shortest root it starts with.
    // Words that start with no root stay as they are.
    public static string ReplaceWords(string[] roots, string sentence)
    {
        var trie = new TrieNode();
        foreach (var root in roots) Insert(trie, root);
        var words = sentence.Split(' ');
        for (int i = 0; i < words.Length; i++) words[i] = ShortestRoot(trie, words[i]);
        return string.Join(" ", words);
    }

    public static void Main()
    {
        Console.WriteLine(ReplaceWords(new[] { "cat", "bat", "rat" }, "the cattle was rattled by the battery")); // the cat was rat by the bat
        Console.WriteLine(ReplaceWords(new[] { "a", "b", "c" }, "aadsfasf absbs bbab cadsfafs")); // a a b c
        Console.WriteLine(ReplaceWords(new[] { "car", "ca", "dog" }, "cart cab dot")); // ca ca dot
    }
}
public class Solution {
    static class TrieNode {
        // One slot per lowercase letter. null means no stored word continues that way.
        TrieNode[] children = new TrieNode[26];
        boolean isWord; // Whether a stored root ends exactly here.
    }

    // Adds word to the trie rooted at trie.
    static void insert(TrieNode trie, String word) {
        TrieNode node = trie;
        for (char ch : word.toCharArray()) {
            int i = ch - 'a';
            if (node.children[i] == null) node.children[i] = new TrieNode();
            node = node.children[i];
        }
        node.isWord = true;
    }

    // The shortest stored root that word starts with, or word itself if none does.
    static String shortestRoot(TrieNode trie, String word) {
        TrieNode node = trie;
        for (int i = 0; i < word.length(); i++) {
            TrieNode child = node.children[word.charAt(i) - 'a'];
            if (child == null) return word; // No root continues this way.
            // Prefixes come up shortest first, so the first root found is the answer.
            if (child.isWord) return word.substring(0, i + 1);
            node = child;
        }
        return word; // The whole word is a path, but no root ends on it.
    }

    // Replaces each word in sentence with the shortest root it starts with.
    // Words that start with no root stay as they are.
    static String replaceWords(String[] roots, String sentence) {
        TrieNode trie = new TrieNode();
        for (String root : roots) insert(trie, root);
        String[] words = sentence.split(" ");
        for (int i = 0; i < words.length; i++) words[i] = shortestRoot(trie, words[i]);
        return String.join(" ", words);
    }

    public static void main(String[] args) {
        System.out.println(replaceWords(new String[] {"cat", "bat", "rat"}, "the cattle was rattled by the battery")); // the cat was rat by the bat
        System.out.println(replaceWords(new String[] {"a", "b", "c"}, "aadsfasf absbs bbab cadsfafs")); // a a b c
        System.out.println(replaceWords(new String[] {"car", "ca", "dog"}, "cart cab dot")); // ca ca dot
    }
}

Common Pitfalls

  • Treating a path as a word. After inserting "apple", the path for "app" exists, so search must check the end-of-word flag.
  • Forgetting the flag when a new word is a prefix of an old one. Inserting "app" after "apple" creates no nodes, but it still has to mark the second p.
  • Using a 26-slot array when the input can hold capitals, digits or other characters. ch - 'a' then lands outside the array. Check the alphabet, or use a hash map.
  • Following only one child at a wildcard. A dot has to try every branch, and back up when one dead-ends.
  • Building a trie when a hash set would do. If you only ever check whole words, a set is simpler and lighter. Reach for a trie when prefixes matter.

Practice

More problems to try on your own.