Data Structures & Algorithms Cheatsheet

Tries

Use this Data Structures & Algorithms reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Complexity

A trie (prefix tree) stores strings by their characters, one edge per character. Cost depends on the word length, not the number of stored words.

OperationTimeSpace
insert a word of length mO(m)O(m) new nodes worst case
search exact wordO(m)O(1)
prefix existsO(m)O(1)
delete a wordO(m)O(1)
all words with a prefixO(m + k)O(k) for the output
count words with a prefixO(m) with a counter per nodeO(1)
build from n wordsO(n · m)O(n · m · Σ) worst case
wildcard match with .O(Σ^d · m)O(m) recursion

Σ is the alphabet size (26 for lowercase English). Space is the weak point: a fixed 26-slot array per node wastes memory on sparse tries, so a hash map per node is usually better.

When To Reach For A Trie

  • Prefix queries. "All words starting with pre", autocomplete, IP routing tables.
  • Many searches against a fixed dictionary. Build once in O(total characters), then each query is O(m) with no dependence on dictionary size.
  • Shared prefixes are common. A trie compresses them, so 10,000 words sharing inter store it once.
  • Word-search-in-grid problems. A trie prunes DFS the moment the current path stops being any word's prefix.
  • Maximum XOR pairs. A binary trie over bits solves it in O(32n) instead of O(n²).

A hash set answers "is this an exact word" just as fast. Use a trie only when prefixes matter.

Basic Implementation

class TrieNode:
    __slots__ = ("children", "is_word")

    def __init__(self):
        self.children = {}        # char -> TrieNode
        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 _walk(self, prefix):
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node

    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

is_word is what separates a stored word from a mere prefix. A trie holding "apple" answers search("app") as False but starts_with("app") as True.

Array-Backed Version

For fixed lowercase alphabets an array child list is faster and allocation-light.

class ArrayTrie:
    def __init__(self):
        self.children = [None] * 26
        self.is_word = False
        self.count = 0            # words passing through, for prefix counts

    def insert(self, word):
        node = self
        for ch in word:
            i = ord(ch) - 97
            if node.children[i] is None:
                node.children[i] = ArrayTrie()
            node = node.children[i]
            node.count += 1
        node.is_word = True

    def count_prefix(self, prefix):
        node = self
        for ch in prefix:
            i = ord(ch) - 97
            if node.children[i] is None:
                return 0
            node = node.children[i]
        return node.count
class TrieNode {
    TrieNode[] next = new TrieNode[26];
    boolean isWord;
}

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

    public void insert(String w) {
        TrieNode cur = root;
        for (char c : w.toCharArray()) {
            int i = c - 'a';
            if (cur.next[i] == null) cur.next[i] = new TrieNode();
            cur = cur.next[i];
        }
        cur.isWord = true;
    }

    public boolean search(String w) {
        TrieNode n = walk(w);
        return n != null && n.isWord;
    }

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

    private TrieNode walk(String s) {
        TrieNode cur = root;
        for (char c : s.toCharArray()) {
            cur = cur.next[c - 'a'];
            if (cur == null) return null;
        }
        return cur;
    }
}

Deletion And Enumeration

def delete(self, word):
    """Unset is_word, then prune nodes that became useless."""
    path = [self.root]
    node = self.root
    for ch in word:
        if ch not in node.children:
            return False
        node = node.children[ch]
        path.append(node)
    if not node.is_word:
        return False
    node.is_word = False
    for i in range(len(path) - 1, 0, -1):
        cur, parent = path[i], path[i - 1]
        if cur.children or cur.is_word:
            break                      # still needed by another word
        del parent.children[word[i - 1]]
    return True

def words_with_prefix(self, prefix):
    """All stored words starting with prefix. O(m + output)."""
    node = self._walk(prefix)
    out = []
    if node is None:
        return out
    def dfs(n, path):
        if n.is_word:
            out.append(prefix + "".join(path))
        for ch, child in n.children.items():
            path.append(ch)
            dfs(child, path)
            path.pop()
    dfs(node, [])
    return out

def longest_common_prefix(words):
    """Walk down while there is exactly one child and no word ends."""
    t = Trie()
    for w in words:
        t.insert(w)
    node, out = t.root, []
    while len(node.children) == 1 and not node.is_word:
        ch, node = next(iter(node.children.items()))
        out.append(ch)
    return "".join(out)

Pruning on delete matters when the trie is long-lived. Without it, deleted words leave dead branches that keep answering starts_with correctly but waste memory forever.

Binary Trie For Maximum XOR

Store the bits of each number, most significant first. To maximize XOR, greedily walk toward the opposite bit at every level.

def find_maximum_xor(nums, bits=32):
    """Maximum a ^ b over all pairs. O(n * bits) instead of O(n^2)."""
    root = {}
    for x in nums:                        # insert
        node = root
        for i in range(bits - 1, -1, -1):
            b = (x >> i) & 1
            node = node.setdefault(b, {})

    best = 0
    for x in nums:                        # query
        node, cur = root, 0
        for i in range(bits - 1, -1, -1):
            b = (x >> i) & 1
            want = 1 - b                  # opposite bit gives a 1 in the XOR
            if want in node:
                cur |= 1 << i
                node = node[want]
            else:
                node = node[b]
        best = max(best, cur)
    return best

Gotchas

  • is_word is not optional. Without it a trie cannot distinguish a stored word from a prefix of one.
  • Alphabet assumptions. ord(ch) - 97 breaks on uppercase, digits, or Unicode. Use a dict when the alphabet is not known and small.
  • Space blowup. A 26-array per node costs about 26 pointers even for a single-child node. Prefer dicts for sparse data, and add __slots__ in Python.
  • Deletion without pruning leaks. The word stops matching but the nodes stay.
  • Wildcard search is exponential in the number of dots. Each . branches over every child, so "...." on a dense trie is expensive.
  • Restore mutated state. Grid DFS must put the original character back after recursing, and path.pop() must run on every exit path.
  • A trie is not always the answer. For exact-match-only lookups a hash set is simpler and faster. For a small fixed dictionary, sorting plus binary search on prefixes also works.
  • Recursion depth equals word length, which is fine for words but a problem for a binary trie over 64-bit keys built recursively. Iterate instead.