Go Guide

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
  1. 01The problem: degenerate trees
  2. 02Rotations
  3. 03Treaps: balance by randomness
  4. 04Ordered sets in real Go code
  5. 05Complexity
  6. 06Summary
  7. 07Check your understanding
  8. 08Practice in Go

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 orderHeight for n = 1,000,000Search cost
randomabout 2.99 · log₂ n ≈ 60 on averagefast
sorted (very common in practice: IDs, timestamps)1,000,000a 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   C

In-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":

TreeBalance ruleNotes
AVLat every node, the two subtree heights differ by at most 1strictly balanced; fastest lookups
red-blacknodes are colored; no red node has a red child; every root-to-leaf path has the same number of black nodesat most 2× the optimal height; used by Java's TreeMap, C++ std::map, Linux
treapkeys in BST order, random priorities in heap ordersimplest to code; balanced in expectation
splaymove every accessed node to the rootgreat when some keys are accessed much more often
B-treemany keys per node; all leaves at the same depthdatabases 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).

treap.go
Go
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))
}
Output
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): 12345

A 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:

NeedUse
build once, then querya sorted slice plus slices.BinarySearch
many inserts and deletes, ordered iteration, floor/ceilinga B-tree package such as github.com/google/btree or github.com/tidwall/btree
top k or "next smallest" onlycontainer/heap
counts or sums over value rangesa Fenwick tree (next lesson)
your own library or an interviewthe 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#

OperationPlain BSTBalanced BST (AVL, red-black, treap expected)
search, insert, deleteO(n) worstO(log n)
min, max, floor, ceilingO(n) worstO(log n)
rank, k-th (with sizes)O(n) worstO(log n)
in-order iterationO(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.

  1. Q1.Inserting 1, 2, 3, …, n into a plain BST produces a tree of height:

  2. Q2.A rotation changes the tree's shape. What does it preserve?

  3. Q3.In a treap, keys follow the BST rule. Priorities follow:

  4. Q4.To answer 'what is the k-th smallest key?' in O(log n), each node also stores:

  5. 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.

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.