Trees & graphs · Lesson 27 of 62 · about 55 min
Balanced BSTs & ordered sets
Why plain BSTs degenerate, how rotations rebalance them, the ideas behind AVL and red-black trees, and a complete treap you can use as an ordered set with rank and k-th queries.
On this page · 8 sections
The problem: degenerate trees#
Insert 1 2 3 4 5 6 into the BST visualizer below and watch the tree become a diagonal line. Every search now walks the whole line: O(n) instead of O(log n).
| Insert order | Height for n = 1,000,000 | Search cost |
|---|---|---|
| random | about 2.99 · log₂ n ≈ 60 on average | fast |
| sorted (very common in practice: IDs, timestamps) | 1,000,000 | a linear scan |
Rotations#
A rotation is a local rearrangement of three links that keeps the in-order sequence identical while changing which node is on top. A right rotation at y lifts its left child x:
y x
/ \ / \
x C ==> A y
/ \ / \
A B B CIn-order before: A x B y C. After: A x B y C. Unchanged, so it is still a valid BST, but the left side got one level shorter and the right side one level taller. A left rotation is the mirror image. Every balanced BST is "a BST plus a rule for when to rotate":
| Tree | Balance rule | Notes |
|---|---|---|
| AVL | at every node, the two subtree heights differ by at most 1 | strictly balanced; fastest lookups |
| red-black | nodes are colored; no red node has a red child; every root-to-leaf path has the same number of black nodes | at most 2× the optimal height; used by Java's TreeMap, C++ std::map, Linux |
| treap | keys in BST order, random priorities in heap order | simplest to code; balanced in expectation |
| splay | move every accessed node to the root | great when some keys are accessed much more often |
| B-tree | many keys per node; all leaves at the same depth | databases and file systems |
Treaps: balance by randomness#
A treap gives each node a random priority and maintains two rules at once:
- keys obey the BST rule (left smaller, right larger);
- priorities obey the heap rule (a parent's priority is higher than its children's).
For a given set of keys and priorities there is exactly one tree that satisfies both, and it is the same tree you would get by inserting the keys in decreasing priority order into a plain BST. Since the priorities are random, that is a random insertion order, whose expected height is O(log n). Insert is ordinary BST insertion followed by rotations that bubble the new node up while its priority beats its parent's, exactly like sift-up in a heap.
Storing each subtree's size in its root adds order statistics for free: Rank(x) (how many keys are smaller) and Kth(k) (the k-th smallest).
package main
import (
"cmp"
"fmt"
"math/rand/v2"
)
type node[K cmp.Ordered] struct {
key K
prio uint64
size int
left, right *node[K]
}
func size[K cmp.Ordered](n *node[K]) int {
if n == nil {
return 0
}
return n.size
}
func (n *node[K]) fix() { n.size = 1 + size(n.left) + size(n.right) }
func rotateRight[K cmp.Ordered](y *node[K]) *node[K] {
x := y.left
y.left, x.right = x.right, y
y.fix()
x.fix()
return x
}
func rotateLeft[K cmp.Ordered](x *node[K]) *node[K] {
y := x.right
x.right, y.left = y.left, x
x.fix()
y.fix()
return y
}
// Set is an ordered set of keys backed by a treap.
type Set[K cmp.Ordered] struct {
root *node[K]
rng *rand.Rand
}
func NewSet[K cmp.Ordered](seed uint64) *Set[K] {
return &Set[K]{rng: rand.New(rand.NewPCG(seed, seed))}
}
func (s *Set[K]) Len() int { return size(s.root) }
func (s *Set[K]) Insert(k K) {
var ins func(n *node[K]) *node[K]
ins = func(n *node[K]) *node[K] {
if n == nil {
return &node[K]{key: k, prio: s.rng.Uint64(), size: 1}
}
switch {
case k < n.key:
n.left = ins(n.left)
if n.left.prio > n.prio {
n = rotateRight(n) // the child has higher priority: lift it
}
case k > n.key:
n.right = ins(n.right)
if n.right.prio > n.prio {
n = rotateLeft(n)
}
}
n.fix()
return n
}
s.root = ins(s.root)
}
func (s *Set[K]) Delete(k K) {
var del func(n *node[K]) *node[K]
del = func(n *node[K]) *node[K] {
if n == nil {
return nil
}
switch {
case k < n.key:
n.left = del(n.left)
case k > n.key:
n.right = del(n.right)
default:
// Rotate the node down toward a leaf, lifting the higher-priority child, then drop it.
if n.left == nil {
return n.right
}
if n.right == nil {
return n.left
}
if n.left.prio > n.right.prio {
n = rotateRight(n)
n.right = del(n.right)
} else {
n = rotateLeft(n)
n.left = del(n.left)
}
}
n.fix()
return n
}
s.root = del(s.root)
}
func (s *Set[K]) Contains(k K) bool {
for n := s.root; n != nil; {
switch {
case k < n.key:
n = n.left
case k > n.key:
n = n.right
default:
return true
}
}
return false
}
// Rank returns how many keys are smaller than k.
func (s *Set[K]) Rank(k K) int {
r := 0
for n := s.root; n != nil; {
if k <= n.key {
n = n.left
} else {
r += size(n.left) + 1
n = n.right
}
}
return r
}
// Kth returns the k-th smallest key (0-based).
func (s *Set[K]) Kth(k int) K {
n := s.root
for {
switch l := size(n.left); {
case k < l:
n = n.left
case k == l:
return n.key
default:
k -= l + 1
n = n.right
}
}
}
// Ceil returns the smallest key >= k.
func (s *Set[K]) Ceil(k K) (K, bool) {
var best K
found := false
for n := s.root; n != nil; {
if n.key >= k {
best, found = n.key, true
n = n.left
} else {
n = n.right
}
}
return best, found
}
func (s *Set[K]) Keys() []K {
var out []K
var walk func(n *node[K])
walk = func(n *node[K]) {
if n != nil {
walk(n.left)
out = append(out, n.key)
walk(n.right)
}
}
walk(s.root)
return out
}
func height[K cmp.Ordered](n *node[K]) int {
if n == nil {
return 0
}
return 1 + max(height(n.left), height(n.right))
}
func main() {
s := NewSet[int](42)
for _, k := range []int{50, 20, 80, 10, 30, 70, 90, 30} {
s.Insert(k) // the duplicate 30 is ignored
}
fmt.Println("keys:", s.Keys(), "len", s.Len())
fmt.Println("contains 70:", s.Contains(70), " contains 75:", s.Contains(75))
fmt.Println("rank(70):", s.Rank(70), " 3rd smallest:", s.Kth(2))
c, ok := s.Ceil(55)
fmt.Println("ceil(55):", c, ok)
s.Delete(50)
fmt.Println("after deleting 50:", s.Keys())
// The real test: sorted input.
big := NewSet[int](7)
for i := range 100_000 {
big.Insert(i)
}
fmt.Println("100000 sorted inserts, height:", height(big.root), "(a plain BST would be 100000)")
fmt.Println("median:", big.Kth(big.Len()/2), " rank(12345):", big.Rank(12345))
}keys: [10 20 30 50 70 80 90] len 7
contains 70: true contains 75: false
rank(70): 4 3rd smallest: 30
ceil(55): 70 true
after deleting 50: [10 20 30 70 80 90]
100000 sorted inserts, height: 41 (a plain BST would be 100000)
median: 50000 rank(12345): 12345A height in the low 40s for 100,000 keys is what theory predicts: about 2.5 to 3 times log₂ n, against 100,000 for a plain BST. The fixed seed makes the output reproducible; in real code, seed from the clock or use rand.Uint64().
Ordered sets in real Go code#
Go deliberately keeps the standard library small. When you need sorted, changing data, choose by access pattern:
| Need | Use |
|---|---|
| build once, then query | a sorted slice plus slices.BinarySearch |
| many inserts and deletes, ordered iteration, floor/ceiling | a B-tree package such as github.com/google/btree or github.com/tidwall/btree |
| top k or "next smallest" only | container/heap |
| counts or sums over value ranges | a Fenwick tree (next lesson) |
| your own library or an interview | the treap above |
Why B-trees
A binary tree node holds one key and two pointers, and following a pointer is a cache miss (or, on disk, a whole read). A B-tree node holds many sorted keys, say 255, and 256 children. Searching does a binary search inside the node, then follows one pointer. With fan-out 256, four levels hold over 4 billion keys. Every relational database index (PostgreSQL, MySQL's InnoDB, SQLite) and most file systems use B-trees or the B+tree variant, where data lives only in leaves and leaves are linked for fast range scans.
Complexity#
| Operation | Plain BST | Balanced BST (AVL, red-black, treap expected) |
|---|---|---|
| search, insert, delete | O(n) worst | O(log n) |
| min, max, floor, ceiling | O(n) worst | O(log n) |
| rank, k-th (with sizes) | O(n) worst | O(log n) |
| in-order iteration | O(n) | O(n) |
Summary#
- A BST is only as fast as it is short; sorted input makes it a list.
- Rotations change the shape but keep the sorted order; every balanced BST is a rule for when to rotate.
- Treaps combine BST keys with random heap-ordered priorities: simple code, expected O(log n).
- Subtree sizes give rank and k-th queries.
- In production Go, use a sorted slice for static data and a B-tree package for changing ordered data.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.Inserting 1, 2, 3, …, n into a plain BST produces a tree of height:
Q2.A rotation changes the tree's shape. What does it preserve?
Q3.In a treap, keys follow the BST rule. Priorities follow:
Q4.To answer 'what is the k-th smallest key?' in O(log n), each node also stores:
Q5.Why do databases use B-trees instead of binary trees on disk?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
Show hint
In-order to a sorted slice, then build recursively from the middle.
Show hint
Middle element as root, recurse on both halves.
Show hint
Return the height, or -1 as soon as any subtree is unbalanced.
Show hint
In-order traversal with a counter; the follow-up wants subtree sizes.
Show hint
Insert from the right into an ordered set with Rank, or use a Fenwick tree (next lesson).
- My Calendar I ↗medium
Show hint
An ordered set of intervals: check the floor and ceiling neighbors of the new start.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.