← Data structures & algorithms
Trees Find everything that starts the same way

Trie

Suggest from the first letters.

Type a colon in a GitHub comment and a list of emoji appears, narrowing with every letter: :th offers 👍 and 🤔 long before you finish thumbsup. In your server, the Go router httprouter and Fastify’s find-my-way match every request path in a radix tree, a compressed trie. Let’s build the structure that makes “everything starting with these letters” one walk down a tree, rank a team’s emoji by use, and measure what all those nodes cost.

TypeScriptGoOne emoji index, two implementations.

01 / The idea

Suggest what starts with what was typed.

GitHub’s documentation puts it plainly: “Typing : will bring up a list of suggested emoji. The list will filter as you type.” The box has a list of shortcodes, and on every keystroke it needs every one that begins with the letters so far.

A trie, also called a prefix tree, stores keys one character per level. The root stands for nothing typed; each child adds one character. tada and taco share the nodes for t and a, then branch. A node is marked when a key ends there, so smile can end on a node that smiley continues from. Everything that starts with th hangs below one node.

René de la Briandais described the idea in 1959, and Edward Fredkin named it in 1960, from the middle of “retrieval.” It still carries traffic: httprouter uses “a compressing dynamic trie (radix tree) structure,” find-my-way’s README says it is built on a radix tree, also called a compact prefix tree, Redis ships its own radix tree, rax, and Linux looks up IPv4 routes in an LC-trie. GitHub documents the behavior, not how its box is built; this lesson builds one way to provide it.

02 / Name the rule

Share the start, branch at the difference.

To add a shortcode, walk down from the root one character at a time. Follow a child that already exists and create the ones that do not, then mark the last node. To find everything that starts with a prefix, walk the prefix; if a character is missing, nothing matches. Otherwise every marked node below is a match.

The walk costs the length of what was typed, not the number of shortcodes. Checking :th reads two nodes whether the index holds nine shortcodes or 1,913. What comes after the walk depends on how much sits below, and that is where this lesson’s costs live.

Removing is the mirror image. Clear the mark, then delete nodes back toward the root while they end no shortcode and lead to none. Stop at the first node that still matters, or the trie deletes someone else’s prefix.

Why not keep shortcodes in a hash map?A hash only answers whole keys

A hash map finds thumbsup in one step, and this lesson’s index keeps one for glyphs and uses. But hashing th tells you nothing about thumbsup: similar keys land in unrelated buckets on purpose. To suggest from a hash map you would read every key.

A trie keeps keys that start alike in the same place, which is exactly the property a hash throws away. The Aho–Corasick lesson builds the same shared states for a different question: finding every glossary term inside a page of text.

03 / Follow one operation

Nine shortcodes, one team.

A team’s emoji index holds nine shortcodes with a month of uses: 👍 thumbsup 300, 😄 smile 140, 🎉 tada 120, 🤔 thinking 95, and five used less. Someone adds 🫖 teapot, types :th and :te, removes ⛺ tent, and sends a message.

Before you watch, predict how many nodes adding teapot creates, and in what order the box lists tea, teapot, and tent. The animation replays what the TypeScript example recorded. Try it lets you type, add, and remove your own.

Trie

Walk down, collect below.

Trie · one node per character; an emoji marks where a shortcode ends

9 shortcodes

  • s
    • mile😄y😃
    • weat😓
  • t
    • a
      • co🌮
      • da🎉
    • e
      • a🍵
      • nt⛺
    • h
      • inking🤔
      • umbsup👍

Shortcodes 9 Nodes 33 Walked 0 Visited 0 Created 0 Pruned 0

01/ 06
Nine shortcodes, 33 nodes

Nine shortcodes share 33 nodes.

47 characters, 33 nodes. tada and taco share t and a, and smiley runs on from smile, whose node is marked because a shortcode ends there.

Reduced motion: choose a scene to see its completed state.

Read this scene

47 characters, 33 nodes. tada and taco share t and a, and smiley runs on from smile, whose node is marked because a shortcode ends there.

47 characters, 33 nodes. tada and taco share t and a, and smiley runs on from smile, whose node is marked because a shortcode ends there.

Nodes: 33. Walked so far: 0. Visited so far: 0.

Watch restarts when you return. Step through keeps your selected step. Try it starts from the team’s nine shortcodes each time you open it.

04 / Read the shape

One trie, one emoji index.

Basic form is Trie: insert, check a word or a prefix, complete a prefix in character order up to a limit, and remove with pruning, recording every node it walks, creates, visits, or prunes. In the wild wraps it in EmojiIndex, which validates shortcodes, glyphs, and uses, suggests after two characters by ranking every match by use, counts uses, adds and removes custom emoji, and turns known shortcodes in a message into emoji.

A trie of words, one node per character with a map of children. Insert, check a word or a prefix, complete a prefix in character order up to a limit, and remove a word while pruning the nodes only it used. Every step can be recorded.

TypeScriptReading
emoji.ts
export type Step = {
	/**
	 * walk: follow a child that exists. miss: the child is not there. create: add a node.
	 * mark or unmark: a word ends here, or no longer does. visit: look at a node while
	 * completing. emit: report a word. prune: delete a node no word uses.
	 */
	kind: 'walk' | 'miss' | 'create' | 'mark' | 'unmark' | 'visit' | 'emit' | 'prune';
	/** The characters from the root to the node involved. */
	path: string;
};

type Node = { children: Map<string, Node>; end: boolean };
const newNode = (): Node => ({ children: new Map(), end: false });
const byCodePoint = (a: string, b: string) => a.codePointAt(0)! - b.codePointAt(0)!;

// A trie stores words one character per level, from a shared root. Words that start the same
// share the same first nodes, so everything starting with a prefix hangs below one node: walk
// down the prefix, then read what is below. The walk costs the prefix's length, however many
// other words the trie holds.
export class Trie {
	#root = newNode();
	#size = 0;
	#nodes = 0;

	// Returns false when the word was already there.
	insert(word: string, steps?: Step[]): boolean {
		let at = this.#root;
		let path = '';
		for (const ch of word) {
			path += ch;
			let next = at.children.get(ch);
			if (next) steps?.push({ kind: 'walk', path });
			else {
				next = newNode();
				at.children.set(ch, next);
				this.#nodes++;
				steps?.push({ kind: 'create', path });
			}
			at = next;
		}
		if (at.end) return false;
		at.end = true;
		this.#size++;
		steps?.push({ kind: 'mark', path: word });
		return true;
	}

	has(word: string, steps?: Step[]): boolean {
		return this.#walk(word, steps)?.end ?? false;
	}

	hasPrefix(prefix: string, steps?: Step[]): boolean {
		return this.#walk(prefix, steps) !== null;
	}

	/** Words that start with the prefix, in character order, shorter first, up to `limit`. */
	complete(prefix: string, limit: number, steps?: Step[]): string[] {
		if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
			throw new RangeError('limit must be a whole number from 1 to 10000');
		const found: string[] = [];
		const visit = (at: Node, path: string): boolean => {
			steps?.push({ kind: 'visit', path });
			if (at.end) {
				found.push(path);
				steps?.push({ kind: 'emit', path });
				if (found.length === limit) return true;
			}
			for (const ch of [...at.children.keys()].sort(byCodePoint))
				if (visit(at.children.get(ch)!, path + ch)) return true;
			return false;
		};
		const start = this.#walk(prefix, steps);
		if (start) visit(start, prefix);
		return found;
	}

	// Remove a word, then delete the nodes only it used, from its last character back toward
	// the root, stopping at a node that ends another word or leads to one. Returns false when
	// the word was not there.
	remove(word: string, steps?: Step[]): boolean {
		const trail: { parent: Node; ch: string; child: Node; path: string }[] = [];
		let at = this.#root;
		let path = '';
		for (const ch of word) {
			path += ch;
			const next = at.children.get(ch);
			if (!next) {
				steps?.push({ kind: 'miss', path });
				return false;
			}
			steps?.push({ kind: 'walk', path });
			trail.push({ parent: at, ch, child: next, path });
			at = next;
		}
		if (!at.end) return false;
		at.end = false;
		this.#size--;
		steps?.push({ kind: 'unmark', path: word });
		for (const { parent, ch, child, path: pruned } of trail.reverse()) {
			if (child.end || child.children.size > 0) break;
			parent.children.delete(ch);
			this.#nodes--;
			steps?.push({ kind: 'prune', path: pruned });
		}
		return true;
	}

	/** Every word, in character order. */
	words(): string[] {
		const found: string[] = [];
		const collect = (at: Node, path: string) => {
			if (at.end) found.push(path);
			for (const ch of [...at.children.keys()].sort(byCodePoint))
				collect(at.children.get(ch)!, path + ch);
		};
		collect(this.#root, '');
		return found;
	}

	get size(): number {
		return this.#size;
	}

	/** Nodes below the root: one per distinct prefix of the words. */
	get nodeCount(): number {
		return this.#nodes;
	}

	#walk(prefix: string, steps?: Step[]): Node | null {
		let at = this.#root;
		let path = '';
		for (const ch of prefix) {
			path += ch;
			const next = at.children.get(ch);
			if (!next) {
				steps?.push({ kind: 'miss', path });
				return null;
			}
			steps?.push({ kind: 'walk', path });
			at = next;
		}
		return at;
	}
}
GoAlongside
emoji.go
// Step is one move. Kind is "walk" (follow a child that exists), "miss" (the child is not
// there), "create" (add a node), "mark" or "unmark" (a word ends here, or no longer does),
// "visit" (look at a node while completing), "emit" (report a word), or "prune" (delete a
// node no word uses). Path is the characters from the root to the node involved.
type Step struct {
	Kind string `json:"kind"`
	Path string `json:"path"`
}

type node struct {
	children map[rune]*node
	end      bool
}

func newNode() *node { return &node{children: map[rune]*node{}} }

func record(steps *[]Step, kind, path string) {
	if steps != nil {
		*steps = append(*steps, Step{kind, path})
	}
}

// Trie stores words one character per level, from a shared root. Words that start the
// same share the same first nodes, so everything starting with a prefix hangs below one
// node: walk down the prefix, then read what is below. The walk costs the prefix's length,
// however many other words the trie holds.
type Trie struct {
	root  *node
	size  int
	nodes int
}

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

// Insert returns false when the word was already there.
func (t *Trie) Insert(word string, steps *[]Step) bool {
	at, path := t.root, ""
	for _, ch := range word {
		path += string(ch)
		next, ok := at.children[ch]
		if ok {
			record(steps, "walk", path)
		} else {
			next = newNode()
			at.children[ch] = next
			t.nodes++
			record(steps, "create", path)
		}
		at = next
	}
	if at.end {
		return false
	}
	at.end = true
	t.size++
	record(steps, "mark", word)
	return true
}

func (t *Trie) Has(word string, steps *[]Step) bool {
	at := t.walk(word, steps)
	return at != nil && at.end
}

func (t *Trie) HasPrefix(prefix string, steps *[]Step) bool {
	return t.walk(prefix, steps) != nil
}

// Complete returns words that start with the prefix, in character order, shorter first,
// up to limit.
func (t *Trie) Complete(prefix string, limit int, steps *[]Step) ([]string, error) {
	if limit < 1 || limit > 10_000 {
		return nil, errors.New("limit must be a whole number from 1 to 10000")
	}
	found := []string{}
	var visit func(at *node, path string) bool
	visit = func(at *node, path string) bool {
		record(steps, "visit", path)
		if at.end {
			found = append(found, path)
			record(steps, "emit", path)
			if len(found) == limit {
				return true
			}
		}
		for _, ch := range sortedKeys(at) {
			if visit(at.children[ch], path+string(ch)) {
				return true
			}
		}
		return false
	}
	if start := t.walk(prefix, steps); start != nil {
		visit(start, prefix)
	}
	return found, nil
}

// Remove removes a word, then deletes the nodes only it used, from its last character back
// toward the root, stopping at a node that ends another word or leads to one. It returns
// false when the word was not there.
func (t *Trie) Remove(word string, steps *[]Step) bool {
	type link struct {
		parent, child *node
		ch            rune
		path          string
	}
	var trail []link
	at, path := t.root, ""
	for _, ch := range word {
		path += string(ch)
		next, ok := at.children[ch]
		if !ok {
			record(steps, "miss", path)
			return false
		}
		record(steps, "walk", path)
		trail = append(trail, link{at, next, ch, path})
		at = next
	}
	if !at.end {
		return false
	}
	at.end = false
	t.size--
	record(steps, "unmark", word)
	for _, l := range slices.Backward(trail) {
		if l.child.end || len(l.child.children) > 0 {
			break
		}
		delete(l.parent.children, l.ch)
		t.nodes--
		record(steps, "prune", l.path)
	}
	return true
}

// Words returns every word, in character order.
func (t *Trie) Words() []string {
	found := []string{}
	var collect func(at *node, path string)
	collect = func(at *node, path string) {
		if at.end {
			found = append(found, path)
		}
		for _, ch := range sortedKeys(at) {
			collect(at.children[ch], path+string(ch))
		}
	}
	collect(t.root, "")
	return found
}

func (t *Trie) Size() int { return t.size }

// NodeCount counts nodes below the root: one per distinct prefix of the words.
func (t *Trie) NodeCount() int { return t.nodes }

func (t *Trie) walk(prefix string, steps *[]Step) *node {
	at, path := t.root, ""
	for _, ch := range prefix {
		path += string(ch)
		next, ok := at.children[ch]
		if !ok {
			record(steps, "miss", path)
			return nil
		}
		record(steps, "walk", path)
		at = next
	}
	return at
}

func sortedKeys(at *node) []rune {
	keys := make([]rune, 0, len(at.children))
	for ch := range at.children {
		keys = append(keys, ch)
	}
	slices.Sort(keys)
	return keys
}
Reading the TypeScriptA Map per node, code points, a trail

Each node is { children: Map<string, Node>, end: boolean }. for (const ch of word) iterates by code point, so an emoji in a key is one character, not two UTF-16 halves. Children are sorted by code point before a completion visits them, because a Map keeps insertion order.

remove pushes each parent, character, and child onto a trail on the way down, then walks the trail backward to prune. suggest asks the trie for every match, then sorts by uses and shortcode and slices five.

Reading the GoRune maps and a backward range

Children are a map[rune]*node, and range over a string yields runes. Go maps have no order, so sortedKeys sorts a node’s runes before every completion; without it, suggestions would change from run to run.

Remove prunes with range slices.Backward(trail). Suggest counts characters with utf8.RuneCountInString, and errors are values: a bad limit returns an error, never a panic.

What would I normally use in application code?Often not a trie at all

Neither TypeScript nor Go ships a trie. For a fixed list of a few thousand shortcodes, a sorted array with binary search is smaller and, as the cost section shows, does less work. A plain filter with startsWith is fine for a few hundred.

When you do route requests by path, you already use a trie through your router. Reach for your own when keys change often, when you need the longest prefix of an input that is a key, or when prefixes themselves are the data, as in a dictionary of words.

05 / Try a decision

Five suggestions, but which five?

The box shows five. It is tempting to let the trie stop as soon as it has found five. Decide what that does to a real list before the feedback tells you.

Your team’s box now suggests from all of gemoji, not just the nine shortcodes in the animation. Nine of gemoji’s 1,913 shortcodes start with th, and your team still uses 👍 thumbsup more than any other emoji. A teammate wants the box to do less work, since it only shows five. What should it do?

06 / Follow the cost

12,142 nodes for 1,913 shortcodes.

Here is every operation at a glance. The rest of this section measures the trie on gemoji, GitHub’s open-source emoji list: 1,870 emoji with 1,913 shortcodes.

Emoji trie: time and extra space
OperationTimeExtra spaceWhat it assumes
Add a shortcodeO(L)O(L)L is its length. Follow the nodes that exist and create at most L new ones.
Check a whole shortcodeO(L)O(1)Walk down; the last node must be marked. The other shortcodes never matter.
Suggest for a prefixO(p + m + k log k)O(k)Walk p characters, visit the m nodes below, then rank the k matches. Each visited node also sorts its children, which stays small for a shortcode alphabet.
Remove a shortcodeO(L)O(L)Keep the path on the way down, clear the mark, prune back up.
Hold n shortcodesO(N) to buildO(N) nodesN is their total length. Shared prefixes save nodes, but every node carries a map of children.
Sorted array insteadO(p log n + k) to suggestO(N)Binary search finds the first match, and the rest sit next to it. Adding one shifts up to n entries.

The 1,913 shortcodes have 19,526 characters between them, and the trie holds 12,142 nodes: sharing prefixes saved 38 percent. In the animation, :th walked two nodes and visited 13. On gemoji, :th walks two and visits 59 to collect nine matches, and the busiest two-letter prefix, :ma, visits 528 for 74 matches. Filtering the whole list reads all 1,913 shortcodes on every keystroke.

A sorted array is the honest comparison. Binary search finds the first shortcode that starts with th in 11 probes, and the nine matches sit right after it: 21 reads, against the trie’s 61. Over all 12,110 prefixes someone could type on the way to a shortcode, the trie averaged 17.3 nodes and the sorted array 13.4 reads. The trie visits every node on the way to a match, not just the matches.

Memory is the larger gap. In one Node.js 22 run, the lesson’s trie took about 2.7 MB of heap, and an array of the same strings about 72 KB, some 38 times less, because each of the 12,142 nodes is an object with its own Map. A radix tree that merges single-child runs, as routers do, needs 2,620 nodes for the same list. These are counts and one rough heap measurement from the lesson’s TypeScript example, not timings.

So for a fixed list this size, the sorted array wins. The trie earns its place when the keys change, since adding a shortcode to a sorted array shifts everything after it; when an input must be matched against the longest key that is its prefix, as routers do; and when the nodes are compressed.

07 / Give it a real job

Collect, then rank.

EmojiIndex keeps the trie for “what starts with this” and a map for “how often is this used.” It suggests nothing until two characters are typed, folds capitals to lowercase, collects every match, and ranks by uses, then by shortcode, so equal counts always come out in the same order.

Real composers add decisions. Uses can be counted per person or per team, and a new teammate’s box looks different from a veteran’s. Custom emoji arrive after the page loads, so the index has to accept additions while someone is typing. And :nope: in a message should stay as typed, not vanish, which this index does.

Build UIs?See where prefix lookup already runs, and the day you own the mention list.

Where it already is in your components

Type a colon in a GitHub comment box and the emoji list narrows with every letter. That is this job done for you, and so is any box in your app that narrows commands, tags, or labels as you type. The structure behind each is not always a trie; the question it answers is always a prefix.

If your server runs on httprouter or Fastify’s find-my-way, the routes you declare already live in a radix tree. Both READMEs say so.

When you have to own it

Now your team chat has 20,000 people in it, and typing @sa should offer Sam Okafor and Aiko Sato. A startsWith over full names finds Sam and misses Aiko, because her surname is the part that matches. So file each person under every word of their name and under their handle, all in one trie. The walk for sa is two nodes long whether the workspace holds 200 people or 20,000, and everyone who matches hangs below it.

Then it gets real. Nobody waits for 20,000 people to download before the box opens, so members arrive a page at a time and join the index as each page lands. Sara Sandoval is filed under sara and sandoval, so @sa reaches her twice, and she should appear once. Rank the matches by who you talked to most recently, then by name, and show eight. And the typing happens mid-sentence: find the @ before the caret, let arrow keys, Enter, and Escape work without leaving the text, and put the handle where the letters were.

Suggestions for what follows the @: each member filed under every word of their name and their handle in one trie, matched once, most recent first, eight at most.

ReactAlready in your code
MentionSuggestions.tsx
import { useMemo } from 'react';
import { PrefixIndex } from './prefix-index';
import type { Member } from './members';

const LIMIT = 8;

export function MentionSuggestions({
	members,
	query,
	onPick
}: {
	members: Member[];
	/** What follows the @, for example "sa". */
	query: string;
	onPick: (member: Member) => void;
}) {
	// File every member under each word of their name and under their handle, so Aiko Sato
	// sits under aiko, sato, and asato.
	const index = useMemo(() => {
		const index = new PrefixIndex();
		for (const member of members)
			for (const token of [...member.name.split(/\s+/), member.handle]) index.add(token, member.id);
		return index;
	}, [members]);
	const byId = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);

	const suggestions = useMemo(() => {
		// A bare @ would gather all 20,000. Wait for a letter.
		if (!query) return [];
		// "sa" walks two nodes and gathers the branch below: Sam Okafor under sam, Aiko Sato
		// under sato. Sara Sandoval is under sara and sandoval, so the Set keeps her once.
		return [...new Set(index.match(query))]
			.map((id) => byId.get(id)!)
			.sort((a, b) => b.lastInteraction - a.lastInteraction || a.name.localeCompare(b.name))
			.slice(0, LIMIT);
	}, [index, byId, query]);

	if (!suggestions.length) return null;
	return (
		<ul aria-label="People to mention, most recent first">
			{suggestions.map((member) => (
				<li key={member.id}>
					<button type="button" onClick={() => onPick(member)}>
						{member.name} <span>@{member.handle}</span>
					</button>
				</li>
			))}
		</ul>
	);
}

08 / Make the call

Ask whether prefixes are the question.

Reach for a trie when you ask about beginnings: every key that starts with this, the longest key that starts this input, or whether anything starts this way at all. It answers in time set by the prefix, and it takes additions and removals without reorganizing the rest.

Look elsewhere when the question changes. For whole keys only, a hash map is smaller and faster. For a fixed list, a sorted array and binary search answer prefixes with less memory. To find many terms anywhere inside a text, not only at the start, use Aho–Corasick, which adds failure links to a trie. And a trie cannot forgive a typo: suggesting thumbsup for thumsup needs edit distance.

09 / Take the idea with you

Explain it without saying “trie.”

“I keep every shortcode as a path of letters from one starting point, and shortcodes that begin the same way share the same first steps. To suggest, I follow the letters typed so far and gather every shortcode that ends below, then sort them by how often they are used. To remove one, I erase its end mark and cut back the steps no other shortcode needs.” That describes the mechanism. The name is what you call it in a review.

Before moving on, explain three things without the name: why checking :th reads two nodes whatever the list size, why stopping after five matches would hide thumbsup, and why a sorted array beat the trie on gemoji. Then find a place in your code that filters a list with startsWith on every keystroke, and decide which of the two it should become, if either.

Connections to follow nextRelated lessons
  • Aho–Corasick builds a trie of terms and adds failure links to find them all inside a text.
  • Binary search answers the same prefix question over a sorted array, with far less memory.
  • Hash map holds each shortcode’s glyph and uses here, for whole-key lookups.
  • Levenshtein distance suggests what someone meant when a prefix has a typo.