Part VI · More Patterns Pattern 18 4 problems

Trie (Prefix Tree)

A tree where each edge is one character. Words that share a prefix share a path. Insert and search cost O(L) for a word of length L, no matter how many words are stored.

A HashSet<string> answers “is this exact word here?” in O(L). It cannot answer “does any word start with app?” without scanning every word. A trie answers both with the same short walk. That one extra question, prefix lookup, is why the structure exists. The .NET base library has no trie, so you build one. The Python version of this page is here.

Contents

  1. When to use
  2. Core idea
  3. The templates
  4. Common mistakes
  5. Implement Trie
  6. Design Add and Search Words
  7. Word Search II
  8. Replace Words
  9. Recap

When to use

The trigger. Many words, and questions about their prefixes. “Starts with”, “autocomplete”, “shortest root”, or a search that should stop the moment no word can match. If every question is about whole words only, a HashSet<string> is simpler.

Core idea

Each node stands for a prefix: the letters on the path from the root to it. The root is the empty prefix. A node holds a Dictionary<char, Node> from the next letter to the child node. It also holds an end flag that says “a word stops here”. To insert or search, walk one letter at a time. Each step is one dictionary lookup, so a word of length L costs O(L).

Insert app, apple and bat. The path a → p → p is stored once and used by both app and apple:

The end flag is what tells app (a word) apart from ap (only a prefix). Both nodes exist. Only one is flagged.

a p p l e b a t root shared by app and apple ap: no flag app apple bat end flag: a word stops here prefix only, no word
Figure 18.1 — app and apple share three nodes. Only the end flag tells a word from a bare prefix.

Reading the figure. Each circle is one node, and the path from the root spells its prefix. The amber edges are the a → p → p path that both app and apple use. Green ringed nodes have IsEnd set. Notice the first p: the prefix ap exists but is not a word.

Cost

The templates

Template A — Dictionary-of-children node with an end flag
/// <summary>One node per prefix. Words that share a prefix share nodes.</summary>
public sealed class TrieNode
{
    // Children: next letter -> child node. A Dictionary, so any alphabet works.
    public Dictionary<char, TrieNode> Children { get; } = [];

    // IsEnd: true when an inserted word stops exactly here.
    // "app" and "apple" share nodes, so this flag tells them apart.
    public bool IsEnd { get; set; }
}

public static class TrieTemplate
{
    /// <summary>Add a word to the trie in O(word.Length).</summary>
    /// <param name="root">The root node. It stands for the empty prefix.</param>
    /// <param name="word">The word to add.</param>
    /// <example><c>InsertWord(root, "app")</c> makes <c>WalkPrefix(root, "ap")</c> non-null.
    /// </example>
    public static void InsertWord(TrieNode root, string word)
    {
        var node = root;

        // ch is the next letter of word.
        // Invariant: node is the end of the path spelling the letters before ch.
        foreach (char ch in word)
        {
            // No child for ch yet: create it. Later words with this prefix reuse it.
            // TryGetValue tests and fetches in one hash lookup.
            if (!node.Children.TryGetValue(ch, out var child))
            {
                child = new TrieNode();
                node.Children[ch] = child;
            }
            node = child;               // step down one letter
        }

        node.IsEnd = true;              // flag the last node: a word stops here
    }

    /// <summary>Node at the end of prefix, or null if no word starts with it.</summary>
    /// <param name="root">The root node.</param>
    /// <param name="prefix">The letters to walk.</param>
    /// <returns>The node that spells prefix, or null.</returns>
    /// <example><c>WalkPrefix(root, "ax")</c> returns <c>null</c> when no word starts
    /// with "ax".</example>
    public static TrieNode? WalkPrefix(TrieNode root, string prefix)
    {
        var node = root;

        // Invariant: node spells the letters of prefix read so far.
        foreach (char ch in prefix)
        {
            // Missing child: no stored word continues this way. Stop at once.
            if (!node.Children.TryGetValue(ch, out var child))
                return null;
            node = child;               // step down one letter
        }

        return node;
    }
}
root a p p t app apt (new) InsertWord(root, "apt") a: child exists, step down p: child exists, step down t: no child, create it word done: set IsEnd = true WalkPrefix(root, "ax"): no x under a, return null
Figure 18.2 — Insert reuses every node that already exists and creates only what is missing.

Reading the figure. Blue nodes already existed, so the walk just steps down. The amber node is new, made because t had no child yet. Green rings mark IsEnd. The amber arrows are the path the loop walks.

Every trie question is built from these two walks. Search(word) is WalkPrefix plus a check of IsEnd. StartsWith(prefix) is WalkPrefix plus a check for null. Use TryGetValue, not ContainsKey and then the indexer. The pair costs two hash lookups per letter.

If the input is lowercase a to z only, an array of 26 children is faster. It has no hashing and no per-node dictionary. The ??= operator creates the child on first use in one line:

// Lowercase only: 26 slots, one per letter 'a'..'z'.
public sealed class LowerNode
{
    public LowerNode?[] Next = new LowerNode?[26];  // 26: letters 'a' to 'z'
    public bool IsEnd;
}
// Step: ch - 'a' maps 'a'..'z' to slots 0..25.
node = node.Next[ch - 'a'] ??= new LowerNode();
Template B — DFS that walks the trie in step
public static class TrieGuidedDfs
{
    /// <summary>Explore something else, moving down the trie one letter per step.</summary>
    /// <typeparam name="TPos">A search position. Grid: (row, col). Wildcard: an index.
    /// </typeparam>
    /// <param name="node">The trie node for the letters read so far.</param>
    /// <param name="position">Where this step reads its letter.</param>
    /// <param name="letterAt">The letter at a position.
    /// Word Search II: board[r][c]. Wildcard search: word[i].</param>
    /// <param name="nextPositions">Where the search may go next.
    /// Word Search II: the 4 neighbour cells. Wildcard: the next index, i + 1.</param>
    /// <param name="record">What to do on a match.
    /// Word Search II: add the word stored on the node.</param>
    /// <example>Walking "cat" by index with a trie holding "cat" records one node.</example>
    public static void Run<TPos>(
        TrieNode node,
        TPos position,
        Func<TPos, char> letterAt,
        Func<TPos, IEnumerable<TPos>> nextPositions,
        Action<TrieNode> record)
    {
        char ch = letterAt(position);

        // Prune: no stored word continues with ch, so nothing below can match.
        if (!node.Children.TryGetValue(ch, out var child))
            return;
        // The trie moves down in step with the search.

        // A word ends here: report it.
        if (child.IsEnd)
            record(child);

        // nxt is one place the search may go next. Each call explores it fully.
        foreach (var nxt in nextPositions(position))
            Run(child, nxt, letterAt, nextPositions, record);
    }
}
c d a o t r g x z Reads c, a, t: each child exists. t is flagged, so record the word. Reads c, a, x: no x under a. Prune. Nothing below can match. Reads z first: no z under root. The search stops in one step.
Figure 18.3 — The trie lets the search go only where some stored word could still match.

Reading the figure. The trie holds cat, car and dog. Green arrows are a search that matches, letter by letter, and ends on a flagged node. Red dashed nodes are letters with no child in the trie. The search returns right there, so it never explores anything below them.

The trie is a pruning oracle. The search only goes where some word still could match. Without it, Word Search II tries every path in the grid once per word. In a real solution, write the three delegates inline as plain code. They are parameters here only to show the shape.

Common mistakes

The problems

1. Implement Trie Medium

Problem

Build a class with three methods. Insert(word) stores a word. Search(word) returns whether that exact word was stored. StartsWith(prefix) returns whether any stored word begins with prefix.

Approach

Solution

/// <summary>A trie node: children by letter, plus an end-of-word flag.</summary>
public sealed class CharNode
{
    // Children: next letter -> child node.
    public Dictionary<char, CharNode> Children { get; } = [];

    // IsEnd: true when a stored word stops at this node.
    public bool IsEnd { get; set; }
}

/// <summary>Prefix tree with insert, exact search and prefix search.
/// Each method costs O(L) for a string of length L.</summary>
public sealed class Trie
{
    // The root holds no letter. It stands for the empty prefix "".
    private readonly CharNode _root = new();

    /// <summary>Store a word.</summary>
    /// <param name="word">The word to add.</param>
    /// <example><c>trie.Insert("apple")</c> then <c>trie.Search("apple")</c> is true.
    /// </example>
    public void Insert(string word)
    {
        var node = _root;
        // Invariant: node spells the letters of word read so far.
        foreach (char ch in word)
        {
            // Create the child on first use. Shared prefixes reuse it later.
            if (!node.Children.TryGetValue(ch, out var child))
            {
                child = new CharNode();
                node.Children[ch] = child;
            }
            node = child;                   // step down one letter
        }
        node.IsEnd = true;                  // a word stops here
    }

    /// <summary>Node reached by spelling text from the root, or null.</summary>
    private CharNode? Find(string text)
    {
        var node = _root;
        // Invariant: node spells the letters of text read so far.
        foreach (char ch in text)
        {
            // Missing child: no stored word has this prefix.
            if (!node.Children.TryGetValue(ch, out var child))
                return null;
            node = child;                   // step down one letter
        }
        return node;
    }

    /// <summary>Whether this exact word was inserted.</summary>
    /// <param name="word">The exact word to look for.</param>
    /// <returns>True only if word was stored, not just a longer word starting with it.
    /// </returns>
    /// <example>After <c>Insert("apple")</c>, <c>Search("app")</c> is false.</example>
    public bool Search(string word)
    {
        // The path must exist AND end on a flagged node.
        return Find(word) is { IsEnd: true };
    }

    /// <summary>Whether any stored word begins with prefix.</summary>
    /// <param name="prefix">The prefix to look for.</param>
    /// <returns>True if the path for prefix exists.</returns>
    /// <example>After <c>Insert("apple")</c>, <c>StartsWith("app")</c> is true.</example>
    public bool StartsWith(string prefix)
    {
        // The path existing is enough. No end flag needed.
        return Find(prefix) is not null;
    }
}

Walkthrough

Insert("apple") a p p l e 5 nodes, only e flagged Search("app") a p p l e Search false, StartsWith true Insert("app") a p p l e now Search("app") is true no flag flag set
Figure 18.4 — Search and StartsWith walk the same path. Only the end flag decides Search.

Reading the figure. Each column is the same trie at a later moment. Amber nodes are the path the call walks. Green ringed nodes have IsEnd set. In the middle column the walk succeeds but the last node has no flag, so Search says no. On the right, Insert("app") creates nothing and only sets the flag.

TimeO(L) per callSpaceO(total letters inserted)

Edge cases to raise

Say this out loud: “Each node is a prefix. Letters live on the edges as dictionary keys. Search and StartsWith share one walk. Search also checks the end flag, because a prefix existing does not mean the word was inserted.”

2. Design Add and Search Words Medium

Problem

Build a class with AddWord(word) and Search(pattern). The search pattern may contain ., which matches any single letter. Return whether any added word matches the whole pattern.

Approach

Solution

/// <summary>A trie node for wildcard search.</summary>
public sealed class WildNode
{
    // Children: next letter -> child node.
    public Dictionary<char, WildNode> Children { get; } = [];

    // IsEnd: true when an added word stops at this node.
    public bool IsEnd { get; set; }
}

/// <summary>Word store whose search treats '.' as any single letter.</summary>
public sealed class WordDictionary
{
    // The root stands for the empty prefix "".
    private readonly WildNode _root = new();

    /// <summary>Store a word.</summary>
    /// <param name="word">Letters only, no wildcards.</param>
    /// <example><c>words.AddWord("bad")</c> then <c>words.Search(".ad")</c> is true.
    /// </example>
    public void AddWord(string word)
    {
        var node = _root;
        // Invariant: node spells the letters of word read so far.
        foreach (char ch in word)
        {
            // Create the child on first use.
            if (!node.Children.TryGetValue(ch, out var child))
            {
                child = new WildNode();
                node.Children[ch] = child;
            }
            node = child;                   // step down one letter
        }
        node.IsEnd = true;                  // a word stops here
    }

    /// <summary>Whether some stored word matches the pattern in full.</summary>
    /// <param name="pattern">Letters and '.', where '.' matches any one letter.</param>
    /// <returns>True if at least one stored word matches.</returns>
    /// <example>With "bad", "dad", "mad" stored, <c>Search("b..")</c> is true and
    /// <c>Search("b.")</c> is false.</example>
    public bool Search(string pattern)
    {
        // 0: start matching at the first symbol, from the root.
        return Match(_root, pattern, 0);
    }

    /// <summary>Can the subtree under node match pattern[i..]?</summary>
    private static bool Match(WildNode node, string pattern, int i)
    {
        // i == pattern.Length: every symbol matched. A word must end here.
        if (i == pattern.Length)
            return node.IsEnd;

        char ch = pattern[i];
        if (ch == '.')
        {
            // '.': any one letter, so try every child.
            // child is one letter some stored word uses at this depth.
            foreach (var child in node.Children.Values)
            {
                // i + 1: the next symbol. Return at the first branch that matches.
                if (Match(child, pattern, i + 1))
                    return true;
            }
            return false;
        }

        // A plain letter: only one child can match it.
        // i + 1: move on to the next symbol of the pattern.
        return node.Children.TryGetValue(ch, out var next) && Match(next, pattern, i + 1);
    }
}

Walkthrough

Stored: bad, dad, mad. Search .ad:

pattern . a d root b a d d a d m a d b, a, d all match. d is flagged: True. d branch: never tried m branch: never tried the loop stops at the first true
Figure 18.5 — A dot fans out to every child, and the first full match ends the search.

Reading the figure. The violet letters on top are the pattern, one column per symbol. The dot could follow b, d or m. Green is the branch that was tried and matched to a flagged node. Grey dashed branches were never visited, because the loop returned on the first true. Dictionary order is not guaranteed, so in a real run the first child tried might be d or m. The answer is the same.

AddO(L)SearchO(L) with no dots, up to O(26d × L) with d dots

Edge cases to raise

Say this out loud: “A plain letter follows one child. A dot tries every child, so search becomes a DFS. The trie keeps it cheap, because I only branch into letters that some word actually uses.”

3. Word Search II Hard

Problem

Given a grid of letters and a list of words, return every word that can be spelled by a path of adjacent cells (up, down, left, right). A path may not use the same cell twice.

Approach

Solution

/// <summary>A trie node that stores the whole word at its end.</summary>
public sealed class GridNode
{
    // Children: next letter -> child node.
    public Dictionary<char, GridNode> Children { get; } = [];

    // Word: the full word ending here, or null if no word ends here.
    // Storing the word, not a flag, means the DFS never rebuilds the string.
    public string? Word { get; set; }
}

public static class WordSearchII
{
    // The 4 neighbours as (row step, col step): down, up, right, left.
    // 1 and -1 are one step. 0 means no move on that axis.
    private static readonly (int Dr, int Dc)[] Steps = [(1, 0), (-1, 0), (0, 1), (0, -1)];

    // '#': marks a used cell. No word contains it, so the trie never has a '#' child.
    private const char Used = '#';

    /// <summary>Every word from words that can be traced on the board.</summary>
    /// <param name="board">Grid of single letters. It is changed during the search and
    /// restored before returning.</param>
    /// <param name="words">Candidate words.</param>
    /// <returns>The words found, sorted in ordinal order, each listed once.</returns>
    /// <example><c>FindWords(grid, ["oath", "pea", "eat", "rain"])</c> returns
    /// <c>["eat", "oath"]</c> for the 4 x 4 example grid.</example>
    public static List<string> FindWords(char[][] board, string[] words)
    {
        // Empty grid, or a grid with an empty first row: nothing can be spelled.
        // 0: no rows, or no columns.
        if (board.Length == 0 || board[0].Length == 0)
            return [];

        // Build one trie holding every word.
        var root = new GridNode();
        foreach (string word in words)
        {
            var node = root;
            // Invariant: node spells the letters of word read so far.
            foreach (char ch in word)
            {
                if (!node.Children.TryGetValue(ch, out var child))
                {
                    child = new GridNode();
                    node.Children[ch] = child;
                }
                node = child;                 // step down one letter
            }
            node.Word = word;                 // the end node remembers the word
        }

        var found = new List<string>();

        // Try every cell as the first letter.
        // Invariant: found holds every word that starts at a cell already tried.
        for (int r = 0; r < board.Length; r++)
            for (int c = 0; c < board[r].Length; c++)
                Dfs(board, r, c, root, found);

        // Ordinal: plain char-code order, the same on every machine and culture.
        found.Sort(StringComparer.Ordinal);
        return found;
    }

    /// <summary>Extend the path into cell (r, c), one level below parent.</summary>
    private static void Dfs(char[][] board, int r, int c, GridNode parent, List<string> found)
    {
        char ch = board[r][c];
        // No child: no word has this prefix. Also catches Used, a cell on the path.
        if (!parent.Children.TryGetValue(ch, out var node))
            return;

        // A word ends here. Report it, then clear it so it is reported once.
        if (node.Word is not null)
        {
            found.Add(node.Word);
            node.Word = null;                 // null: this word is done
        }

        board[r][c] = Used;                   // mark used so the path cannot reuse it

        // (dr, dc) is one of the 4 neighbour steps.
        foreach (var (dr, dc) in Steps)
        {
            int nr = r + dr, nc = c + dc;
            // 0 <= nr < rows and 0 <= nc < cols: stay inside the grid.
            if (nr >= 0 && nr < board.Length && nc >= 0 && nc < board[nr].Length)
                Dfs(board, nr, nc, node, found);
        }

        board[r][c] = ch;                     // backtrack: give the cell back

        // Prune: a leaf with no word left can never match again. Cut it off
        // so later starts do not walk into it. 0: no children left.
        if (node.Children.Count == 0 && node.Word is null)
            parent.Children.Remove(ch);
    }
}

Walkthrough

Words oath, pea, eat, rain on the grid in the example:

o a a n e t a e i h k r i f l v board o a t h found p e a no p on grid e a t found r a i n no a near r root
Figure 18.6 — One trie-guided DFS per cell finds oath and eat and gives up on pea and rain early.

Reading the figure. On the left, green cells and arrows trace oath. Violet traces eat, which reuses the same t cell. On the right is the trie of all four words. Ringed nodes are words found. Dashed nodes are never reached, because the grid has no next letter to match. Every other start cell fails at the root in one step.

TimeO(R × C × 4 × 3L-1) worst caseSpaceO(total letters in words)

L is the longest word. The first step has 4 directions, then 3, since you never step back onto the cell you came from. Pruning makes real runs far faster than this bound.

Edge cases to raise

Say this out loud: “One trie for all the words, one DFS from each cell, and the trie moves down with the path. If the letters so far are no word’s prefix, I stop. I clear each word once found, and I cut off empty leaves so the trie shrinks as I go.”

4. Replace Words Medium

Problem

Given a dictionary of roots and a sentence, replace every word with the shortest root that is a prefix of it. Words with no matching root stay as they are.

Approach

Solution

/// <summary>A trie node for dictionary roots.</summary>
public sealed class RootNode
{
    // Children: next letter -> child node.
    public Dictionary<char, RootNode> Children { get; } = [];

    // IsEnd: true when a root stops at this node.
    public bool IsEnd { get; set; }
}

public static class ReplaceWords
{
    /// <summary>Replace each word with the shortest dictionary root it starts with.</summary>
    /// <param name="dictionary">Root words.</param>
    /// <param name="sentence">Words separated by spaces.</param>
    /// <returns>The sentence with every word replaced by its shortest root, if any.
    /// </returns>
    /// <example><c>Replace(["cat", "bat", "rat"], "the cattle was rattled")</c> returns
    /// <c>"the cat was rat"</c>.</example>
    public static string Replace(string[] dictionary, string sentence)
    {
        // Build the trie of roots.
        var root = new RootNode();
        foreach (string stem in dictionary)
        {
            var node = root;
            // Invariant: node spells the letters of stem read so far.
            foreach (char ch in stem)
            {
                if (!node.Children.TryGetValue(ch, out var child))
                {
                    child = new RootNode();
                    node.Children[ch] = child;
                }
                node = child;                 // step down one letter
            }
            node.IsEnd = true;                // a root stops here
        }

        // RemoveEmptyEntries: runs of spaces do not make empty words.
        string[] words = sentence.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        var sb = new System.Text.StringBuilder();
        // w is the index of the next word. Invariant: sb holds words[0..w) rewritten.
        for (int w = 0; w < words.Length; w++)
        {
            // 0: no space before the first word.
            if (w > 0)
                sb.Append(' ');
            sb.Append(ShortestRoot(root, words[w]));
        }
        return sb.ToString();
    }

    /// <summary>The shortest root that prefixes word, or word itself.</summary>
    private static string ShortestRoot(RootNode root, string word)
    {
        var node = root;
        // i is the index of the next letter.
        // Invariant: no root is a prefix of word[..i], or we would have returned.
        for (int i = 0; i < word.Length; i++)
        {
            // No child: no root continues this way. Keep the word.
            if (!node.Children.TryGetValue(word[i], out var child))
                return word;
            node = child;                     // step down one letter
            // First flagged node on the walk: the shortest matching root.
            if (node.IsEnd)
                return word[..(i + 1)];       // + 1: range end is exclusive, keep word[i]
        }
        // The word ran out first: it is a prefix of a root, not the reverse.
        return word;
    }
}

Walkthrough

Roots cat, bat, rat. Sentence the cattle was rattled:

root c a t b a t r a t word result the the no t child cattle cat flag after 3 was was no w child rattled rat flag after 3
Figure 18.7 — The first flagged node on a word's path is its shortest root.

Reading the figure. The trie holds the roots. Amber nodes are the paths that cattle and rattled walk. Each walk stops at the first green ringed node, so the rest of the word is never read. On the right, green results were replaced. Grey ones had no matching first letter and stay as they are.

TimeO(total letters in roots and sentence)SpaceO(total letters in roots)
The easy cousin: Longest Common Prefix. Insert every word, then walk down from the root while the node has exactly one child and is not a word end. The letters walked are the answer. In an interview the plain scan is shorter: compare the words column by column and stop at the first mismatch. Offer the trie version only if asked, or if many queries follow.

Edge cases to raise

Say this out loud: “All roots go in a trie. For each word I walk down and stop at the first end flag, which is the shortest root. Each word costs only its own length, however many roots there are.”

Recap

The six things to carry forward

Where this goes next

A trie splits strings letter by letter. Pattern 19, Bit Manipulation, splits numbers bit by bit. The two meet in Maximum XOR of Two Numbers, which stores each number in a trie of its bits.


← 17 — Greedy 19 — Bit Manipulation →