GUIDE · Trees

Trie patterns

tree keyed by characters; a path from root spells a prefix; shared prefixes share nodes.

lesson7 sectionsruns in browser29 asserts180 lines

At a glance

WHAT
tree keyed by characters; a path from root spells a prefix; shared prefixes share nodes.
TELL
"prefix", "autocomplete", "starts with", "dictionary of words", "many lookups against a fixed word set", "wildcard match", "word search II on a grid".
SAY
trie / prefix tree, TrieNode, children map, terminal flag (isEnd), insert / search / startsWith, prefix count, DFS collect, pruning (delete leaf nodes after a word is found).
BIG-O
insert / search / startsWith O(L) where L = word length; space O(total chars * alphabet).
NOTE
children as Map (any alphabet, iteration order), object {} (simple), or Array(26) (fastest, lowercase only).

The idea#

Picture a dictionary where every word is a path of letters, and words that start the same way share the pages until they split. "cat" and "car" share the page for "ca". You never store that shared opening twice.

A trie is a tree keyed by characters. A path from the root spells a prefix, and shared prefixes share nodes. Insert, search, and startsWith each walk O(L) steps for a word of length L. The space is proportional to the number of characters you stored times how you represent the alphabet.

Each node holds a map of children and a terminal flag, isEnd, that means a word ends here. "app" can be a word and also the prefix of "apple", so a node can be terminal and still have children. A counter on the node, bumped once for every word that passes through, answers "how many words start with this" in the same O(L) walk.

Deleting a word clears isEnd and then prunes. If a node is no longer terminal and has no children, it comes off its parent. "apple" can go away while "app" stays. Autocomplete walks to the prefix node and collects every terminal below it. That costs the length of the prefix plus the size of the subtree you read.

Why it matters#

Reach for a trie when the problem says prefix, autocomplete, starts with, or a dictionary you will query many times. A hash set answers "is this exact word present" in O(L) average, which matches a trie for a single lookup. The trie wins when the question is about the shared structure: every word with this prefix, the shortest root of this word, a wildcard dot that means any letter, a grid search that has to try many words at once.

Word search on a grid is the loud example. The dictionary goes in the trie, and each step on the board follows one child. A missing child kills the path. A dot in a search is the branch: one letter takes one child, and . tries every child, worst case O(26^dots · L). The cost lives in the wildcards.

You already use the shape when a phone suggests the rest of a contact after three letters. The contacts that share "Al" share those two nodes. The list under that node is the suggestion.

A small example#

JS
function startsWith(words, prefix) {  const root = { kids: new Map(), end: false };  for (const word of words) {    let node = root;    for (const ch of word) {      if (!node.kids.has(ch)) node.kids.set(ch, { kids: new Map(), end: false });      node = node.kids.get(ch);    }    node.end = true;  }  let node = root;  for (const ch of prefix) {    node = node.kids.get(ch);    if (!node) return false;  }  return true;} const hasCat = startsWith(['cat', 'car', 'dog'], 'ca');

hasCat is true. "cat" and "car" share the nodes for c and a. The walk for "ca" never looks at "dog".

What the code below is doing#

Section 1 is TrieNode and Trie. Children are a Map. isEnd marks a word. count is how many inserted words pass through the node. insert, search, startsWith, and countPrefix are each a walk of length L. delete is recursive: it drops the count on the way down, clears isEnd at the end, and removes a child that is empty and not a word of its own. Deleting a missing word does nothing. Deleting "apple" leaves "app" in place.

Section 2 is autocomplete, wordsWithPrefix. Walk to the prefix. If it is missing, return an empty list. Otherwise depth-first collect every isEnd below, emitting a word before the longer words that extend it. Cost is O(prefix plus the size of that subtree).

Section 3 is word search II. Build a trie of the dictionary, then depth-first search the grid, stepping only into a child that matches the cell. A cell marked # is on the current path. On isEnd, record the word and clear the flag so a duplicate board path does not report it twice. The comment also names the optional prune of leaf nodes. Put the letter back on the way out. Worst case sits near O(m · n · 4 · 3^(L - 1)).

Section 4 builds the longest common prefix. Insert every word, then walk while the node has exactly one child and is not itself the end of a word. Stop when the words diverge or when one word ends. O(total characters). Section 5 replaces each word in a sentence with the shortest dictionary root that is a prefix of it. Walk until the first isEnd, and if the walk falls off the trie, keep the original word. O(total characters).

Section 6 is a dictionary that supports . as one wildcard character. addWord is a normal insert. search is a depth-first walk: a letter takes that child, a dot tries every child. Worst case O(26^dots · L). Section 7 is the prefix counter you already met on the node. Insert bumps count on every node the word passes through, and countPrefix reads that number in O(L).

Section 8 is the space note to say when someone asks. Map for any alphabet. A plain object for simple lowercase, with Object.create(null) so inherited keys stay out. Array(26) for speed and compactness on a through z, at the price of 26 slots on every node.

When you talk about it, say this: shared prefixes share nodes, and insert, search, and startsWith are O(L) in the length of the word.

Everything below is the demonstration. Same idea, written as code you can run.

JSalgorithms/tries.js · setup
'use strict';const assert = require('node:assert/strict');
01

TrieNode / Trie#

insert, search, startsWith, delete (recursive, prunes empty nodes)

O(L) each
JS
class TrieNode {  children = new Map(); // char -> TrieNode  isEnd = false;  count = 0; // number of words passing through (for prefix counts)}class Trie {  root = new TrieNode();  insert(word) {    let n = this.root;    for (const c of word) {      if (!n.children.has(c)) n.children.set(c, new TrieNode());      n = n.children.get(c);      n.count++;    }    n.isEnd = true;  }  #walk(prefix) { // node at end of prefix, or null    let n = this.root;    for (const c of prefix) { n = n.children.get(c); if (!n) return null; }    return n;  }  search(word) { return this.#walk(word)?.isEnd ?? false; }  startsWith(prefix) { return this.#walk(prefix) !== null; }  countPrefix(prefix) { return this.#walk(prefix)?.count ?? 0; }  delete(word) {    const rec = (n, i) => { // returns true if n should be removed from its parent      if (i === word.length) { if (!n.isEnd) return false; n.isEnd = false; return n.children.size === 0; }      const child = n.children.get(word[i]);      if (!child) return false;      child.count--;      if (rec(child, i + 1)) n.children.delete(word[i]);      return !n.isEnd && n.children.size === 0;    };    rec(this.root, 0);  }  // ── 2. Autocomplete  —  walk to prefix, DFS collect every isEnd below  —  O(prefix + size of subtree)  wordsWithPrefix(prefix) {    const start = this.#walk(prefix), out = [];    if (!start) return out;    const dfs = (n, acc) => {      if (n.isEnd) out.push(acc);      for (const [c, child] of n.children) dfs(child, acc + c);    };    dfs(start, prefix);    return out;  }}const t = new Trie();['apple', 'app', 'apt', 'bat'].forEach((w) => t.insert(w));t.delete('apple');t.delete('nothere'); // no-op
Verified by 11 asserts
JS
assert.equal(t.search('app'), true);assert.equal(t.search('ap'), false);assert.equal(t.startsWith('ap'), true);assert.equal(t.search(''), false);assert.deepEqual(t.wordsWithPrefix('ap'), ['app', 'apple', 'apt']); // pre-order: a word is emitted before its extensionsassert.deepEqual(t.wordsWithPrefix('z'), []);assert.equal(t.countPrefix('ap'), 3);assert.equal(t.search('apple'), false);assert.equal(t.search('app'), true); // prefix word survivesassert.equal(t.countPrefix('ap'), 2);assert.equal(t.countPrefix('b'), 1);
03

Word search II idea (trie + grid DFS)#

build trie of words; DFS grid walking trie nodes; mark cell visited; collect on isEnd

JS
//        PRUNE: set isEnd=false after finding (no dups); optionally delete leaf nodes to shrink the trie. O(mn * 4 * 3^(L-1)) worst.function findWords(board, words) {  const trie = new Trie();  words.forEach((w) => trie.insert(w));  const m = board.length, n = m ? board[0].length : 0, out = [];  const dfs = (r, c, node, acc) => {    if (r < 0 || c < 0 || r >= m || c >= n || board[r][c] === '#') return;    const ch = board[r][c], next = node.children.get(ch);    if (!next) return;    if (next.isEnd) { out.push(acc + ch); next.isEnd = false; }    board[r][c] = '#'; // visited    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) dfs(r + dr, c + dc, next, acc + ch);    board[r][c] = ch; // un-choose  };  for (let r = 0; r < m; r++) for (let c = 0; c < n; c++) dfs(r, c, trie.root, '');  return out.sort();}
Verified by 3 asserts
JS
assert.deepEqual(findWords([['o', 'a', 'a', 'n'], ['e', 't', 'a', 'e'], ['i', 'h', 'k', 'r'], ['i', 'f', 'l', 'v']], ['oath', 'pea', 'eat', 'rain']), ['eat', 'oath']);assert.deepEqual(findWords([['a']], ['a', 'b']), ['a']);assert.deepEqual(findWords([], ['a']), []);
04

Longest common prefix via trie#

walk down while exactly one child and not isEnd

O(total chars)
JS
function longestCommonPrefix(words) {  if (words.length === 0) return '';  const trie = new Trie();  words.forEach((w) => trie.insert(w));  let n = trie.root, out = '';  while (n.children.size === 1 && !n.isEnd) {    const [c, child] = n.children.entries().next().value;    out += c; n = child;  }  return out;}
Verified by 3 asserts
JS
assert.equal(longestCommonPrefix(['flower', 'flow', 'flight']), 'fl');assert.equal(longestCommonPrefix(['dog', 'racecar']), '');assert.equal(longestCommonPrefix(['a']), 'a');
05

Replace words (shortest root in dictionary)#

walk each word; stop at first isEnd

O(total chars)
JS
function replaceWords(dict, sentence) {  const trie = new Trie();  dict.forEach((w) => trie.insert(w));  const shortestRoot = (w) => {    let n = trie.root, acc = '';    for (const c of w) {      n = n.children.get(c);      if (!n) return w;      acc += c;      if (n.isEnd) return acc;    }    return w;  };  return sentence.split(' ').map(shortestRoot).join(' ');}
Verified by 3 asserts
JS
assert.equal(replaceWords(['cat', 'bat', 'rat'], 'the cattle was rattled by the battery'), 'the cat was rat by the bat');assert.equal(replaceWords(['a', 'aa'], 'aaa aab'), 'a a');assert.equal(replaceWords([], 'x y'), 'x y');
06

Add and search word with '.' wildcard#

DFS; on '.', try every child

O(26^dots * L) worst
JS
class WordDictionary {  #root = new TrieNode();  addWord(w) {    let n = this.#root;    for (const c of w) { if (!n.children.has(c)) n.children.set(c, new TrieNode()); n = n.children.get(c); }    n.isEnd = true;  }  search(w) {    const dfs = (n, i) => {      if (i === w.length) return n.isEnd;      if (w[i] === '.') { for (const child of n.children.values()) if (dfs(child, i + 1)) return true; return false; }      const child = n.children.get(w[i]);      return child ? dfs(child, i + 1) : false;    };    return dfs(this.#root, 0);  }}const wd = new WordDictionary();['bad', 'dad', 'mad'].forEach((w) => wd.addWord(w));
Verified by 4 asserts
JS
assert.equal(wd.search('pad'), false);assert.equal(wd.search('.ad'), true);assert.equal(wd.search('b..'), true);assert.equal(wd.search('b...'), false);
07

Count words with given prefix#

per-node counter incremented on insert (see Trie.countPrefix)

O(L)
JS
const t2 = new Trie();['pay', 'attention', 'practice', 'attend'].forEach((w) => t2.insert(w));
Verified by 3 asserts
JS
assert.equal(t2.countPrefix('at'), 2);assert.equal(t2.countPrefix('p'), 2);assert.equal(t2.countPrefix('zz'), 0);
08

Children representation note (say it when asked about space)#

JS
//   Map            : any alphabet (unicode), predictable iteration, ~most memory per node.//   object {}      : simplest to write; fine for lowercase; watch out for inherited keys (use Object.create(null) or Map).//   Array(26)      : children[c.charCodeAt(0) - 97]; fastest and most compact for lowercase a-z; wastes 26 slots per node.class ArrayTrieNode { children = new Array(26).fill(null); isEnd = false; }const atn = new ArrayTrieNode();atn.children['c'.charCodeAt(0) - 97] = new ArrayTrieNode();
Verified by 2 asserts
JS
assert.ok(atn.children[2] instanceof ArrayTrieNode);assert.equal(atn.children[0], null);

See also