SwadhyAI Notes

All DSA notes

DSA Strings & Trees

Trie (Prefix Tree)

Autocomplete stalls when every keystroke rescans a million-word dictionary. Exact membership in a hash set is fine; “all words starting with typ” is not.

A trie stores strings so share nodes. The plus a separate “path exists” from “word exists.”

Algorithm journey

1

Insert

Follow or create one edge per symbol; mark terminal.

2

Search

Path plus terminal means membership.

3

Prefix query

Reachability of the prefix node is enough.

4

Delete carefully

Clear terminal; prune only unused nodes.

What actually happens

Naive idea: store every word in a list or hash set; for each prefix, scan all keys. Correct, and costly per keystroke at dictionary scale.

Better idea: index by characters. Walk the prefix once in O(P), then enumerate the subtree. Shared structure pays for common stems.

The improved design still fails with wrong units, aggressive delete pruning that removes shared nodes, unbounded memory, or using a trie when only exact membership was needed.

Invariant

The symbols on every root-to-node path spell exactly one stored prefix. A node is terminal iff that prefix is an inserted word. Deletion clears the terminal and prunes upward only while a node is nonterminal and childless.

How to choose a trie

1. Prefix queries or autocomplete? Trie (or sorted array + lower bound for static data).

2. Exact membership only? Prefer a hash set.

3. Memory tight on long chains? Consider or an FST for immutable dictionaries.

Quantitative check

Prefix walk is O(P). Returning k suggestions also costs output traversal: never call the whole feature O(P). One million words with sharing still allocate millions of nodes; measure edges and payloads.

Key characteristics

1

Root is Empty

The root node doesn't store any character; it's the starting point for all words.

2

Shared Prefixes

Words with common prefixes share the same nodes up to the point where they differ.

3

EndOfWord Marker

Nodes often have a boolean flag to indicate the completion of a valid word.

🎮 Interactive trie

Insert lowercase words and watch prefixes merge. The root is labeled ∅ (empty). A green dot marks the end of a complete word. Use the prefix field to highlight the path you would follow for startsWith.

path
ℹ️ Trie seeded with: cat, car, card, cake, cut
∅
c
a
k
e
r
d
t
u
t

💻 Implementation

Python

python
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end_of_word = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.is_end_of_word = True

    def search(self, word):
        node = self.root
        for char in word:
            if char not in node.children:
                return False
            node = node.children[char]
        return node.is_end_of_word

    def starts_with(self, prefix):
        node = self.root
        for char in prefix:
            if char not in node.children:
                return False
            node = node.children[char]
        return True

    def delete(self, word):
        def erase(node, i):
            if i == len(word):
                if not node.is_end_of_word:
                    return False, False
                node.is_end_of_word = False
                return True, not node.children
            child = node.children.get(word[i])
            if child is None:
                return False, False
            deleted, prune = erase(child, i + 1)
            if prune:
                del node.children[word[i]]
            return deleted, (not node.is_end_of_word and not node.children)

        deleted, _ = erase(self.root, 0)
        return deleted

trie = Trie()
for value in ("app", "apple", "café"): trie.insert(value)
assert trie.search("app") and trie.starts_with("ap")
assert trie.delete("app") and not trie.search("app") and trie.search("apple")

Java

java
import java.util.HashMap;
import java.util.Map;
import java.util.PrimitiveIterator;

class Trie {
    private static final class Node {
        final Map<Integer, Node> children = new HashMap<>();
        boolean terminal;
    }

    private final Node root = new Node();

    public void insert(String word) {
        Node node = root;
        PrimitiveIterator.OfInt points = word.codePoints().iterator();
        while (points.hasNext()) {
            int codePoint = points.nextInt();
            node = node.children.computeIfAbsent(codePoint, ignored -> new Node());
        }
        node.terminal = true;
    }

    public boolean search(String word) {
        Node node = find(word);
        return node != null && node.terminal;
    }

    public boolean startsWith(String prefix) { return find(prefix) != null; }

    public boolean delete(String word) {
        if (!search(word)) return false;
        erase(root, word.codePoints().toArray(), 0);
        return true;
    }

    private boolean erase(Node node, int[] word, int index) {
        if (index == word.length) node.terminal = false;
        else {
            Node child = node.children.get(word[index]);
            if (erase(child, word, index + 1)) node.children.remove(word[index]);
        }
        return !node.terminal && node.children.isEmpty();
    }

    private Node find(String text) {
        Node node = root;
        PrimitiveIterator.OfInt points = text.codePoints().iterator();
        while (points.hasNext()) {
            int codePoint = points.nextInt();
            node = node.children.get(codePoint);
            if (node == null) return null;
        }
        return node;
    }

    public static void main(String[] args) {
        Trie trie = new Trie();
        trie.insert("app"); trie.insert("apple"); trie.insert("café");
        if (!trie.search("app") || !trie.startsWith("ap")) throw new AssertionError();
        if (!trie.delete("app") || trie.search("app") || !trie.search("apple")) throw new AssertionError();
    }
}

Lifecycle invariant: paths are prefixes

Insert

Follow/create one edge per symbol; mark terminal only at the full word.

Search

The path may exist while the word does not: terminal distinguishes “app” from prefix of “apple”.

Prefix

Reachability of the prefix node is sufficient; enumerate its subtree for suggestions.

Delete

Clear terminal, then prune upward only while a node is nonterminal and childless.

Invariant: the symbols on every root-to-node path spell exactly one stored prefix. Deleting a word must never prune a node shared by another word or a node that is itself terminal.

Memory, alphabets, and Unicode

Child representationLookupMemory profileBest fit
Fixed array of σ slotsO(1)Potentially huge at sparse nodesSmall fixed alphabet such as lowercase ASCII
Hash mapExpected O(1)Allocation/hash overhead per populated edgeSparse or large alphabets
Sorted vector/mapO(log degree)Compact, cache-friendly choices possibleRead-heavy immutable tries

Define the unit: bytes, Unicode code points, or user-perceived grapheme clusters. Java char is a UTF-16 code unit, so the reference code iterates code points. Normalize equivalent text (for example NFC) and decide case-folding/locale policy before indexing.

Compressed tries and alternatives

Radix / Patricia compression

Collapse every nonterminal one-child chain into one edge labeled by a substring. Height becomes branching decisions rather than characters, saving nodes but making splits/merges more complex.

Choose by query

  • • Hash set: exact membership, simpler and usually smaller.
  • • Sorted array + lower bound: static prefix ranges with compact storage.
  • • Ternary search tree: three pointers per symbol node.
  • • Minimal DFA/FST: highly compressed immutable dictionaries.
  • • Suffix array/automaton: substring rather than prefix queries.

Autocomplete and production checklist

  • • Prefix traversal is O(P); returning k suggestions also costs output traversal, never call it just O(P).
  • • Store frequency/recency or cached top-k candidates when ranking matters.
  • • Enforce limits for word length, node count, and result count against memory abuse.
  • • Immutable snapshots simplify concurrent reads; updates need synchronization/copy-on-write.
  • • Test empty string policy, prefix-as-word, overlapping words, duplicates, and missing deletes.
  • • Test emoji/supplementary code points, normalization equivalents, and locale case behavior.
  • • After deletion, verify other words and shared prefixes still exist.
  • • Measure nodes/edge fan-out and payload memory, not only O(total characters).

📊 Complexity Analysis

OperationTime ComplexitySpace Complexity
InsertionO(L)O(L) new nodes worst case
SearchO(L)O(1)
Prefix SearchO(L)O(1)

* L = length of the word, N = number of words

Exercises

  1. Insert “app” then “apple”. Delete “app”. Which nodes must remain to preserve the ?
  2. Dictionary of 10⁶ words, prefix length 3, return top 10. Compare trie + subtree walk vs scanning a hash set each keystroke.
  3. When is a sorted array + lower_bound better than a mutable trie for prefix ranges?

Primary sources

  • Fredkin, E. (1960): Trie memory.
  • Knuth, The Art of Computer Programming, Vol. 3: digital search trees / tries.
  • Morrison, D. (1968): PATRICIA (compressed path tries).

Takeaway

Tries win on prefix work because of the prefix invariant and shared paths. For exact membership alone, a hash set is usually simpler.