COMP2521 3,010 words·16 min read

20. Tries

Why exact-key lookup is not enough#

A hash table answers “Is this complete key present?” efficiently. Autocomplete asks a different question: “Which keys begin with these characters?” Knowing that "s" exists or does not exist in a hash table tells us little about "sea" or "shore". We need a representation that makes prefixes explicit.

A prefix consists of the first zero or more characters of a string. For "shell" the prefixes are the empty string, "s", "sh", "she", "shel" and "shell". A trie stores strings in a rooted tree whose links represent characters. Following the links from the root spells a prefix. A node marked finishing or terminal records that the prefix is also a complete stored key.

The name comes from retrieval, although it is pronounced like “try”. The lecture motivates tries through autocomplete, predictive text, spellchecking and approximate string matching. Exact search, insertion and deletion are the core algorithms developed here. Approximate matching needs an additional search procedure; it does not follow automatically from an exact lookup.

Lecture trie for ace, aces, ape, apes, app, apply, early, earth and east

Read a path from the root downwards, collecting its letters. Dashed red circles mark finishing nodes. The path a → c → e spells "ace"; continuing to s spells "aces". Both are stored because both endpoints are marked. A finishing node can therefore have children. It is not the same thing as a leaf.

The root represents the empty prefix and usually stores no character. If the application permits the empty string as a key, mark the root finishing. Different stored words share nodes for their common prefix.

A BST stores a whole key at each node and compares keys to decide left/right. A trie stores keys implicitly in paths, and a node may have many children. It is not a binary search tree. The static diagrams below preserve the lecture's real multiway structures.

Practice. If "app" and "apply" are stored, must "ap" and "appl" be present as keys?

Note

- Answer
Their paths exist because they are prefixes, but they need not be marked finishing. A path proves that some key has that prefix; the finishing flag proves that the prefix itself is a stored key.

An alphabet is the set of symbols allowed in keys. Let RR be its size. With lowercase English letters, R=26R=26. Each node can contain an array of 26 child pointers, one per letter, with absent children stored as NULL.

C
#define ALPHABET_SIZE 26
struct trieNode {
    struct trieNode *children[ALPHABET_SIZE];
    bool finish;
    int data;
};

The integer payload is meaningful only at a finishing node. It could instead be a definition, a count, or a list of document locations. As always, choose an ownership policy if payloads contain pointers.

For ASCII lowercase keys, character c selects index c - 'a'. This is array indexing, not hashing. 'a' selects 0 and 'z' selects 25. Validate the alphabet before using the index: uppercase letters, punctuation and bytes from other encodings require a different representation or normalisation policy.

Concrete lecture representation uses an array of child pointers and finishing flags

The diagram expands each logical node into an array. Most entries are unused. Following a pointer in the s position corresponds to taking an s-labelled link in the conceptual tree. Notice how the logical picture is compact while the actual representation reserves space for every possible next letter.

If total character count across all input keys is KK, the trie has at most K+1K+1 nodes: each new character can create at most one node, plus the root. Shared prefixes reduce the actual node count PP. The array representation uses Θ(PR)\Theta(PR) pointer slots, hence O((K+1)R)O((K+1)R) space including the root. If R=26R=26 is fixed, this is O(K+1)O(K+1) asymptotically, but its constant is substantial.

On a machine with eight-byte pointers, 26 child pointers alone occupy 208 bytes per node, before flags, payload and padding. Reserving 128 pointers for an ASCII alphabet requires 1024 bytes per node just for child links.

Practice. Insert "sea" and "sell" into an empty trie. How many non-root nodes are needed?

Note

- Answer
Five: the distinct non-empty prefixes are s, se, sea, sel and sell. Separate paths would need 3+4=73+4=7 nodes, but the two-character shared prefix saves two. The root adds one more node, so the complete trie has six nodes.

Insertion: extend a shared path#

Start at the root and read the key from left to right. For each character, create its child only if that child is absent, then descend. After all characters, mark the current node finishing and assign the payload.

The lecture inserts sea, shell, sell, shore, she:

  1. sea creates s, se and sea; mark sea.
  2. shell reuses s, creates sh, she, shel and shell; mark shell. The intermediate she node is not yet finishing.
  3. sell reuses s and se, creates sel and sell; mark sell.
  4. shore reuses s and sh, creates sho, shor and shore; mark shore.
  5. she reuses its entire existing path and marks she finishing.

The completed insertion example shares s, se and sh prefixes and marks she separately from shell

Inserting an already present key updates the payload at its endpoint. It does not add another copy of the path. Inserting a word which is already a prefix, such as she above, may allocate no nodes at all.

The invariant after reading ii characters is that the current node represents exactly the first ii characters of the key, and every node/link created so far extends an existing correct prefix. Each next child preserves that property. On termination the current node therefore represents the whole key, so marking it finishing records precisely the desired word.

Practice. After shell is stored, what changes when inserting she? What changes when inserting shells?

Note

- Answer
For she, reuse three links and change only its endpoint's finishing flag/payload. For shells, reuse five links, create one s child of shell, and mark that new endpoint. Shell's finishing flag remains true.

Note

Checkpoint
Prefixes are shared paths. Terminal flags distinguish words from their shorter prefixes, and values belong to the terminal endpoint rather than every character node.

Search: two different ways to fail#

Follow the key's characters from the root. If the required child is absent, return false. If all characters are consumed, return the endpoint's finishing flag.

For the first dictionary diagram:

  • early follows e, a, r, l, y and ends at a finishing node: true.
  • apple follows a, p, p, l, then cannot find e: false.
  • ear follows e, a, r successfully but its endpoint is not finishing: false.

Searching apple fails at the missing e child after appl

There is an l child after app because apply exists, but its child is y, not e. Matching a long prefix does not establish a complete match.

Searching ear reaches its path but stops at a non-finishing node

The search invariant is the same prefix correspondence used for insertion. A missing link proves that no stored word has that extension. A non-finishing endpoint proves the path is merely a prefix. Only consuming the entire query at a finishing node establishes membership.

The lecture's recursive pseudocode writes return t->finish = true at this point. In C that would assign true and turn a prefix into a stored word. Correct lookup returns t->finish without modifying it.

Practice. Why does searching "ace" succeed although the endpoint is not a leaf? Why can searching "a" fail?

Note

- Answer
Ace's endpoint is marked finishing even though it has the s child used by aces. The a path exists, but its node is unmarked in this dictionary, so a itself is absent.

Deletion: remove a word without removing other words#

The simplest deletion clears the endpoint's finishing flag. This preserves all other paths but can leave dead branches: paths which lead to no finishing node and are no longer useful.

To reclaim nodes, recurse along the word, clear its finishing flag, then check nodes while returning towards the root. A node can be removed only when it is not finishing and has no children. Never remove the permanent root in this representation.

Three lecture examples reveal different cases:

  • Delete ace: clear its flag but retain its node because the s child stores aces.
  • Delete apply: remove terminal y, then remove the now-childless non-finishing l representing appl. Stop at p representing app because it is finishing.
  • Delete earth: remove h, then t representing eart. Stop at r representing ear because its l child still leads to early.

After deleting apply, its exclusive l-y branch is removed and app remains

Returning NULL to a parent makes the corresponding child pointer empty. It does not itself release allocated memory. A C implementation must free the node before returning NULL; the lecture pseudocode abstracts that release.

Complete C implementation#

This implementation accepts lowercase ASCII words, stores copied integer payloads, retains an allocated root, and permits the empty key. Invalid alphabet input is rejected before mutation. Allocation is fail-fast: the teaching program prints an error and exits rather than providing transactional recovery after a partially built path.

C
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

#define ALPHABET_SIZE 26
struct trieNode {
    struct trieNode *children[ALPHABET_SIZE];
    bool finish;
    int data;
};

static bool validWord(const char *word) {
    if (word == NULL) return false;
    for (const unsigned char *p = (const unsigned char *)word; *p; p++)
        if (*p < 'a' || *p > 'z') return false;
    return true;
}

struct trieNode *trieNew(void) {
    struct trieNode *t = calloc(1, sizeof *t);
    if (t == NULL) {
        fputs("out of memory\n", stderr);
        exit(EXIT_FAILURE);
    }
    return t;
}

bool trieInsert(struct trieNode *root, const char *word, int data) {
    if (!validWord(word)) return false;
    struct trieNode *t = root;
    for (const char *p = word; *p; p++) {
        size_t i = (size_t)(*p - 'a');
        if (t->children[i] == NULL) t->children[i] = trieNew();
        t = t->children[i];
    }
    t->finish = true;
    t->data = data;
    return true;
}

bool trieGet(const struct trieNode *root, const char *word, int *out) {
    if (!validWord(word)) return false;
    const struct trieNode *t = root;
    for (const char *p = word; *p; p++) {
        t = t->children[(size_t)(*p - 'a')];
        if (t == NULL) return false;
    }
    if (!t->finish) return false;
    if (out != NULL) *out = t->data;
    return true;
}

static bool childless(const struct trieNode *t) {
    for (size_t i = 0; i < ALPHABET_SIZE; i++)
        if (t->children[i] != NULL) return false;
    return true;
}

static struct trieNode *removeWord(struct trieNode *t,
                                   const char *word, bool isRoot) {
    if (*word == '\0') {
        t->finish = false;
    } else {
        size_t i = (size_t)(*word - 'a');
        t->children[i] = removeWord(t->children[i], word + 1, false);
    }
    if (!isRoot && !t->finish && childless(t)) {
        free(t);
        return NULL;
    }
    return t;
}

bool trieDelete(struct trieNode *root, const char *word) {
    if (!trieGet(root, word, NULL)) return false;
    (void)removeWord(root, word, true);
    return true;
}

void trieFree(struct trieNode *t) {
    if (t == NULL) return;
    for (size_t i = 0; i < ALPHABET_SIZE; i++)
        trieFree(t->children[i]);
    free(t);
}

All operations other than trieFree require the allocated root. The wrapper's trieGet establishes that the complete path exists before the deletion helper follows it, so the helper does not need an absent-child case. word + 1 points into the existing string; it does not allocate or copy the suffix. The pointer assignment after recursion is essential: if the child is freed, the parent must receive NULL.

trieFree uses postorder: free all children before freeing their parent. This is the same ownership reasoning as recursive tree/list processing. Freeing a parent first would lose access to its child pointers.

For fixed alphabet size and key length LL, insertion, worst-case lookup and deletion are O(L)O(L), including input validation. Iterative insertion/lookup use O(1)O(1) auxiliary space; insertion may allocate O(LR)O(LR) additional node storage. Recursive deletion uses O(L)O(L) stack frames and may scan RR child entries per frame, so more explicitly its time is O(LR)O(LR); with fixed R=26R=26, that becomes O(L)O(L) as in the lecture. trieFree visits all PP nodes and scans their RR pointers, taking O(PR)O(PR) time and stack depth proportional to the longest stored path.

Practice. Why not delete a childless node if it is finishing? Why retain the root after deleting the last word?

Note

- Answer
A finishing leaf still represents a stored key. Deleting it merely because it has no children would erase that key. Keeping the root preserves the valid empty-trie object expected by insertion/search and avoids replacing the caller's root pointer.

Storage variants#

The array-child representation gives constant-time next-character selection but many unused pointers. The lecture considers three ways to reduce storage, each changing how a path is represented or followed.

Linked lists of children#

Store only existing children, each with its character, node pointer and next pointer. To choose a child, scan that list for the character.

Alternatively combine the child-list entry and trie node: a node stores a character, a pointer to its first child and a pointer to its next sibling.

First-child and next-sibling pointers encode a multiway trie

Solid downward links reach a first child; horizontal dashed links walk siblings. To choose d at the top level, scan a, then b, then d. Although there are two pointer fields, they mean child and sibling, not the left and right subtrees of a BST. Do not apply binary-search comparisons or the binary-tree visualiser to this semantic structure.

If a node has bb children, finding the right child costs O(b)O(b). With alphabet size RR, worst-case lookup is O(LR)O(LR); treating RR as fixed still gives O(L)O(L) but potentially larger constants than direct indexing. Storage becomes O(P)O(P) nodes/links rather than O(PR)O(PR) child-array entries.

Practice. Why can the list representation save memory yet make an absent-character lookup slower?

Note

- Answer
It allocates links only for actual children. But to establish that a character is absent, it may examine every sibling; a direct array checks one pointer.

Alphabet reduction#

Break each eight-bit byte into two four-bit pieces called nibbles. Each nibble has 16 possibilities. Store two links per byte instead of one link chosen from 256 possibilities.

The bytes for sea split into six four-bit labels

ASCII sea becomes hexadecimal nibbles 7,3,6,5,6,1: s is 0x73, e is 0x65 and a is 0x61. Branching drops to 16 pointer slots per node, but a three-byte word has a six-edge path.

Worst-case path length doubles; asymptotically lookup remains O(L)O(L) for byte length LL. A loose pointer-storage bound changes from about 256L256L per unshared byte path to about 16(2L)=32L16(2L)=32L, before allowing for prefix sharing and node overhead. This comparison is against a full-byte alphabet, not the earlier 26-letter alphabet; savings depend on the representation being replaced.

Practice. How many children can a nibble node have, and where is the finishing flag for a two-byte key?

Note

- Answer
At most 16 children. The key uses four nibble links, so its finishing flag is at the node reached after all four, not halfway through the second byte.

Compressed tries#

A compressed trie merges non-branching chains into multi-character labels. Only nodes which are non-finishing and have one child can be merged without losing a key endpoint or branch decision.

Compression replaces unbranched character chains with longer labels while preserving branches and keys

If "shore" is the only key below "sho", the remaining "re" can be one label rather than two separate nodes. If "she" and "shell" are both keys, do not merge away the finishing boundary at she unless the new representation explicitly records it.

Search compares every character in an edge label before continuing. A mismatch inside the label proves absence. Insertion may have to split a label where the new key diverges or ends. Compression reduces pointer/node overhead; it does not justify skipping the characters needed to distinguish keys. Exact search remains O(L)O(L) character work under constant-time branch selection.

Note

Checkpoint
Child lists remove unused array entries; alphabet reduction trades smaller branching for longer paths; compression removes unbranched chains while preserving word endpoints. Space savings depend on the actual key distribution and alphabet.

Applications: prefixes and multiple possible letters#

Word finding in a document#

Preprocess a document by inserting its words into a trie. At each word's finishing node, store the positions of all its occurrences. Searching then follows the word once and returns its occurrence list rather than rescanning the entire document.

A document trie associates finishing words with lists of occurrence positions

The diagram's numbers belong to the word endpoint, not each character. If a word occurs three times, its path is stored once and its payload contains three locations.

Let KK be document character work used to build the index and zz the number of occurrences returned by a query of length LL. Building has at least O(K)O(K) input-reading work plus storage for locations. Lookup is O(L)O(L) for the path; materialising zz results adds Θ(z)\Theta(z). Saying “the whole query is O(L)O(L)” would omit its output.

Autocomplete#

Follow the typed prefix, such as "sh". If its path is absent, there are no completions. Otherwise traverse the subtree rooted there and report every finishing endpoint.

For the lecture sea/shell/sell/shore/she trie, "sh" reaches a node with e and o branches. The e branch reaches finishing she, then continues to shell; the o branch reaches shore. With children visited alphabetically, the completions are she, shell, shore.

The initial prefix lookup costs O(L)O(L). Reporting completions additionally visits subtree nodes and constructs output strings. With array children, visiting PsP_s subtree nodes scans RPsRP_s child entries. Include output character work as well: returning thousands of matches cannot be constant-time.

Practice. Can autocomplete return she and shell together? Why must traversal continue after a finishing node?

Note

- Answer
Yes. She is a valid completion and an ancestor of shell. Report she when its finishing flag is encountered, then continue through its children; stopping there would lose longer completions.

Predictive text#

On a phone keypad, one digit corresponds to several letters: 4 to g/h/i, 6 to m/n/o, and 3 to d/e/f. A digit sequence therefore describes several possible paths rather than one.

For 4663, the lecture examples include good, hoof, home, hood. Follow only child links allowed by the current digit, maintain all surviving prefixes, and retain finishing words after the final digit. This differs from exact search's single path.

The player below follows a small dictionary containing just those four words. The array is the unchanged digit input; the table records surviving prefixes. It deliberately uses no binary-tree picture for the multiway trie.

Predictive text in a four-word dictionary

Values: 4, 6, 6, 3. All four endpoints are finishing nodes, so all four words match 4663. Final values: 4, 6, 6, 3. Enable JavaScript to inspect each step.

If there are bb choices per digit and LL digits, a loose upper bound on potential strings is bLb^L. The trie prunes absent prefixes, but the actual number of surviving branches still matters; predictive text does not inherit exact lookup's single-path O(L)O(L) guarantee. Ranking matches by frequency needs stored frequency data and an explicit ranking rule.

The C tab implements a depth-first traversal of the same conceptual multiway trie. node is the current prefix; digits points to the unprocessed suffix. Each allowed letter selects an actual child pointer, and a null child prunes that entire branch. At the end, only finishing nodes are emitted. A small wrapper should first reject digits outside 2–9, allocate strlen(digits)+1 bytes for buffer, and pass a valid root/callback; that keeps index arithmetic safe. The callback consumes the buffer before the next candidate overwrites it.

C
#include <stddef.h>

/* Uses the trieNode from this chapter. Caller validates digits 2..9,
   and provides a buffer of strlen(digits)+1 bytes and an emit callback. */
static const char *letters[10] = {
    "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};

void predict(const struct trieNode *node, const char *digits,
             size_t depth, char *buffer,
             void (*emit)(const char *)) {
    if (*digits == '\0') {
        if (node->finish) {
            buffer[depth] = '\0';
            emit(buffer);
        }
        return;
    }
    for (const char *p = letters[*digits - '0']; *p; p++) {
        const struct trieNode *next = node->children[*p - 'a'];
        if (next == NULL) continue; /* Prune absent prefix. */
        buffer[depth] = *p;
        predict(next, digits + 1, depth + 1, buffer, emit);
    }
}

More exam-style practice#

Work each prompt before opening its answer. State the invariant or cost assumption that makes your reasoning valid.

Question 1. A trie contains car and cart. Delete car. Which nodes remain, and which terminal marker changes?

Note

- Answer
The path c→a→r→t must remain because it represents cart. Clear the terminal marker at r for car; do not free r or its ancestors while they have the child path. cart remains searchable.

Question 2. A compressed trie initially stores only car as one edge label. Insert cat. Where must the edge be split, and which two suffix labels branch afterward?

Note

- Answer
The words share ca, so split the old edge after that prefix. The shared node has two outgoing labels, r and t, each leading to a terminal word endpoint. Keeping one unsplit car label would leave no branch where the new word diverges.

Question 3. A trie stores stone, stony and stop. What work is required to enumerate all completions of prefix sto?

Note

- Answer
Follow three prefix links, then traverse the descendant subtrie and emit each terminal word. Cost is proportional to prefix length plus visited descendant nodes and output size; O(|sto|) alone accounts only for locating the prefix node, not listing completions.

Note

Checkpoint
Exact trie lookup follows one character path. Autocomplete follows a prefix then enumerates a subtree. Predictive text branches across allowed letters. Always include the work to find and output multiple matches.

For exact-key lookup comparisons, return to 17. Hash Tables and 11. AVL Trees. 21. COMP2521 Revision brings these structures together.