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.
HashSet<string> is simpler.. that matches any one letter. The trie lets you branch only where words actually exist.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:
a → p → p (end: “app”) → l → e (end: “apple”)b → a → t (end: “bat”)The end flag is what tells app (a word) apart from ap (only a prefix). Both nodes exist. Only one is flagged.
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.
Dictionary. That is about 100 bytes or more per node. A trie of a million letters puts real pressure on the GC./// <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;
}
}
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();
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);
}
}
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.
Search("app") returns true after inserting only apple. The flag is the difference between a word and a prefix.ch - 'a' throws IndexOutOfRangeException on any other char. The dictionary works everywhere. Say which you chose and why.ContainsKey then Children[ch] hashes twice. TryGetValue does it once.StringBuilder.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.
Search and StartsWith share one private helper that walks a string and returns the node, or null.Search also needs the end flag on the last node. The property pattern is { IsEnd: true } checks “not null and flagged” in one test./// <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;
}
}
Insert("apple"): creates 5 nodes, a p p l e. Flags the e node.Search("app"): walks a p p. The node exists but is not flagged. false.StartsWith("app"): same walk, the node exists. true.Insert("app"): walks the 3 existing nodes, creates nothing, flags the second p.Search("app"): now flagged. true.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.
Insert("") flags the root. StartsWith("") is always true. Ask whether empty words can occur.null word: with nullable reference types on, the signature says string, not string?. Say you would add ArgumentNullException.ThrowIfNull(word) in production code.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.
., try every child. That turns the walk into a DFS. Return true as soon as one branch matches.foreach with an early return beats LINQ Any here. It allocates no closure and reads the same./// <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);
}
}
Stored: bad, dad, mad. Search .ad:
.. Try children b, d, m in turn.b: symbol 1 a exists. Symbol 2 d exists.i == 3, the end of the pattern. The d node is flagged. The loop returns true without trying d or m.b. reaches the end at the a node, which is not flagged. false.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.
...: matches any stored word of length 3.false.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.
char[][]. It works with collection expressions, and each row is a plain array you can change in place./// <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);
}
}
Words oath, pea, eat, rain on the grid in the example:
o, p, e, r.o. The DFS follows o → a → t → h through (0, 1), (1, 1), (2, 1). It finds oath, clears it, and prunes the now empty h leaf, then t, a, o on the way back.a. No root child a, so it stops in one step. Most cells end this way.e. The DFS follows e → a → t through (1, 2), (1, 1). It finds eat.pea has no p on the grid. rain has r at (2, 3) but no a next to it. Result: ["eat", "oath"].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.
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.
eat and eats: the first is reported and the DFS keeps going for the second. The node is only pruned when it has no children left.'#' is safe: no word contains it, so TryGetValue('#', ...) always fails. Ask if the alphabet could include it.StringComparer.Ordinal sorts by char code. The default comparer is culture-aware and can order some strings differently.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.
StringBuilder, not repeated +./// <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;
}
}
Roots cat, bat, rat. Sentence the cattle was rattled:
the: root has no t child. Kept.cattle: walks c a t. The t node is flagged after 3 letters, so return word[..3], which is cat.was: no w child. Kept.rattled: walks r a t, flagged. Becomes rat.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.
a and aa: the walk stops at the first flag, so the shorter root wins.StringSplitOptions.RemoveEmptyEntries drops them, so the output has single spaces. Ask if spacing must be kept.HashSet<string> is simpler.TryGetValue for one lookup per letter.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.