📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a trie (prefix tree)? Implement insert, exact search and prefix search in C#, and discuss when a trie is the right choice for something like autocomplete versus alternatives.

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.

Mid-Level Coding Intermediate autocomplete coding data-structures prefix-tree strings trie

🎯 Key points

  • A trie is a tree where each edge is a character and each root-to-node path spells a prefix; an end-of-word flag marks complete words
  • Insert and exact search cost O(L) for a word of length L, independent of how many words are stored
  • Prefix search walks L nodes and then collects matches from that subtree, so cost is O(L + results) instead of scanning every word
  • The trade-off is memory: one node per character plus a child map; small alphabets suit Node[26] arrays, mixed text suits dictionaries, and radix (compressed) tries merge single-child chains
  • Production autocomplete needs ranking and limits: precompute a top-K list per node (O(L) queries, more memory) or collect with a size-K heap, and cap results for short prefixes
  • Normalise case, accents and whitespace once at insert time and identically at query time, using culture-invariant rules
  • Build the trie at startup or on a schedule and publish it as an immutable snapshot so reads need no locks
  • Alternatives: a sorted array with binary search (compact, often fast enough), a sargable database prefix query, or a search service's suggesters for fuzzy, ranked results at scale

⏱ 30-second answer

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.

  • Insert and exact search: O(L) where L is the word length, independent of how many words are stored.
  • Prefix search ("all words starting with 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.

🗣 Full answer (senior)

Structure

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.

Costs

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 to use it

  • In-memory autocomplete over a bounded vocabulary (product names, suburbs, commands).
  • Many prefix queries per second with low latency.
  • Longest-prefix match (routing tables, URL prefix rules).

When not: huge vocabularies (memory), fuzzy matching and ranking (use a search engine), or when a database index already answers LIKE 'abc%' fast enough.

Production example

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.

🔍 What the interviewer is checking

A coding question that checks tree manipulation, string handling and trade-off thinking.

Green flags

  • Correct insert/search/prefix implementation with an end-of-word marker.
  • States O(L) costs and the memory trade-off.
  • Mentions normalisation, result limits and ranking.
  • Considers alternatives (sorted array, DB index, search engine).

Red flags

  • Forgets the end-of-word flag (so "car" matches when only "cart" exists).
  • Scans all words for every prefix query.

📚 Learn in depth

1. Node design choices

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

2. Compressed (radix) trie

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.

3. Ranking and limits

Real autocomplete needs the best suggestions, not all of them. Options:

  • Store a small top-K list at each node (precomputed): O(L) query, more memory.
  • Collect the subtree with DFS and keep a size-K heap by score.
  • Cap results and depth to avoid huge responses for one-letter prefixes.

4. Normalisation

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.

5. Alternatives in practice

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

6. Try it yourself

  1. Implement the trie in the code example and load 100,000 English words; measure memory with GC.GetTotalMemory.
  2. Implement the sorted-array alternative and compare memory and prefix query speed.
  3. Add a top-5 list per node and compare latency for one-letter prefixes.

💻 Code

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];
}

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares tries with search trees Why: Checks tree fundamentals
    How to answer

    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.

  2. When: Candidate suggests a HashSet for lookups Why: Checks when sets are better
    How to answer

    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)).

  3. When: Candidate ranks suggestions Why: Probes top-K with heaps
    How to answer

    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.

    • Peek min: O(1). Insert (sift up) and remove min (sift down): O(log n). Build from n items: O(n).
    • Not sorted overall, and not searchable quickly (finding an arbitrary item is O(n)).

    .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.)

🛠 Real-world problems: how you handle them

Suburb autocomplete moved from SQL LIKE queries to an in-memory trie
Situation
[Checkout address form] on ASP.NET Core [10] calling an autocomplete endpoint on each keystroke for [~15,000] suburbs.
Signals
Autocomplete latency [40–80 ms], noticeable typing lag, and autocomplete was [25%] of database queries at peak.
Investigation
Each keystroke ran `LIKE @prefix + '%'`; plans were fine but round trips and volume dominated.
Resolution
Loaded suburbs into a trie at startup with a precomputed top-10 (by population) per node; refreshed nightly. Added result caps and normalisation.
Prevention
Latency SLO for autocomplete, load test with realistic typing patterns, memory budget check for the trie.

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].

↩ Usually asked after

More in Data Structures

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me