Mustaque Nadim Academy
Trie

Tries

Autocomplete has to find every word starting with "str" out of millions — a trie makes prefix search almost free.

The problem

You type "str" into a search box and the app instantly offers "string", "stride", "structure", "strong". Behind that box sits a dictionary of a few million words. Every keystroke, it has to answer the same question: which words start with what I have typed so far?

Do that naively and the box feels sticky. Each letter triggers a scan of millions of words, and the user is typing faster than you can scan. You need prefix lookups that stay fast no matter how big the dictionary grows.

A first attempt

The obvious move is to keep every word in a list and, on each keystroke, walk the whole list keeping the ones that start with your prefix.

def suggest(words, prefix):
    return [w for w in words if w.startswith(prefix)]

For n words of length up to L, that is O(n · L) work on every single keystroke. With a few million words, one keystroke can touch tens of millions of characters. Sorting the list and binary-searching the prefix range helps — O(L · log n) — but you still compare against unrelated words, and you re-do all of it for the next letter. The waste is obvious: "str" and "stri" share the "str" prefix, yet you throw that work away each time.

The insight

Words that share a prefix should share the path you walk to find them. Instead of storing whole words side by side, store them letter by letter in a tree, where each edge is one character. All words beginning with "str" hang below the single node you reach by walking s → t → r.

Now a prefix lookup is just walking down that path — one step per character of the prefix, and independent of how many words exist. That tree is a trie (from retrieval, though everyone says "try").

How it works

Give every node a map of children

Each node holds a dictionary from a single character to a child node. The root represents the empty prefix. An edge labelled c means "append c to the prefix so far".

Insert by walking and creating

To insert a word, start at the root and walk one character at a time. If the child for the next character is missing, create it. When the word ends, flag that node as the end of a complete word.

insert "str", "strong":

        (root)
          |
          s
          |
          t
          |
          r  <- end of "str"
          |
          o
          |
          n
          |
          g  <- end of "strong"

Search by walking without creating

To check a word or prefix, walk the same path. If any character has no child, the prefix is not present. For a full-word lookup, also require the final node to be flagged as a word end.

Collect suggestions from the prefix node

Walk to the node for "str", then depth-first collect every flagged descendant. Those are exactly the words that start with "str" — and you never looked at a word that did not.

The code

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


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

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

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_word

    def starts_with(self, prefix):
        return self._walk(prefix) is not None

    def _walk(self, s):
        node = self.root
        for ch in s:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node


t = Trie()
for w in ["string", "strong", "stride"]:
    t.insert(w)
print(t.search("strong"))    # True
print(t.search("stron"))     # False
print(t.starts_with("str"))  # True
class TrieNode {
  children: Map<string, TrieNode> = new Map();
  isWord = false;
}

class Trie {
  private root = new TrieNode();

  insert(word: string): void {
    let node = this.root;
    for (const ch of word) {
      if (!node.children.has(ch)) node.children.set(ch, new TrieNode());
      node = node.children.get(ch)!;
    }
    node.isWord = true;
  }

  private walk(s: string): TrieNode | null {
    let node = this.root;
    for (const ch of s) {
      const next = node.children.get(ch);
      if (!next) return null;
      node = next;
    }
    return node;
  }

  search(word: string): boolean {
    const node = this.walk(word);
    return node !== null && node.isWord;
  }

  startsWith(prefix: string): boolean {
    return this.walk(prefix) !== null;
  }
}

const t = new Trie();
["string", "strong", "stride"].forEach((w) => t.insert(w));
console.log(t.search("strong"));   // true
console.log(t.search("stron"));    // false
console.log(t.startsWith("str"));  // true
import java.util.HashMap;
import java.util.Map;

class Trie {
    private static class Node {
        Map<Character, Node> children = new HashMap<>();
        boolean isWord = false;
    }

    private final Node root = new Node();

    public void insert(String word) {
        Node node = root;
        for (char ch : word.toCharArray()) {
            node = node.children.computeIfAbsent(ch, k -> new Node());
        }
        node.isWord = true;
    }

    private Node walk(String s) {
        Node node = root;
        for (char ch : s.toCharArray()) {
            node = node.children.get(ch);
            if (node == null) return null;
        }
        return node;
    }

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

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

    public static void main(String[] args) {
        Trie t = new Trie();
        for (String w : new String[] {"string", "strong", "stride"}) t.insert(w);
        System.out.println(t.search("strong"));   // true
        System.out.println(t.search("stron"));    // false
        System.out.println(t.startsWith("str"));  // true
    }
}
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>

#define ALPHABET 26

typedef struct Node {
    struct Node *children[ALPHABET];
    bool is_word;
} Node;

Node *new_node(void) {
    return calloc(1, sizeof(Node));  // children NULL, is_word false
}

void insert(Node *root, const char *word) {
    Node *node = root;
    for (const char *p = word; *p; p++) {
        int i = *p - 'a';
        if (!node->children[i]) node->children[i] = new_node();
        node = node->children[i];
    }
    node->is_word = true;
}

Node *walk(Node *root, const char *s) {
    Node *node = root;
    for (const char *p = s; *p; p++) {
        node = node->children[*p - 'a'];
        if (!node) return NULL;
    }
    return node;
}

bool search(Node *root, const char *word) {
    Node *node = walk(root, word);
    return node && node->is_word;
}

bool starts_with(Node *root, const char *prefix) {
    return walk(root, prefix) != NULL;
}

int main(void) {
    Node *root = new_node();
    insert(root, "string");
    insert(root, "strong");
    insert(root, "stride");
    printf("%d\n", search(root, "strong"));    // 1
    printf("%d\n", search(root, "stron"));     // 0
    printf("%d\n", starts_with(root, "str"));  // 1
    return 0;
}
#include <iostream>
#include <unordered_map>
#include <string>

class Trie {
    struct Node {
        std::unordered_map<char, Node*> children;
        bool isWord = false;
    };
    Node* root = new Node();

    Node* walk(const std::string& s) const {
        Node* node = root;
        for (char ch : s) {
            auto it = node->children.find(ch);
            if (it == node->children.end()) return nullptr;
            node = it->second;
        }
        return node;
    }

public:
    void insert(const std::string& word) {
        Node* node = root;
        for (char ch : word) {
            if (!node->children.count(ch)) node->children[ch] = new Node();
            node = node->children[ch];
        }
        node->isWord = true;
    }

    bool search(const std::string& word) const {
        Node* node = walk(word);
        return node && node->isWord;
    }

    bool startsWith(const std::string& prefix) const {
        return walk(prefix) != nullptr;
    }
};

int main() {
    Trie t;
    for (const std::string& w : {"string", "strong", "stride"}) t.insert(w);
    std::cout << t.search("strong")   << "\n";  // 1
    std::cout << t.search("stron")    << "\n";  // 0
    std::cout << t.startsWith("str")  << "\n";  // 1
}

Complexity

Let L be the length of the word or prefix, and Σ the alphabet size.

OperationTimeSpace
Insert a wordO(L)O(L · Σ) worst case for new nodes
Search a wordO(L)O(1)
Prefix checkO(L)O(1)
Collect all words under a prefixO(matches · L)O(1) extra

The headline: every core operation costs O(L) and is completely independent of n, the number of words stored. That is why a trie beats scanning a list.

When to use it

Reach for a trie when prefixes matter

A trie shines for prefix queries: autocomplete, dictionary lookups, and routing where keys share leading characters. If you only ever need exact-key lookups with no prefix logic, a hash table is simpler and lighter. Watch the memory cost — a naive array-of-26 node uses space even for empty slots, so prefer a map of children when the alphabet is large or sparse.

Practice

Recap

  • A trie stores strings by sharing common prefixes along tree paths, so all words under a prefix hang below one node.
  • Insert, search, and prefix checks are all O(L) and independent of the number of stored words.
  • The trade-off is memory: many small nodes, so use a child map for large or sparse alphabets and prefer a hash set when you never need prefixes.

How is this guide?

Last updated on

On this page