leetcode / Tries / Tries core

Tries

A trie (pronounced "try", from retrieval) stores a set of words as a tree of letters, so every word that begins the same way shares the same path from the root. That sharing is the whole point: asking "does anything start with ca?" costs two steps, no matter how many words are stored. Prefix questions, autocomplete, wildcard search and pruning a word search on a grid are all the same walk down the same tree.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The invariant
  5. Insert, search and prefix
  6. Wildcards and the board search
  7. Worked example
  8. Implementation
  9. Variations and edge cases
  10. Complexity and tradeoffs
  11. Problems
  12. Medium
  13. Hard
  14. Also uses this pattern
  15. Connections
  16. Read first
  17. Read next
  18. Check yourself
  19. References

Problem

You hold a collection of words and must answer questions about prefixes: is this a stored word, does any word start with this, which words start with this, or which of these many words can be spelled on a board. A hash set answers the first question in O(length) but cannot answer the others without scanning every word, because a hash destroys the order of the characters. A sorted list with binary search can answer prefix queries, but inserting is slow. A trie answers every one of them by following one character at a time.

SignalPatternWhy
"Implement insert, search and startsWith"Plain trieEach operation is one walk of at most one step per character.
"Does any word begin with this prefix?"Walk to the prefix node and stopReaching the node means the prefix exists; no scan of the word list is needed.
"Search where a dot can stand for any letter"Trie plus depth-first search at the wildcardA dot branches into every child, a letter follows one child.
"Find all words from a list on a grid of letters"Trie of the words, grid depth-first search that follows the trieThe trie tells the search to stop the moment no word continues the current path.
"Many words share long prefixes"TrieShared prefixes are stored once, which saves memory and lookup work.
"Pairs of words that combine into a palindrome" or a file system by pathTrie over reversed words, or over path partsThe tree's edges can be any token: characters, reversed characters or directory names.
"Only exact membership, no prefixes"A set is simplerA trie adds nothing over a hash set when prefixes are never asked about.

Intuition

Write the words car, card, care, cat, do, dog on a page. Many start the same way: car, card and care share the prefix car, and car and cat share ca. A trie draws each shared prefix once. Starting at an empty root, each edge carries one letter, and a word is the path from the root to a node that is marked "a word ends here". The 6 words have 19 letters in total, but the trie needs only 9 letter nodes, because the shared prefixes collapse.

The mark matters. The node for ca exists because car and cat pass through it, but ca is not a stored word. Without the end mark you could not tell a word from a mere prefix. This is the most common bug in a first trie: answering search with "I found the path" instead of "I found the path and the end mark is set".

A trie holding six words A tree with an empty root and 9 letter nodes storing the words car, card, care, cat, do, dog. The words car, card and care share the path c, a, r; cat branches off at the node for ca; do and dog share d, o. Nodes drawn in amber mark the end of a stored word, so ca is on the path but is not itself a word. c a r d e t d o g

Each node is one letter. Amber nodes end a stored word; the root is empty. The diagram scrolls sideways on a narrow screen.

Mechanics

The invariant

The path from the root to any node spells a prefix of at least one stored word, and every prefix of every stored word has exactly one node. A node's children are keyed by the next letter, so two different words can only share a node if they share the whole path leading to it. An end flag on a node means "the path to here is itself a word".

Insert, search and prefix

Insert walks the word one letter at a time, creating the child when it is missing, and sets the end flag on the last node. Search walks the same way but never creates; it fails if a letter has no child, and otherwise answers with the end flag of the last node. Starts with is search without the final check: reaching the node at all is the answer. All three cost one step per letter, O(L) for a word of length L, independent of how many words are stored.

Correctness follows from the invariant. A word is stored exactly when its path exists and its last node is marked; a prefix is present exactly when its path exists. Because each node has at most one child per letter, the walk is deterministic, with no choices to try.

Wildcards and the board search

A dot that matches any letter turns the deterministic walk into a search: at a dot, try every child and succeed if any branch succeeds. That is a depth-first search on the trie, and its worst case is every node at that depth. For a grid of letters the roles reverse. Instead of testing each word separately, build a trie of all the words and run a depth-first search from each cell that carries the trie node along: step to a neighbour only if the current node has a child for that neighbour's letter. The moment no stored word continues the path, the search backs out, so one walk over the board serves every word at once. Removing a leaf from the trie after its word is found keeps the search from rediscovering the same word and shrinks the work as the board is mined out.

Three queries walking down the trie Three rows of letter boxes. The query card matches all 4 letters and the last node is marked as a word, so it is found. The query ca matches both letters but ca is only a prefix, not a word. The query cow matches only 1 letter and then has no child for o, so the walk stops and nothing starts with cow. card c a r d found as a word ca c a a prefix, not a word cow c o w no word starts with this

Green letters found a child; the amber letter is the first with none. Reaching the end of the query is not enough for a word: the node must also carry the end mark.

Worked example

The trie holds car, card, care, cat, do, dog: 9 letter nodes for 19 letters of text.

QueryLetters matchedStored word?Prefix?
car3 of 3yesyes
card4 of 4yesyes
ca2 of 2noyes
cow1 of 3nono

ca is a prefix but not a word, which is exactly why the end mark exists. The prefix car leads to a node with three words below it: car, card, care.

PatternFound?Nodes entered
ca.yes3
..gyes5
c.xno2

The pattern ca. follows c and a and then branches at the dot. The pattern ..g opens with two dots, so it branches at the first two levels and enters 5 nodes before it finds dog. A pattern with a dot early costs more than one with a dot late.

Implementation

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

    def __init__(self):
        self.children = {}          # letter -> TrieNode
        self.end = False


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

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.end = True

    def _walk(self, text):
        node = self.root
        for ch in text:
            node = node.children.get(ch)
            if node is None:
                return None
        return node

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

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


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

    def add_word(self, word):
        node = self.root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.end = True

    def search(self, pattern):
        def match(node, i):
            if i == len(pattern):
                return node.end
            if pattern[i] == ".":
                return any(match(child, i + 1) for child in node.children.values())
            child = node.children.get(pattern[i])
            return child is not None and match(child, i + 1)

        return match(self.root, 0)


def find_words(board, words):
    root = TrieNode()
    for word in words:
        node = root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.end = word                  # store the word itself, so no path rebuilding is needed

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

    def dfs(r, c, parent):
        ch = board[r][c]
        node = parent.children[ch]
        if node.end:
            found.append(node.end)
            node.end = False             # report each word once
        board[r][c] = "#"                # mark visited in place
        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] in node.children:
                dfs(nr, nc, node)
        board[r][c] = ch
        if not node.children and not node.end:
            del parent.children[ch]      # prune exhausted branches

    for r in range(rows):
        for c in range(cols):
            if board[r][c] in root.children:
                dfs(r, c, root)
    return found

A dictionary of children is the idiomatic Python choice: it handles any alphabet and only stores the letters that exist. In find_words the end flag holds the word itself, which doubles as the "is a word" test because any non-empty string is truthy.

Variations and edge cases

  • Fixed alphabet. For lowercase letters a list of 26 slots per node is faster than a dictionary but uses 26 pointers per node even when most are empty.
  • Counts per node. Store how many words pass through each node to answer "how many words have this prefix" in O(L), or to delete a word by decrementing.
  • Reversed words. Insert each word backwards to match suffixes; pairs-that-form-a-palindrome problems combine a trie of reversed words with a check on the leftover part.
  • Tokens other than letters. The edges can be directory names, so an in-memory file system is a trie whose nodes hold file contents.
  • Edge cases: the empty string (marks the root, so give the root an end flag if empty words are allowed), a word that is a prefix of another, a word inserted twice, a query longer than every stored word, and a board with repeated letters, where the in-place visited mark keeps a path from reusing a cell.

Complexity and tradeoffs

  • Time: insert, search and prefix are O(L) for a word of length L, independent of the number of stored words. A wildcard query is O(L) in the best case and can visit every node in the worst case, which is why the dots in the example above matter. The board search is O(cells × 4L) in the worst case for a word length L, but the trie prunes almost all of that in practice.
  • Space: at most one node per letter of input, here 9 nodes against 19 letters, and far fewer when prefixes are shared. The cost is the per-node overhead of a dictionary or a 26-slot array, which can exceed the cost of storing the strings directly.
  • Baseline. A hash set answers exact membership in O(L) with less memory. Scanning every word for a prefix is O(words × L). A sorted array with binary search finds a prefix in O(L log n) but cannot take new words cheaply.
  • When it fails. When only exact lookups are needed, a set is simpler and smaller. When the words are few and the prefixes short, the trie's overhead is not paid back. For huge dictionaries a compressed trie or a finite state transducer saves memory by merging single-child chains.

Problems

The first two home problems are the basic trie and its wildcard extension; the third is the board search that makes pruning pay off. The two Hard problems from the Grind list reuse the structure for palindromes and file paths.

Medium

Hard

  • 212. Word Search II Blind 75 Grind 169 NeetCode 150 NeetCode 250
    Put every word into one trie and run a single board DFS guided by it, pruning trie branches whose words have all been found.
  • 336. Palindrome Pairs Grind 169
    For each word, split it every possible way; when one part is a palindrome, look up the reverse of the other part in a map of words.
  • 588. Design In-Memory File System Grind 169
    A trie of path components, where each node is a directory or a file with content, makes ls, mkdir and file access path walks.

Also uses this pattern

These problems have a different home page, but this idea is part of their solution.

Connections

Read first

Read next

No page builds on this one yet.

A trie is a tree whose children are keyed by a letter, so the traversals of the depth-first page apply directly, and a dictionary of children is the hash map of the hash-maps page placed at every node. The word-break problems ask whether a string splits into dictionary words; a trie speeds up their inner loop by scanning forward through the string one letter at a time and stopping when the prefix dies, instead of slicing and hashing substrings. The board search is backtracking with a pruning oracle, which returns in the backtracking pages later on.

Check yourself

Why does the trie for these words have 9 letter nodes and not 19?

Words that share a prefix share the nodes of that prefix. car, card and care use one path for car, and cat reuses the nodes for ca; likewise dog reuses do. Only the first occurrence of each prefix creates nodes.

The query ca reaches a node. Why is searching for it still "no"?

The node exists because longer words pass through it, but no word ends there: its end mark is unset. A search must check that mark; a prefix query must not.

Why can the pattern ..g cost more than c.x, even though both have three characters?

A dot tries every child, so dots near the root multiply the branches that must be explored. The example entered 5 nodes for ..g and only 2 for c.x, which dies as soon as c has no child x after one dot.

In the board search, why is one trie of all the words better than a separate search per word?

The trie lets one walk of the board serve every word at once and lets the search stop the instant no stored word continues the current path. A search per word repeats the same board walk for each word, and cannot share work between words with a common prefix.

References

  • Fredkin, "Trie memory", Communications of the ACM 3(9), 1960: the paper that named the structure.
  • de la Briandais, "File searching using variable length keys", Proc. Western Joint Computer Conference, 1959: the first description of the structure.
  • Knuth, The Art of Computer Programming, volume 3, section 6.3: digital searching.
  • Sedgewick and Wayne, Algorithms, section 5.2: tries and ternary search tries.
  • Cormen, Leiserson, Rivest and Stein, Introduction to Algorithms, chapter 12 problems: radix trees.
  • Skiena, The Algorithm Design Manual, the section on string data structures.
  • The NeetCode roadmap and the Blind 75, Grind 169, NeetCode 150 and NeetCode 250 lists, used as the index of topics and problems.