Trees & Tries
The shape behind file systems, the DOM and every database index: traversals, binary search trees, why they need balancing (rotations, AVL, red-black), tries for prefixes and autocomplete, and when a plain sorted slice beats them all — in Go and JavaScript.
The 30-second version
You already use trees every day, even if nobody called them that:
- Folders on your laptop.
/home/alice/notes.txt: a folder contains folders and files, which contain more. - A web page. The browser turns your HTML into the DOM, a tree of elements:
<body>holds<nav>and<main>, which hold links and paragraphs. - JSON. Objects inside objects inside arrays.
- An org chart or a family tree. One person at the top, branching down.
- Your code. Before a compiler, linter or bundler does anything with
total = price * qty + tax, it turns it into a tree called an AST.
Why a whole topic on trees?
Because one shape does three very different jobs:
Hierarchy
Fast sorted lookup
Prefixes
The catch in job 2 is the most important idea in this topic: a tree is only fast if it is short. Feed a naive sorted tree already-sorted data and it degrades into a long chain, as slow as scanning a list. Balancing tricks (§07) exist to stop that from happening.
Root, leaf, depth, height
Here are the words you'll meet in every tree discussion. Each one is shown in the widget below, so click around while you read.
- A node is one box: some data plus links to its children.
- The root is the one node with no parent. A node's children hang directly below it, and it is their parent. A leaf has no children.
- The depth of a node is how many links you follow from the root to reach it. The root has depth 0.
- The height of a tree is how many levels it has. A tree that's just a root has height 1. A lookup that walks from the root downward visits at most height nodes, which is why height decides speed.
- A subtree is any node plus everything below it. Every child is the root of its own smaller tree.
The one idea that makes tree code short
Look at the widget again: pick any node, and its subtree is a complete tree in its own right. So a question about a whole tree (“how many files?”, “what's the total size?”) can be answered by asking the same question of each child's subtree and combining the answers.
A function that solves a problem by calling itself on smaller pieces is called Recursion. Nearly every tree algorithm in this topic is three lines of recursion. The Composite pattern is this same idea dressed up as a design pattern: a folder answers Size() by summing its children's Size().
What a node looks like in code
A general tree node holds a value and a list of children. A binary tree is the special case where every node has at most two children, called Left and Right; that's the shape behind the search trees in §04.
// Any number of children (a folder, a DOM element)
type Dir struct {
Name string
Children []*Dir // a slice of pointers to other nodes
}
// At most two children (a binary tree)
type Node struct {
Val int
Left, Right *Node // nil = "no child here"
}
// Height in levels: recursion in three lines
func height(n *Node) int {
if n == nil {
return 0 // an empty tree has no levels
}
return 1 + max(height(n.Left), height(n.Right))
}Notice how height works: an empty tree has 0 levels; any other tree has its own level plus the taller of its two subtrees. You'll see this shape (“handle nil, then recurse left and right”) in almost every snippet that follows.
Walking a tree: four orders
Depth-first traversals (DFS) all follow the same recipe at every node: handle the left subtree, handle the right subtree, and handle this node somewhere in between. Where you put “this node” gives the three names:
| Order | At each node | What it's good for |
|---|---|---|
| pre-order | me → left → right | Copying or saving a tree: every parent comes out before its children, so you can rebuild it top-down. Printing a folder listing. |
| in-order | left → me → right | On a search tree (§04), this prints the keys sorted. |
| post-order | left → right → me | Anything that needs the children's answers first: folder sizes, deleting a tree, evaluating price * qty + tax. |
| level-order | row by row | Breadth-first (BFS): “show the org chart one level at a time”, “find the shallowest match”. |
Step through all four on the same tree. Watch the right-hand panel: depth-first orders keep a stack of unfinished calls, level-order keeps a queue.
The code: three functions that differ by one line
func preorder(n *Node, visit func(int)) {
if n == nil {
return // empty subtree: nothing to do
}
visit(n.Val) // ← pre: me first…
preorder(n.Left, visit)
preorder(n.Right, visit)
}
func inorder(n *Node, visit func(int)) {
if n == nil {
return
}
inorder(n.Left, visit)
visit(n.Val) // ← in: …between my children…
inorder(n.Right, visit)
}
func postorder(n *Node, visit func(int)) {
if n == nil {
return
}
postorder(n.Left, visit)
postorder(n.Right, visit)
visit(n.Val) // ← post: …or after them
}Where's the stack? Doing it by hand
Recursion hides a stack: each call that hasn't finished yet waits on the program's call stack. You can do the same job with your own stack (a slice or array you push to and pop from), and level-order is the same loop with a queue instead:
// Same output as preorder, with our own stack instead of the call stack.
func preorderLoop(root *Node) []int {
var out []int
stack := []*Node{root}
for len(stack) > 0 {
n := stack[len(stack)-1] // look at the top…
stack = stack[:len(stack)-1] // …and pop it
if n == nil {
continue
}
out = append(out, n.Val)
stack = append(stack, n.Right, n.Left) // push Right first so Left pops first
}
return out
}
// Breadth-first: a queue instead of a stack.
func levelOrder(root *Node) []int {
var out []int
queue := []*Node{root}
for len(queue) > 0 {
n := queue[0] // take from the front…
queue = queue[1:] // …and drop it
if n == nil {
continue
}
out = append(out, n.Val)
queue = append(queue, n.Left, n.Right) // children wait at the back
}
return out
}loop: [8 3 1 6 4 7 10 14 13]
level: [8 3 10 1 6 14 4 7 13]Recursive or loop?
Write the recursive version first: it's shorter and obviously right. Switch to an explicit stack when the tree can be very deep. Each recursive call uses some call-stack memory, and in JavaScript that runs out: on Node 22 with default settings, our recursive inorder crashes with RangeError: Maximum call stack size exceeded after roughly 10,000 nested calls (a Stack overflow). Nothing is wrong with the code — the recursion does end — it's just deeper than the stack allows. Go grows goroutine stacks automatically (up to 1 GB by default), so it survives far deeper trees, but it still spends memory per level.
How does a tree get 10,000 levels deep? §06 shows how ordinary sorted data does it to a search tree.
Binary search trees: higher or lower?
The rule
For every node — not just the root — all keys in its left subtree are smaller and all keys in its right subtree are bigger. This is the BST invariant: every operation must leave it true, and every operation relies on it being true.
Search: walk down, one comparison per level
Start at the root. If the key you want is smaller, go left; bigger, go right; equal, you found it. If you fall off the bottom (reach nil), the key isn't there. You never look at the other side of any node, because the rule promises the key can't be there.
Insert: search, then fill the empty spot
Do the same walk. When you fall off the bottom, the empty spot you fell into is exactly where the new key belongs. Hang a new node there. Nothing else moves.
Try both. Search for 65, insert 45 and 33, and watch the comparison counter:
Why it's fast: cost = height
Each comparison moves you down one level, so the number of comparisons is at most the tree's height. How small can the height be? A tree where every level is full holds 1 + 2 + 4 + … keys:
| Levels (height) | Keys a full tree holds |
|---|---|
| 3 | 7 |
| 10 | 1,023 |
| 20 | about 1 million |
| 30 | about 1 billion |
Each extra level doubles the capacity. Turn that around: n keys need only about log₂ n levels. “log₂ n” just means how many times you can halve n before you reach 1: for a million, about 20. That's what “O(log n)” means here (see Big-O for the notation): a million keys, at most ~20 comparisons, if the tree is well shaped.
That “if” is the whole story of §06 and §07. Press “insert 1…7 in order” in the playground and look at the shape you get.
Free bonus: sorted order
Walk any BST in-order — visit the left subtree, then the node, then the right subtree, at every node (§03 animates it) — and the keys come out sorted. Why: in-order prints the left subtree (all smaller), then the node, then the right subtree (all bigger), and the same holds inside every subtree.
This is what a BST gives you that a Hash map can't. A hash map finds exact keys faster, but it keeps them in no particular order. A BST can also answer:
- “What's the smallest key?” (go left until you can't)
- “What's the next key after 42?”
- “Give me every key between 100 and 200, in order.” (a Range query)
A map that keeps its keys in order like this is called a sorted map. That's the main reason to use a tree over a hash map.
Deleting: the three cases
Insert never moves existing nodes; delete sometimes has to. The difficulty is that a node's parent has one pointer to it, and that pointer has to keep pointing at something that obeys the BST rule.
Why the successor always works
The in-order successor of a node is the next bigger key in the whole tree. When the node has a right child, the successor is in its right subtree, at the far left: one step right, then left until you can't.
That key is the perfect stand-in, for two reasons:
- It's bigger than every key in the left subtree (it came from the right side), so the left side is still fine.
- It's smaller than every other key in the right subtree (it was the smallest there), so the right side is still fine.
And removing it from its old spot is always easy: it was the leftmost node, so it has no left child. That makes its removal case 1 or case 2 — never case 3 again.
In code
All three cases fit in a few lines. This uses the same Node type as §02 and §03 (Val, Left, Right); in a search tree, Val is the key. The function returns the new root of each subtree, so the parent simply stores whatever comes back. That's how “the parent's pointer now points at the child” happens without any parent pointers:
// type Node struct { Val int; Left, Right *Node } — from §02
func remove(n *Node, key int) *Node {
if n == nil {
return nil // key wasn't there
}
switch {
case key < n.Val:
n.Left = remove(n.Left, key)
case key > n.Val:
n.Right = remove(n.Right, key)
default: // n is the node to delete
if n.Left == nil {
return n.Right // case 1 (both nil) or case 2 (only a right child)
}
if n.Right == nil {
return n.Left // case 2: only a left child
}
succ := n.Right // case 3: find the successor…
for succ.Left != nil {
succ = succ.Left
}
n.Val = succ.Val // …copy its key here (in a map, copy the value too)…
n.Right = remove(n.Right, succ.Val) // …and delete it below (case 1 or 2)
}
return n
}Run it on the §03 tree (root 8) with inorder from §03:
root = remove(root, 3) // 3 has two children: its successor 4 takes its place
inorder(root, func(v int) { fmt.Print(v, " ") })1 4 6 7 8 10 13 14Case 1 needs no code of its own: if both children are nil, return n.right returns nil, which is exactly “cut it off”. §09 wraps these functions into a reusable, tested type.
Why balance matters
Watch it happen
Insert 1, 2, 3, 4, 5 into an empty BST. 1 becomes the root. 2 is bigger, so it goes right. 3 is bigger than 1 and 2, so it goes right of 2. Every key goes right of the one before it:
1
\
2
\
3
\
4
\
5That's a Linked list with extra steps. Searching for 5 takes 5 comparisons; with 1,000,000 keys inserted in order, it's 1,000,000 comparisons instead of ~20. In Big-O notation terms every operation slid from O(log n) to O(n). You saw the same shape in the §04 playground with “insert 1…7 in order”.
The four bars, top to bottom:
- Sorted inserts, plain BST: height = n. The disaster case.
- The same keys, shuffled: much better. Random insert orders give a height of a few times log₂ n on average, but it's luck, not a promise.
- An AVL tree, fed the same sorted keys: it rearranges itself as it goes (§07) and stays close to the minimum.
- The best possible: a perfectly full tree.
“But my data isn't sorted”
It often is, or nearly so:
- Auto-increment IDs (1, 2, 3, …) and timestamps arrive in increasing order by definition.
- Loading a file or export that was already sorted, or the result of an
ORDER BYquery. - Data that's mostly sorted with a few exceptions builds long chains too.
So a plain BST is fine for learning and for data you know is random, but anything that takes outside input needs a guarantee. There are two ways to get one: make the tree fix its own shape after each change (§07), or use a different structure entirely (a sorted slice or a B-tree, §10).
Self-balancing trees: rotations, AVL, red-black
A balanced tree stays close to the minimum height of about log₂ n. The tool every balanced BST uses is the same.
The rotation
A rotation takes a node x and its child y and makes y the parent instead. The three subtrees hanging below them (A, B, C — each could be empty or huge) get reattached so the BST rule still holds. Step through it:
1func rotateLeft(x *Node) *Node {2 y := x.Right3 x.Right = y.Left // B moves4 y.Left = x // x under y5 return y // parent → y6}
Three facts make rotations the right tool:
- Sorted order is preserved. Read the nodes left to right before and after: A, x, B, y, C. Only the shape changed.
- They change height. A left rotation moves one level of height from the right side to the left side.
- They're cheap. Three pointer assignments, whatever the size of A, B and C. Nothing is copied.
Some lopsided shapes need two rotations. If the tall part is on the right child's left side (a zig-zag shape), you first rotate the child right to straighten it, then rotate the node left. That's called a double rotation; it's still just the same move used twice. Insert 1, 3, 2:
zig-zag: rotate right at 3: rotate left at 1:
(now a straight line)
1 1 2
\ \ / \
3 2 1 3
/ \
2 3One rotation at 1 alone wouldn't help here: 2 would end up as 1's right child, still a zig-zag, just mirrored.
AVL trees: the strict rule
An AVL tree (1962, the first self-balancing BST) stores each node's height and enforces one rule: at every node, the left and right subtree heights differ by at most 1. That difference is the node's balance factor.
After an insert or delete, walk back up the path you came down, updating heights. If any node's balance factor reaches ±2, rotate there (once or twice). That's it. Insert 1, 2, 3 in order:
after inserting 1, 2, 3: rotate left at 1:
1 ← balance factor −2 2
\ (left height 0, right 2) / \
2 1 3
\
3The guarantee: an AVL tree's height is at most about 1.44 × log₂ n. For a million keys, under 29 levels — in practice usually 20 or 21.
Red-black trees: the relaxed rule
A red-black tree colors every node red or black and enforces a few rules. You don't need to memorize them to use one, but here they are:
- The root is black, and the empty spots (
nil) count as black. - A red node can't have a red child (no two reds in a row).
- Every path from a node down to an empty spot passes through the same number of black nodes.
Rule 3 makes all paths equally long if you count only black nodes. Rule 2 means the red nodes can at most double a path's length: at worst a path alternates black, red, black, red…, so it has no more reds than blacks. Together: no path is more than twice as long as any other, and height ≤ 2 × log₂(n+1). For a million keys, at most ~40 levels.
Fixing a broken rule after an insert or delete takes recoloring (cheap) plus at most two rotations for an insert and three for a delete. An AVL tree also needs at most two rotations per insert, but a delete may need a rotation at every level on the way back up.
| AVL | Red-black | |
|---|---|---|
| Rule | heights of siblings differ by ≤ 1 | colors + equal black count on every path |
| Max height (n keys) | ~1.44 × log₂ n | 2 × log₂(n+1) |
| Lookups | a bit faster (shorter tree) | a bit slower |
| Rotations per change | insert: at most 2; delete: may rotate at every level on the way up | insert: at most 2; delete: at most 3 |
| Used in | lookup-heavy in-memory indexes | Java TreeMap, C++ std::map (in the major implementations), the Linux kernel |
Both give the same Big-O notation guarantee: O(log n) per operation, whatever order the keys arrive in. That guarantee is what you're buying.
Simpler alternatives that balance on average
- A treap gives every node a random priority and keeps higher priorities above lower ones. The randomness makes the shape behave like a shuffled insert order no matter what order keys arrive in. It's much less code than red-black, and the guarantee is “O(log n) with overwhelming probability” rather than always.
- A skip list isn't a tree at all: it's a sorted linked list with random “express lanes”. It's easier to make safe for concurrent writers, which is why many databases' in-memory write buffers use one (see LSM Trees).
- A B-tree solves the problem a different way: hundreds of keys per node, so the tree is short and wide (§10).
Tries: trees for prefixes
Each node also needs a flag meaning “a word ends here”. Without it, storing “card” would make it look like “car” and “ca” were words too. In the widget, a double ring marks those nodes.
Things to try:
- Type
ca, thencar, thencart. The walk down costs one step per letter typed, whether the trie holds 8 words or 8 million. Lookup cost depends on the length of the key, not on how many keys are stored. - Insert
cars: only one new node, because c-a-r already exists. - Type
x: the walk stops at the root immediately. “No word starts with x” is answered after one step.
Why not a hash map or a BST?
A Hash map answers only “is exactly this key stored?”. To list everything starting with “car” you'd scan every key. A BST can do prefix searches (all keys from “car” up to “cas” form one sorted range), but every comparison along the way re-reads the strings from the first letter, so the shared prefix is read again at every level. A trie reads each character of your key once.
The memory problem, and radix trees
Plain tries are wasteful. Every node is a separate allocation with its own children table, and long words with no siblings build chains of single-child nodes. Click + dolphin: that adds five new nodes in a row (l, p, h, i, n), used by exactly one word.
A radix tree (also called a compressed trie, or Patricia trie) merges every such chain into one node holding a fragment, like “lphin”. Flip the widget to radix and compare the node counts. Same words, same lookups, far fewer nodes. Real implementations almost always use the compressed form.
How each node stores its children is the other big trade-off:
| Children stored as | Speed | Memory |
|---|---|---|
| A fixed array of 26 (or 256) slots | Fastest: index by letter | Huge: mostly empty slots in every node |
| A small hash map per node (our code in §09) | Fast | A whole map per node |
| A short sorted list | Fine when nodes have few children | Smallest |
Where tries run in production
HTTP routers. Routing /users/:id/posts means matching the request path against hundreds of patterns that share prefixes. Go's popular httprouter package, and frameworks built on it like Gin, store routes in a radix tree. Go's own net/http.ServeMux (Go 1.22 and later) matches patterns with a tree that branches on host, method, then one path segment per level.
IP routing. A Router's routing table holds rules like “10.0.0.0/8 → link A” and “10.1.0.0/16 → link B” (that's CIDR notation notation: the first 8 or 16 bits are fixed). A packet for 10.1.2.3 matches both, and the router must pick the most specific one. That's Longest-prefix match. Put the rules in a trie keyed by the address's bits (a binary trie: one level per bit, two children per node), walk down following 10.1.2.3's bits, and the last rule you passed on the way is the answer. The Linux kernel's IPv4 routing table is a compressed trie of this kind.
Autocomplete, spell checkers, word games. Anything that asks “which stored strings start with this?” or “is this a prefix of any word?”.
Build it: a generic BST and a trie
This is a plain, unbalanced BST: perfect for learning and for random keys, but remember §06 before using it on real input.
The BST in Go
package main
import (
"cmp"
"iter"
)
// node is one box in the tree: a key and two child pointers.
type node[K cmp.Ordered] struct {
key K
left, right *node[K]
}
// BST works for any key type that supports < and > (ints, floats, strings…).
type BST[K cmp.Ordered] struct {
root *node[K]
}
func (t *BST[K]) Insert(key K) { t.root = insert(t.root, key) }
func (t *BST[K]) Delete(key K) { t.root = remove(t.root, key) } // §05's remove, made generic
func (t *BST[K]) Height() int { return height(t.root) } // §02's height, made generic
func insert[K cmp.Ordered](n *node[K], key K) *node[K] {
if n == nil {
return &node[K]{key: key} // ← the empty spot where key belongs
}
switch {
case key < n.key:
n.left = insert(n.left, key)
case key > n.key:
n.right = insert(n.right, key)
} // equal: already present, nothing to do
return n
}
// Contains walks down without recursion: left if smaller, right if bigger.
func (t *BST[K]) Contains(key K) bool {
n := t.root
for n != nil {
switch {
case key < n.key:
n = n.left
case key > n.key:
n = n.right
default:
return true
}
}
return false // fell off the bottom
}
// All yields the keys in sorted order (an in-order traversal).
func (t *BST[K]) All() iter.Seq[K] {
return func(yield func(K) bool) { inorder(t.root, yield) }
}
func inorder[K cmp.Ordered](n *node[K], yield func(K) bool) bool {
if n == nil {
return true
}
// && stops early if any part returns false (the caller broke out of its loop)
return inorder(n.left, yield) && yield(n.key) && inorder(n.right, yield)
}Using it:
var t BST[int]
for _, k := range []int{8, 3, 10, 1, 6, 14, 4, 7, 13} {
t.Insert(k)
}
fmt.Println(slices.Collect(t.All()), t.Contains(6), t.Contains(5))
t.Delete(3) // two children → replaced by its successor, 4
fmt.Println(slices.Collect(t.All()))
var words BST[string] // same code, different key type
for _, w := range []string{"pear", "apple", "fig"} {
words.Insert(w)
}
fmt.Println(slices.Collect(words.All()))$ go run .
[1 3 4 6 7 8 10 13 14] true false
[1 4 6 7 8 10 13 14]
[apple fig pear]Testing it against a model
Hand-picked test cases miss the weird shapes. Instead, generate 10,000 random inserts and deletes, apply each one to the tree and to a sorted slice (too slow for real use, but obviously correct), and check that they always agree:
func TestBSTMatchesSortedSlice(t *testing.T) {
r := rand.New(rand.NewPCG(1, 2)) // fixed seed → the same run every time
var tree BST[int]
var want []int // the "obviously correct" model
for range 10_000 {
k := r.IntN(500)
if r.IntN(3) == 0 { // one operation in three is a delete
tree.Delete(k)
if i, found := slices.BinarySearch(want, k); found {
want = slices.Delete(want, i, i+1)
}
} else {
tree.Insert(k)
if i, found := slices.BinarySearch(want, k); !found {
want = slices.Insert(want, i, k)
}
}
if tree.Contains(k) != slices.Contains(want, k) {
t.Fatalf("Contains(%d) disagrees with the model", k)
}
}
if got := slices.Collect(tree.All()); !slices.Equal(got, want) {
t.Fatalf("in-order walk = %v, want %v", got, want)
}
}$ go test -v
=== RUN TestBSTMatchesSortedSlice
--- PASS: TestBSTMatchesSortedSlice (0.00s)
PASS
ok bst 0.004sThis style is called model-based testing, a kind of property-based testing (generate many random inputs and check a rule that must always hold). It's a very effective way to test a data structure: the model only needs to be correct, not fast, and random sequences reach odd shapes that hand-picked cases miss.
The BST in JavaScript
export class BST {
#root = null; // # = private: only code inside the class can touch it
constructor(compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0)) {
this.compare = compare; // like Array.prototype.sort's: <0, 0 or >0
}
has(key) {
let n = this.#root;
while (n) {
const c = this.compare(key, n.key);
if (c === 0) return true;
n = c < 0 ? n.left : n.right; // smaller → left, bigger → right
}
return false;
}
insert(key) { this.#root = this.#insert(this.#root, key); }
#insert(n, key) {
if (!n) return { key, left: null, right: null }; // the empty spot
const c = this.compare(key, n.key);
if (c < 0) n.left = this.#insert(n.left, key);
else if (c > 0) n.right = this.#insert(n.right, key);
return n;
}
delete(key) { this.#root = this.#delete(this.#root, key); }
#delete(n, key) { // §05's remove, using this.compare
if (!n) return null;
const c = this.compare(key, n.key);
if (c < 0) n.left = this.#delete(n.left, key);
else if (c > 0) n.right = this.#delete(n.right, key);
else {
if (!n.left) return n.right;
if (!n.right) return n.left;
let succ = n.right;
while (succ.left) succ = succ.left;
n.key = succ.key;
n.right = this.#delete(n.right, succ.key);
}
return n;
}
// In-order walk with an explicit stack, as a generator.
*[Symbol.iterator]() {
const stack = [];
let n = this.#root;
while (n || stack.length) {
while (n) { stack.push(n); n = n.left; } // go as far left as possible
n = stack.pop(); // the smallest key not yet visited
yield n.key;
n = n.right; // then everything bigger than it
}
}
}import { BST } from "./bst.mjs";
const t = new BST();
for (const k of [8, 3, 10, 1, 6, 14, 4, 7, 13]) t.insert(k);
console.log([...t].join(" "), t.has(6), t.has(5));
const byLength = new BST((a, b) => a.length - b.length || a.localeCompare(b));
for (const w of ["pear", "fig", "banana", "kiwi"]) byLength.insert(w);
console.log([...byLength]);$ node demo.mjs
1 3 4 6 7 8 10 13 14 true false
[ 'fig', 'kiwi', 'pear', 'banana' ]The same model-based test, with Node's built-in test runner:
import { test } from "node:test";
import assert from "node:assert/strict";
import { BST } from "./bst.mjs";
test("BST matches a Set after random ops", () => {
let seed = 42; // a tiny fixed-seed random generator, so runs repeat
const rand = (n) => (seed = (seed * 1103515245 + 12345) % 2 ** 31) % n;
const tree = new BST(), model = new Set();
for (let i = 0; i < 10_000; i++) {
const k = rand(500);
if (rand(3) === 0) { tree.delete(k); model.delete(k); }
else { tree.insert(k); model.add(k); }
assert.equal(tree.has(k), model.has(k));
}
assert.deepEqual([...tree], [...model].sort((a, b) => a - b)); // numeric sort!
});$ node --test bst.test.mjs # spec-style output in a terminal
✔ BST matches a Set after random ops (26.6ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0The trie in Go and JavaScript
type trieNode struct {
children map[rune]*trieNode // one entry per next letter
end bool // true if a word ends exactly here
}
type Trie struct{ root *trieNode }
func NewTrie() *Trie {
return &Trie{root: &trieNode{children: map[rune]*trieNode{}}}
}
func (t *Trie) Insert(word string) {
n := t.root
for _, ch := range word { // ch is a rune: one Unicode character
next, ok := n.children[ch]
if !ok { // no path for this letter yet: create it
next = &trieNode{children: map[rune]*trieNode{}}
n.children[ch] = next
}
n = next
}
n.end = true // mark "a word ends here"
}
// WithPrefix returns every stored word starting with prefix, sorted.
func (t *Trie) WithPrefix(prefix string) []string {
n := t.root
for _, ch := range prefix { // 1. walk down the prefix
n = n.children[ch]
if n == nil {
return nil // nobody starts with that
}
}
var out []string
var walk func(n *trieNode, word []rune)
walk = func(n *trieNode, word []rune) { // 2. collect everything below
if n.end {
out = append(out, string(word))
}
for _, ch := range slices.Sorted(maps.Keys(n.children)) {
walk(n.children[ch], append(word, ch))
}
}
walk(n, []rune(prefix))
return out
}$ go run . # after inserting car, card, care, cart, cat, do, dog
WithPrefix("car") → [car card care cart]
WithPrefix("ca") → [car card care cart cat]
WithPrefix("x") → []In practice: choosing a sorted structure
What each language gives you
| Language | Built-in sorted map? | What people use |
|---|---|---|
| Go | No. map is unordered. | A sorted slice + slices.BinarySearch, or a library like github.com/google/btree (generic since v1.1) or github.com/tidwall/btree |
| JavaScript | No. Map remembers insertion order, not sorted order. | A sorted array + binary search, or an npm package |
| Java | TreeMap, TreeSet | A red-black tree |
| C++ | std::map, std::set | A red-black tree in all the major standard libraries |
| Rust | BTreeMap, BTreeSet | An in-memory B-tree, not a binary tree |
| Python | No | The bisect module on a sorted list, or the sortedcontainers package |
Notice that newer designs (Rust, the popular Go packages) chose B-trees, not red-black trees, even for in-memory data. Here is why.
Why databases (and newer libraries) use B-trees
A binary tree with a million keys is ~20 levels deep, and every level is a separate node somewhere in memory. Following a child pointer means loading memory you can't predict in advance — Pointer chasing — and each hop can stall the CPU for ~100 ns while it waits for RAM. On disk it's far worse: each hop can be a separate page read.
A B-tree puts many sorted keys in each node (a node fills a disk page, or a few CPU cache lines), so each node has hundreds of children — its fan-out. A million keys then fit in 3–4 levels. Searching inside one node is a quick scan of memory that sits side by side, which the CPU handles well. Fewer hops wins, even with more comparisons per hop.
That's the whole recap here. Indexes & B-Trees §03 builds a B-tree up from scratch with page splits and a playground, and CPU Caches §10 explains the hardware side.
Two Go options, side by side
package main
import (
"fmt"
"slices"
"github.com/google/btree" // go get github.com/google/btree@v1.1.3
)
type Tier struct {
Start int // tier applies from this price (in cents) upward
Name string
}
func main() {
// Option 1: sorted slice + binary search (standard library only)
tiers := []Tier{{0, "free"}, {1000, "basic"}, {5000, "pro"}, {20000, "enterprise"}} // sorted by Start
// The last tier with Start <= price. Assumes price >= tiers[0].Start (here: not negative).
find := func(price int) Tier {
i, found := slices.BinarySearchFunc(tiers, price, func(t Tier, p int) int { return t.Start - p })
if !found {
i-- // i is where price would be inserted; the tier before it covers price
}
return tiers[i]
}
fmt.Println(find(4999).Name, find(5000).Name, find(99999).Name) // basic pro enterprise
// Option 2: an in-memory B-tree (github.com/google/btree, v1.1+)
t := btree.NewOrderedG[int](32) // degree 32: up to 63 keys per node
for _, k := range []int{50, 20, 80, 10, 30, 70, 90} {
t.ReplaceOrInsert(k)
}
t.AscendRange(25, 75, func(k int) bool { // every key in [25, 75)
fmt.Print(k, " ") // 30 50 70
return true // keep going
})
fmt.Println()
}A benchmark: one million random int keys
Measured on this site's build machine (4-core Intel Xeon VM at 2.3 GHz, Go 1.24). Lookups probe a million random keys that exist, in random order, so caches can't hide anything. VM numbers are noisy: treat the ranges as rough.
| Structure | Lookup (ns/op) | Memory | Build from 1M keys | Insert one key later |
|---|---|---|---|---|
map[int]struct{} | ~60–80 | ~38 MB | ~0.2–0.3 s | fast (but no order) |
sorted []int + slices.BinarySearch | ~200–400 | 8 MB | ~0.2 s (append, then sort once) | ~1 ms (shifts ~4 MB) |
google/btree, degree 32 | ~700–1,000 | ~13 MB | ~0.3–0.5 s | fast, O(log n) |
| our plain BST (§09), random order | ~1,300–1,800 | ~24 MB | ~2 s | fast if lucky, O(log n) average |
What it shows:
- The hash map is fastest for exact lookups, by a lot — but it can't answer “next key after x” or ranges.
- The sorted slice beats both trees on lookups and uses a third of the BST's memory (and about 60% of the B-tree's): one contiguous block of 8-byte numbers, no pointers. Its weakness is inserting into the middle: every later element must shift over.
- The B-tree is the all-rounder when data changes often and you need order. It's ~2× faster than the binary tree here, with half the memory.
- Our plain BST was 50 levels tall with these random keys (versus 20 for a perfect tree) and pays a likely cache miss on nearly every level.
Trees in disguise: persistent, Merkle, interval
Persistent trees: cheap copies by sharing
In React or Redux you never mutate state in place; you produce a new state object and keep the old one. That's an Immutable style. Copying a 100,000-item structure on every keystroke would be far too slow, though. The trick is a persistent data structure: an update returns a new version while the old one stays valid, and the two share every part that didn't change.
For a tree this is easy. To change one leaf, copy only the nodes on the path from the root down to it — about log₂ n nodes — and let the copies point at the old, untouched subtrees. That's Structural sharing:
Where you meet it:
- Immer (the library behind Redux Toolkit's reducers) lets you write
draft.items[3].done = trueand produces a new object tree that shares every untouched branch with the old one. - Clojure and Immutable.js build their vectors and maps as wide trees (32 children per node), so an update copies only a handful of small nodes.
- Undo history, time travel and snapshots come almost for free: keep the old root.
- Copy-on-write B-trees in databases and file systems (LMDB, Btrfs) apply the same idea on disk: never overwrite a page, write new copies of the path and switch the root.
The cost: more garbage for the garbage collector, and an extra allocation per node on the path for every update. Fine for UI state; a real cost in a hot loop.
Merkle trees: fingerprints all the way up
A Hash function turns any data into a short fingerprint. A Merkle tree hashes every data block, then hashes pairs of hashes, and so on up to a single root hash. Change any byte anywhere and the root changes, and the path of changed hashes leads straight to the change:
That gives two superpowers: one hash proves the whole dataset is identical, and when it isn't, comparing children narrows down the difference in about log₂ n steps instead of comparing every block.
- Git names every file, directory and commit by a hash of its contents, and a directory's hash covers its children's hashes. A commit's hash therefore fingerprints the entire project. That's why
git diffbetween two commits can skip whole folders whose hashes match. - Replica sync. Dynamo-style databases (Cassandra, Riak) run Anti-entropy: two replicas exchange Merkle tree hashes of their data and copy only the ranges that differ, instead of shipping every row (Replication §10).
- Blockchains and certificate transparency logs put a Merkle root in each block or log entry, so you can prove one transaction or certificate is included by showing just the ~log₂ n sibling hashes on its path.
Interval and segment trees, in one paragraph
Some questions are about ranges, not keys: “which meetings overlap 2–3 pm?”, “which IP blocks contain this address?”, “what's the sum of sales from day 100 to day 900?”. An interval tree is a BST of intervals, sorted by start, where each node also remembers the largest end anywhere in its subtree; that one extra number lets a search skip subtrees that can't possibly overlap, answering in O(log n + number of matches). A segment tree splits an array into halves, quarters and so on, and stores a summary (sum, min, max) for each piece, so any range sum or minimum takes O(log n) and updating one element touches only log n summaries. Both are the same trick as Merkle trees: store a summary of each subtree in its root, so whole subtrees can be answered or skipped without visiting them.
Pitfalls gallery
A plain BST fed sorted keys
The tree from §09 is fine for random keys. Fed auto-increment IDs or timestamps, it becomes a linked list: O(n) per operation, and 1,000 levels after 1,000 inserts (§06). If keys come from outside your control, use a balanced structure or a library.
Recursion on a tree you don't control
A recursive walk is as deep as the tree is tall. On a degenerate tree, or a deeply nested JSON document from a user, JavaScript throws RangeError: Maximum call stack size exceeded after roughly 10,000 levels on Node 22 (§03). Go survives much deeper but burns stack memory. For untrusted input, use an explicit stack, or reject inputs nested deeper than a sane limit — many JSON parsers have exactly such a limit for this reason.
Changing a key while it's inside the tree
This one bites trees that hold objects sorted by a compare function, like the JS class from §09 or a Go library tree built with a “less” function (btree.NewG). The tree's shape depends on its keys. If you store objects and later change the field the tree sorts by, the node is now in the wrong place, and searches for it will walk the wrong way and miss it.
u := &User{Name: "bob"}
tree.Insert(u) // sorted by Name
u.Name = "zoe" // tree still has it where "bob" goes
tree.Contains(u) // false: search goes right, node is on the lefttree.Delete(u) // take it out…
u.Name = "zoe"
tree.Insert(u) // …and put it backThe same rule applies to hash map keys, to Java's TreeMap, and to anything else that organizes data by its value.
A comparison function that isn't consistent
A custom compare(a, b) must give the same answer every time, and must agree with itself: if a < b and b < c, then a < c. Comparisons that break this silently corrupt the tree. Common culprits:
- Comparing with
NaN(every comparison withNaNis false, so it's neither smaller, bigger, nor equal). (a, b) => a.price - b.pricewhen two different products have the same price: the tree thinks they're the same key and keeps only one. Add a tie-breaker, like|| a.id - b.id.- Sorting strings with
<(by character code:"Z" < "a") in one place and withlocaleCompare(by language rules) in another.
Assuming a JS Map or Go map is sorted
Go deliberately randomizes map iteration order. A JS Map iterates in insertion order, which looks sorted in tests where you inserted sorted data, and then isn't in production. If you need keys in order, sort them (slices.Sorted(maps.Keys(m)) in Go 1.23+, [...m.keys()].sort((a, b) => a - b) in JS for numbers — plain .sort() compares as strings, so [10, 9, 100] becomes [10, 100, 9]) or use a sorted structure.
A trie with a hash map in every node
A plain trie of a million English words can easily use hundreds of megabytes: one allocation, one map and a few pointers per character. Use a radix tree (§08), a sorted slice of strings with binary search for prefixes, or a library built for it. Measure memory before shipping a trie.
Writing your own balanced tree for production
It's a great exercise and a poor production choice. Red-black deletion has many cases, and bugs show up only on rare shapes. Use the standard library (TreeMap, std::map, BTreeMap) or a widely used package, and test your usage with a model-based test (§09).
Using a tree when a sorted slice would do
If the data is loaded once and read many times, a sorted slice is smaller, faster to search and safe to share between goroutines without locks, as long as nobody changes it after sharing (§10). To update it, build a new slice and swap it in atomically (for example with atomic.Pointer), rather than editing the shared one. Trees earn their keep when inserts and deletes keep coming while you read.
Check yourself
Predict the answer before you click. Each explanation points back to the section that covers it.
Insert 5, 3, 8, 1, 4 (in that order) into an empty BST, then print it with an in-order traversal. What comes out?
var t BST[int] // the plain BST from §09
for k := 1; k <= 1000; k++ {
t.Insert(k)
}
fmt.Println(t.Height())You want to print a company org chart one management level at a time: the CEO, then everyone reporting to the CEO, then the next level down…
The tree: root 50; left child 30 (children 20, 40); right child 70 (children 60, 80); 60 has a right child 65. You delete 50. What is the new root?
You store 2 million product names in memory and want suggestions as the user types. Why reach for a trie (or radix tree) instead of a hash map?
A red-black tree holds 1,000,000 keys. Roughly how many levels can it have, at most?
At startup, a Go service loads 50,000 sorted price tiers from a file. They never change while it runs. Requests ask “which tier contains price p?” (the largest tier start ≤ p). What's the best fit?
In Node 22 you run the recursive in-order traversal from §03 on a plain BST built by inserting 1…50,000 in order. What happens?
Cheat sheet
| Operation | Plain BST | Balanced BST (AVL, red-black) | B-tree | Sorted slice | Hash map | Trie (key length m) |
|---|---|---|---|---|---|---|
| Find a key | O(log n) average, O(n) worst | O(log n) | O(log n), few levels | O(log n) | O(1) average | O(m) |
| Insert / delete | O(log n) average, O(n) worst | O(log n) | O(log n) | O(n) (shifting) | O(1) average | O(m) |
| In sorted order / ranges | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ (alphabetical) |
| Prefix search | via a range | via a range | via a range | via a range | ❌ | ✅ best |
| Go | JavaScript |
|---|---|
No sorted map: slices.Sort + slices.BinarySearch / BinarySearchFunc | No sorted map: sorted array + your own binary search |
github.com/google/btree (BTreeG, v1.1+) for a mutable sorted set/map | An npm sorted-collection package |
iter.Seq[K] + for k := range t.All() (Go 1.23+) to expose an in-order walk | *[Symbol.iterator]() generator to make a tree loopable with for…of |
[K cmp.Ordered] for generic keys | A compare(a, b) function returning < 0, 0, > 0 |