Trie structure and operations, O(L) insert and search, prefix queries, memory trade-offs and compressed (radix) tries, and alternatives such as sorted arrays, database prefix queries and search engines.
A trie is a tree where each edge is a character and each path from the root spells a prefix. Nodes mark where a complete word ends.
syd"): walk L nodes, then collect the subtree.Great for autocomplete, spell-check, word games and longest-prefix matching (IP routing). Downside: memory (one node per character, plus child maps). Compressed (radix) tries merge single-child chains.
Alternatives: a sorted array + binary search for prefixes (compact, often fast enough), a database LIKE 'syd%' on an index, or a search engine's suggesters for fuzzy, ranked autocomplete at scale.
Words: car, cart, care, cat, dog
(root)
/ \
c d
| |
a o
/ \ |
r* t* g*
/ \
t* e* * = end of a word
Searching "car" follows c → a → r and checks the end marker. Prefix "ca" follows c → a, then collects car, cart, care, cat.
| Operation | Trie | HashSet | Sorted array |
|---|---|---|---|
| Exact lookup | O(L) | O(L) to hash + compare | O(L log n) |
| Prefix query | O(L + results) | O(n) scan | O(L log n + results) |
| Memory | High (node per char) | Moderate | Low |
| Insert | O(L) | O(L) | O(n) (shift) |
When not: huge vocabularies (memory), fuzzy matching and ranking (use a search engine), or when a database index already answers LIKE 'abc%' fast enough.
An address form needed suburb autocomplete for ~15,000 Australian suburbs, called on each keystroke. The first version queried SQL with LIKE @prefix + '%' per keystroke, adding database load and 40–80 ms latency. We loaded suburbs into an in-memory trie at startup, storing the top 10 suburbs by population at each node, so a lookup was O(L) and returned pre-ranked results. Latency dropped to under 1 ms on the server, and the database stopped seeing autocomplete traffic.
A coding question that checks tree manipulation, string handling and trade-off thinking.
Green flags
Red flags
| Children storage | Memory | Speed | Use when |
|---|---|---|---|
Node[26] array |
26 references per node, mostly null | Fastest | Lowercase a–z only |
Dictionary<char, Node> |
Only existing children, but dictionary overhead | Good | Unicode, mixed characters |
| Sorted small array of (char, Node) | Compact | Good for few children | Memory-sensitive |
Plain trie: r ─ o ─ m ─ a ─ n ─ e* Radix trie: "roman" ─ "e"*
└─ u ─ s* └ "us"*
Chains of single children are merged into one edge labelled with a string, which saves many nodes. Routers and some key-value stores use radix trees.
Real autocomplete needs the best suggestions, not all of them. Options:
Decide how to treat case, accents and spaces before inserting: e.g. "Wagga Wagga" → "wagga wagga", strip diacritics with string.Normalize(NormalizationForm.FormD) and remove combining marks. Apply the same normalisation to queries.
| Option | Strength |
|---|---|
| Sorted array + binary search for the first match, then scan | Very compact, simple, cache-friendly |
SQL index with LIKE 'abc%' (sargable) |
No extra infrastructure |
| Elasticsearch / OpenSearch completion suggester, Azure AI Search suggesters | Fuzzy matching, ranking, huge scale |
FrozenSet/HashSet |
Exact lookups only |
GC.GetTotalMemory.public sealed class Trie
{
private sealed class Node
{
public Dictionary<char, Node> Children { get; } = new();
public bool IsWord { get; set; }
}
private readonly Node _root = new();
public void Insert(string word)
{
var node = _root;
foreach (var c in Normalise(word))
{
if (!node.Children.TryGetValue(c, out var next))
{
next = new Node();
node.Children[c] = next;
}
node = next;
}
node.IsWord = true;
}
public bool Contains(string word) => Find(Normalise(word)) is { IsWord: true };
public IEnumerable<string> StartsWith(string prefix, int limit = 10)
{
var normalised = Normalise(prefix);
var start = Find(normalised);
if (start is null) yield break;
// Iterative DFS so deep words cannot overflow the call stack
var stack = new Stack<(Node Node, string Text)>();
stack.Push((start, normalised));
int count = 0;
while (count < limit && stack.TryPop(out var item))
{
if (item.Node.IsWord) { yield return item.Text; count++; }
foreach (var (c, child) in item.Node.Children.OrderByDescending(kv => kv.Key))
stack.Push((child, item.Text + c)); // push in reverse so output is alphabetical
}
}
private Node? Find(string text)
{
var node = _root;
foreach (var c in text)
{
if (!node.Children.TryGetValue(c, out var next)) return null;
node = next;
}
return node;
}
private static string Normalise(string s) => s.Trim().ToLowerInvariant();
}
// Usage
var suburbs = new Trie();
foreach (var s in new[] { "Sydney", "Sydenham", "Sylvania", "Melbourne" }) suburbs.Insert(s);
Console.WriteLine(string.Join(", ", suburbs.StartsWith("syd"))); // sydenham, sydney
Console.WriteLine(suburbs.Contains("SYDNEY")); // True
// Compact alternative: sorted array + binary search for a prefix range
string[] sorted = names.Select(n => n.ToLowerInvariant()).Order(StringComparer.Ordinal).ToArray();
IEnumerable<string> PrefixSearch(string prefix)
{
int i = Array.BinarySearch(sorted, prefix, StringComparer.Ordinal);
if (i < 0) i = ~i; // first position >= prefix
for (; i < sorted.Length && sorted[i].StartsWith(prefix, StringComparison.Ordinal); i++)
yield return sorted[i];
}
Mistake: Using a trie for exact lookups only.
Why it is wrong: A HashSet<string> or FrozenSet<string> is simpler, faster to build and uses far less memory for that job.
Instead: Use a trie when you need prefix queries or longest-prefix matching.
Mistake: Ignoring memory.
Why it is wrong: A node object plus a Dictionary<char, Node> per character can use many times the size of the raw strings, and a large trie adds GC pressure.
Instead: Use arrays for small alphabets, compress chains (radix trie), or consider a sorted array; measure with a memory profiler before choosing.
Mistake: Returning every match for short prefixes. Why it is wrong: A one-letter prefix can match thousands of words, inflating response size and latency on every keystroke. Instead: Cap results and rank them (precomputed top-K per node or a heap).
Mistake: Inconsistent or expensive normalisation.
Why it is wrong: Inserting "Sydney" but querying "syd" without lower-casing misses matches, and re-normalising every stored word on each query (or using culture-sensitive ToLower) wastes CPU and gives different results per server culture.
Instead: Normalise once at insert time with ToLowerInvariant and, where needed, Normalize(NormalizationForm.FormD) with combining marks removed; apply exactly the same function to each query.
Mistake: Mutating a shared trie while requests read it. Why it is wrong: The child dictionaries are not thread-safe, so concurrent inserts and reads can corrupt them. Instead: Build a new trie off to the side and swap the reference atomically.
Mistake: Building fuzzy, ranked search on a hand-made trie. Why it is wrong: Typos, synonyms and relevance tuning quickly turn into a search-engine project. Instead: Use a search service's suggesters when requirements go beyond prefix matching.
A trie is the textbook answer for prefix queries, and it is excellent for small to medium, mostly static vocabularies on hot paths. But I weigh it against simpler options before building one.
| Option | Memory | Prefix query | Ranking and fuzziness | Operations cost |
|---|---|---|---|---|
| In-memory trie with top-K per node | High | O(L) | Ranking yes, fuzzy no | Rebuild and swap on change |
| Sorted array + binary search | Low | O(L log n + results) | Ranking needs extra work | Very simple |
Database prefix index (LIKE 'abc%') |
None in app | Index seek | Basic | A round trip per keystroke |
| Search service suggesters | External | Fast | Both, at scale | Extra infrastructure and cost |
What makes autocomplete good in production is usually ranking, normalisation, limits and caching, not the structure itself. My usual design: debounce on the client, precompute top suggestions per prefix from usage or population data, normalise consistently, cap results, add Cache-Control for short prefixes, and rebuild the in-memory structure on a schedule and swap it atomically. The trie is an implementation detail behind that contract.
A tree is a hierarchy of nodes with one root, where each node has children and no cycles. A binary tree limits each node to two children (left, right). A binary search tree (BST) adds an ordering rule: left subtree < node < right subtree, so search, insert and delete take O(height).
If the tree is balanced, height is O(log n); if you insert sorted data into a plain BST it degenerates into a linked list with O(n) height. Self-balancing trees (red-black, AVL) keep O(log n); .NET's SortedSet and SortedDictionary are red-black trees.
Traversals: in-order (sorted output for a BST), pre-order (copy/serialise), post-order (delete/evaluate), level-order (BFS).
Trees are everywhere: database B+ tree indexes, file systems, JSON/XML/DOM, Roslyn syntax trees, expression trees, org charts.
A set stores unique items and answers "is X in it?" quickly.
HashSet<T>: hash table without values. Add, Remove, Contains are O(1) average. No ordering.SortedSet<T>: red-black tree. O(log n) operations, items kept sorted, supports Min, Max and GetViewBetween ranges.FrozenSet<T> (.NET 8+): built once, optimised for fast reads; immutable.ImmutableHashSet<T>: immutable with cheap "modified copies", for snapshot-style sharing.Set algebra: UnionWith, IntersectWith, ExceptWith, SymmetricExceptWith, IsSubsetOf, Overlaps, SetEquals. The biggest everyday win is replacing List.Contains (O(n)) in loops with HashSet.Contains (O(1)).
A heap is a complete tree stored in an array that keeps the heap property: in a min-heap every parent ≤ its children, so the smallest item is always at the root.
.NET's PriorityQueue<TElement,TPriority> (.NET 6+) is an array-backed min-heap (4-ary internally). Enqueue, Dequeue, TryPeek, EnqueueDequeue; not stable for equal priorities; no decrease-key (Remove was added in .NET 9). For a max-heap, pass a reversed comparer.
Top K from a stream: keep a min-heap of size K; for each item, if it beats the heap's minimum, replace it. That is O(n log K) time and O(K) memory.
(Not to be confused with the managed heap, which is memory.)
How to tell it (STAR)
**S:** Our [checkout] suburb autocomplete lagged and generated a quarter of our database queries. **T:** I was asked to make it instant without new infrastructure. **A:** I built an in-memory trie of [15,000] suburbs at startup, storing the top 10 matches at each node so a lookup is just a walk down the prefix. I normalised case and capped results. **R:** Server latency dropped below [1 ms], the database load from autocomplete disappeared, and the trie used only [~6 MB].
Last updated 23 Oct 2026.