Toolmingo
Guides13 min read

Trie Data Structure: Complete Guide with Code Examples

Master the trie (prefix tree) data structure — insert, search, prefix operations, implementations in Python, JavaScript, Java, and Go, plus classic interview problems.

A trie (pronounced "try", from retrieval) is a tree-like data structure optimised for storing and searching strings by their prefixes. Instead of hashing an entire key, a trie decomposes each key into its characters and stores them along branches — every path from root to a marked node spells out a word.

Tries power autocomplete, spell-checkers, IP routing tables, and competitive-programming prefix problems. They trade memory for lightning-fast prefix queries.

Quick reference

Operation Time Space Notes
Insert O(L) O(L × Σ) L = word length, Σ = alphabet size
Search (exact) O(L) O(1) Traverse character by character
Starts-with (prefix) O(P) O(1) P = prefix length
Delete O(L) O(1) Mark leaf, prune if needed
List all words O(N) O(N) N = total characters stored
Count words with prefix O(P + W) O(1) W = words matching prefix

What is a trie?

Each node in a trie represents one character. Children of a node are the characters that can follow it. A special end-of-word flag on a node marks where a complete word finishes.

Words: ["cat", "car", "card", "care", "dog"]

          root
         /    \
        c      d
        |      |
        a      o
       / \     |
      t   r    g*
      *  / \
        d   e
        *   *

Nodes marked with * = is_end = True.

Notice how "car", "card", and "care" share the path c → a → r, only diverging at the last character. This prefix sharing is why tries excel at autocomplete.


Core operations

Insert

Walk the trie character by character. Create a new node wherever a character is missing. Set is_end = True on the final node.

Insert "care" into a trie containing "car":

c → a → r → e   (create node 'e', set is_end)
         *       ('r' node already has is_end from "car")

Search

Walk the trie. If any character is missing → word not found. If you reach the end but is_end is False → prefix exists but not the whole word.

Starts-with

Same as search, but don't check is_end at the end — just confirm the path exists.


Implementations

Python

class TrieNode:
    def __init__(self):
        self.children: dict[str, "TrieNode"] = {}
        self.is_end = False
        self.count = 0  # words passing through this node


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

    def insert(self, word: str) -> None:
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
            node.count += 1
        node.is_end = True

    def search(self, word: str) -> bool:
        node = self._find(word)
        return node is not None and node.is_end

    def starts_with(self, prefix: str) -> bool:
        return self._find(prefix) is not None

    def count_words_with_prefix(self, prefix: str) -> int:
        node = self._find(prefix)
        return node.count if node else 0

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

    def delete(self, word: str) -> bool:
        """Returns True if word was deleted."""
        def _delete(node: TrieNode, word: str, depth: int) -> bool:
            if depth == len(word):
                if not node.is_end:
                    return False
                node.is_end = False
                return len(node.children) == 0  # safe to delete this node

            ch = word[depth]
            if ch not in node.children:
                return False

            child = node.children[ch]
            should_delete = _delete(child, word, depth + 1)

            if should_delete:
                del node.children[ch]
                return not node.is_end and len(node.children) == 0

            return False

        return _delete(self.root, word, 0)

    def list_all_words(self) -> list[str]:
        results = []
        def dfs(node: TrieNode, path: list[str]):
            if node.is_end:
                results.append("".join(path))
            for ch, child in node.children.items():
                path.append(ch)
                dfs(child, path)
                path.pop()
        dfs(self.root, [])
        return results


# Usage
trie = Trie()
for word in ["apple", "app", "apply", "apt", "bat"]:
    trie.insert(word)

print(trie.search("app"))          # True
print(trie.search("ap"))           # False
print(trie.starts_with("ap"))      # True
print(trie.count_words_with_prefix("app"))  # 3
print(trie.list_all_words())       # ['app', 'apple', 'apply', 'apt', 'bat']
trie.delete("apple")
print(trie.search("apple"))        # False
print(trie.search("app"))          # True (still there)

JavaScript

class TrieNode {
  constructor() {
    this.children = new Map();
    this.isEnd = false;
  }
}

class Trie {
  constructor() {
    this.root = new TrieNode();
  }

  insert(word) {
    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.isEnd = true;
  }

  search(word) {
    const node = this._find(word);
    return node !== null && node.isEnd;
  }

  startsWith(prefix) {
    return this._find(prefix) !== null;
  }

  _find(prefix) {
    let node = this.root;
    for (const ch of prefix) {
      if (!node.children.has(ch)) return null;
      node = node.children.get(ch);
    }
    return node;
  }

  // Return all words starting with prefix
  autocomplete(prefix) {
    const node = this._find(prefix);
    if (!node) return [];

    const results = [];
    const dfs = (n, path) => {
      if (n.isEnd) results.push(prefix + path);
      for (const [ch, child] of n.children) {
        dfs(child, path + ch);
      }
    };
    dfs(node, "");
    return results;
  }
}

// Usage
const trie = new Trie();
["apple", "app", "apply", "apt", "bat"].forEach(w => trie.insert(w));

console.log(trie.search("app"));       // true
console.log(trie.startsWith("ap"));    // true
console.log(trie.autocomplete("app")); // ["app", "apple", "apply"]

Java

import java.util.*;

class TrieNode {
    Map<Character, TrieNode> children = new HashMap<>();
    boolean isEnd = false;
}

public class Trie {
    private final TrieNode root = new TrieNode();

    public void insert(String word) {
        TrieNode node = root;
        for (char ch : word.toCharArray()) {
            node.children.putIfAbsent(ch, new TrieNode());
            node = node.children.get(ch);
        }
        node.isEnd = true;
    }

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

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

    private TrieNode find(String prefix) {
        TrieNode node = root;
        for (char ch : prefix.toCharArray()) {
            if (!node.children.containsKey(ch)) return null;
            node = node.children.get(ch);
        }
        return node;
    }

    public List<String> autocomplete(String prefix) {
        List<String> results = new ArrayList<>();
        TrieNode node = find(prefix);
        if (node == null) return results;
        dfs(node, new StringBuilder(prefix), results);
        return results;
    }

    private void dfs(TrieNode node, StringBuilder path, List<String> results) {
        if (node.isEnd) results.add(path.toString());
        for (Map.Entry<Character, TrieNode> e : node.children.entrySet()) {
            path.append(e.getKey());
            dfs(e.getValue(), path, results);
            path.deleteCharAt(path.length() - 1);
        }
    }

    public static void main(String[] args) {
        Trie trie = new Trie();
        for (String w : new String[]{"apple", "app", "apply", "apt", "bat"}) {
            trie.insert(w);
        }
        System.out.println(trie.search("app"));          // true
        System.out.println(trie.startsWith("ap"));       // true
        System.out.println(trie.autocomplete("app"));    // [app, apple, apply]
    }
}

Go

package main

import "fmt"

type TrieNode struct {
    children map[rune]*TrieNode
    isEnd    bool
}

func newTrieNode() *TrieNode {
    return &TrieNode{children: make(map[rune]*TrieNode)}
}

type Trie struct {
    root *TrieNode
}

func NewTrie() *Trie { return &Trie{root: newTrieNode()} }

func (t *Trie) Insert(word string) {
    node := t.root
    for _, ch := range word {
        if _, ok := node.children[ch]; !ok {
            node.children[ch] = newTrieNode()
        }
        node = node.children[ch]
    }
    node.isEnd = true
}

func (t *Trie) find(prefix string) *TrieNode {
    node := t.root
    for _, ch := range prefix {
        if _, ok := node.children[ch]; !ok {
            return nil
        }
        node = node.children[ch]
    }
    return node
}

func (t *Trie) Search(word string) bool {
    node := t.find(word)
    return node != nil && node.isEnd
}

func (t *Trie) StartsWith(prefix string) bool {
    return t.find(prefix) != nil
}

func (t *Trie) Autocomplete(prefix string) []string {
    node := t.find(prefix)
    if node == nil {
        return nil
    }
    var results []string
    var dfs func(*TrieNode, []rune)
    dfs = func(n *TrieNode, path []rune) {
        if n.isEnd {
            results = append(results, prefix+string(path))
        }
        for ch, child := range n.children {
            dfs(child, append(path, ch))
        }
    }
    dfs(node, nil)
    return results
}

func main() {
    t := NewTrie()
    for _, w := range []string{"apple", "app", "apply", "apt", "bat"} {
        t.Insert(w)
    }
    fmt.Println(t.Search("app"))         // true
    fmt.Println(t.StartsWith("ap"))      // true
    fmt.Println(t.Autocomplete("app"))   // [app apple apply]
}

Classic interview problems

1. Implement Trie (LeetCode 208) — Easy

The standard implementation above covers this exactly: insert, search, startsWith.

Key point: search must check is_end; startsWith must not.


2. Word Search II (LeetCode 212) — Hard

Find all words from a dictionary that exist in an m×n board (DFS).

def find_words(board: list[list[str]], words: list[str]) -> list[str]:
    trie = Trie()
    for w in words:
        trie.insert(w)

    rows, cols = len(board), len(board[0])
    found = set()

    def dfs(node, r, c, path):
        ch = board[r][c]
        if ch not in node.children:
            return
        next_node = node.children[ch]
        path.append(ch)
        if next_node.is_end:
            found.add("".join(path))
        board[r][c] = "#"  # mark visited
        for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] != "#":
                dfs(next_node, nr, nc, path)
        board[r][c] = ch   # restore
        path.pop()

    for r in range(rows):
        for c in range(cols):
            dfs(trie.root, r, c, [])

    return list(found)

Why trie? Pruning — if no word starts with the current path, stop immediately. O(M × 4 × 3^(L−1)) vs O(M × W × L) brute-force.


3. Replace Words (LeetCode 648) — Medium

Replace every word in a sentence with its shortest dictionary root.

def replace_words(dictionary: list[str], sentence: str) -> str:
    trie = Trie()
    for root in dictionary:
        trie.insert(root)

    def find_root(word):
        node = trie.root
        for i, ch in enumerate(word):
            if ch not in node.children:
                break
            node = node.children[ch]
            if node.is_end:
                return word[:i+1]  # shortest root found
        return word

    return " ".join(find_root(w) for w in sentence.split())

# replace_words(["cat","bat","rat"], "the cattle was rattled by the battery")
# → "the cat was rat by the bat"

4. Design Search Autocomplete System (LeetCode 642) — Hard

Return top-3 hot sentences that start with the typed prefix, ranked by frequency then lexicographically.

import heapq
from collections import defaultdict

class AutocompleteSystem:
    def __init__(self, sentences: list[str], times: list[int]):
        self.freq = defaultdict(int)
        for s, t in zip(sentences, times):
            self.freq[s] = t
        self.trie = Trie()
        for s in sentences:
            self.trie.insert(s)
        self.current = []

    def input(self, c: str) -> list[str]:
        if c == "#":
            sentence = "".join(self.current)
            self.freq[sentence] += 1
            self.trie.insert(sentence)
            self.current = []
            return []

        self.current.append(c)
        prefix = "".join(self.current)
        candidates = self.trie.list_words_with_prefix(prefix)
        # Top 3: highest frequency, break ties lexicographically
        return heapq.nlargest(3, candidates, key=lambda s: (self.freq[s], [-ord(x) for x in s]))

5. Longest Word in Dictionary (LeetCode 720) — Medium

Find the longest word buildable one character at a time, where every prefix is also in the dictionary.

def longest_word(words: list[str]) -> str:
    trie = Trie()
    for w in words:
        trie.insert(w)

    best = ""
    def dfs(node, path):
        nonlocal best
        word = "".join(path)
        if len(word) > len(best) or (len(word) == len(best) and word < best):
            best = word
        for ch, child in node.children.items():
            if child.is_end:  # every prefix must be a valid word
                path.append(ch)
                dfs(child, path)
                path.pop()

    dfs(trie.root, [])
    return best

# longest_word(["w","wo","wor","worl","world"]) → "world"

6. Palindrome Pairs (LeetCode 336) — Hard

Find all pairs (i, j) where words[i] + words[j] is a palindrome.

A trie of reversed words enables checking: for each word, look for reversed suffixes that are palindromes.

Concept: insert all reversed(word) into trie. For each word, traverse the trie — if remaining trie path forms a palindrome (or remaining word suffix is a palindrome), it's a valid pair.


Trie variants

Variant Description Use case
Compressed trie (Patricia) Merge single-child chains into one edge Lower memory, Unix file paths
Ternary search trie Each node has 3 children (less, equal, greater) Memory-efficient for large alphabets
Radix tree (PATRICIA trie) Bitwise compressed trie IP routing, kernel data structures
Suffix trie All suffixes of a string Substring search, bioinformatics
Suffix array Sorted suffixes (space-efficient alternative) Full-text search
Double-array trie Two arrays encode trie structure Production NLP, high-performance lookup
Bitwise trie Bits of integer as path XOR problems, integer range queries

Trie vs hash table vs sorted array

Dimension Trie Hash table Sorted array
Exact lookup O(L) O(L) avg O(L log N)
Prefix search O(P) O(N × L) O(log N + K)
Autocomplete O(P + W) O(N × L) O(log N + W)
Sorted iteration O(N × L) in order O(N log N) O(N)
Memory O(N × L × Σ) O(N × L) O(N × L)
Worst case O(L) O(L) (hash collision) O(L log N)
Alphabet dependency Yes (branching factor Σ) No No

Rule of thumb: reach for a trie when you need prefix queries or autocomplete; reach for a hash table when you only need exact lookups.


When to use a trie

Use a trie when:

  • You need prefix search (autocomplete, IP routing, spell-check).
  • Multiple strings share long common prefixes (saves memory vs storing each separately).
  • You need lexicographic ordering without sorting.
  • The problem involves building words character by character.
  • You need to prune a search space by prefix (Word Search II, Boggle).

Do NOT use a trie when:

  • You only need exact match lookups — a hash set is simpler and faster in practice.
  • The alphabet is huge (Unicode with no bound) — each node may have thousands of children; a hash map per node mitigates this but adds overhead.
  • Memory is critical — a trie with 26-child arrays uses ~26 × 8 bytes per node even when mostly empty.
  • The strings are numbers or binary data — consider a bitwise trie or segment tree instead.

Memory optimisation

Array-based children (fast, memory-hungry):

class TrieNode:
    def __init__(self):
        self.children = [None] * 26  # only lowercase a-z
        self.is_end = False

# Access: node.children[ord(ch) - ord('a')]
# Memory: 26 pointers × 8 bytes = 208 bytes/node (even when sparse)

Dict-based children (flexible, slightly slower):

class TrieNode:
    def __init__(self):
        self.children: dict[str, TrieNode] = {}
        self.is_end = False
# Memory: only stores existing children — better for large alphabets or sparse tries

Compressed trie (merge single-child chains):

Instead of: r → u → n → n → i → n → g*
Store:       r → "unnin" → g*   (one edge = multi-char string)

Reduces node count from O(N × L) to O(N) for N words.


Complexity summary

Operation Time Notes
Insert O(L) L = length of word
Search O(L)
Starts-with O(P) P = prefix length
Delete O(L) Recursive pruning
Autocomplete (list K words) O(P + K × L) DFS from prefix node
Build trie (N words, avg len L) O(N × L)
Space O(N × L × Σ) Σ = alphabet size

Common mistakes

Mistake Problem Fix
Not checking is_end in search search("ap") returns true when only "apple" is stored Always check is_end for exact match
Treating startsWith like search Confuse the two in problems that need both startsWith = path exists; search = path exists AND is_end
Using array children with large alphabet 26 slots hardcoded; fails for Unicode Use dict / HashMap for variable alphabets
Forgetting to restore board cells in Word Search II DFS marks cells visited but forgets to unmark Set board[r][c] = "#" before recursion, restore after
O(N × Σ) memory per node (array-based) 208 bytes/node × millions of nodes Switch to dict children or compressed trie
Not pruning the trie after finding a word (Word Search II) Re-visits found words repeatedly Set node.is_end = False after adding to results
Infinite recursion in delete No base case for empty string Always check depth == len(word) first
Wrong time complexity claim Saying "O(1) lookup" (it's O(L)) Trie lookup is O(L) — better than hash when prefix matters, not faster for exact match

Trie vs related structures

Structure Best for Not ideal for
Trie Prefix search, autocomplete, pruning Exact match only, huge alphabets
Hash table O(1) exact match Prefix queries, sorted order
BST / sorted array Range queries, sorted iteration Fast prefix enumeration
Suffix array Arbitrary substring search Prefix-only problems
Bloom filter Probabilistic membership Exact match required
Aho-Corasick Multi-pattern string matching Single-pattern search

FAQ

Q: Is "trie" pronounced "tree" or "try"? Officially "try" (from retrieval), but "tree" is widely heard. Both are understood.

Q: Trie vs hash set for interview problems — which to use? If the problem involves prefixes, autocomplete, or character-by-character building → trie. If only exact lookups → hash set is simpler.

Q: How does a trie handle Unicode? Use a dict (or HashMap) per node keyed on the full Unicode character. Array-based children only work for fixed, small alphabets.

Q: What's the difference between a trie and a prefix tree? They're the same thing. "Prefix tree" is the descriptive name; "trie" is the historical name from "retrieval".

Q: Can a trie store integers? Yes — a bitwise trie treats each bit of an integer as a 0/1 branch. Used for XOR maximisation problems and radix-based sorting.

Q: What is Aho-Corasick and how does it relate to tries? Aho-Corasick extends a trie with failure links (similar to KMP), enabling simultaneous search for multiple patterns in O(N + M + Z) where Z is match count. Standard tries search one pattern at a time.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools