Other balanced BSTs
Other balancing schemes: 2-3 trees, 2-3-4 trees, AA trees, scapegoat trees and weight-balanced trees, each with a short Java sketch.
Learning goals
- Describe how 2-3 and 2-3-4 trees stay perfectly balanced by splitting nodes
- Relate 2-3-4 trees to red-black trees
- Implement the skew and split steps of an AA tree
- Explain scapegoat rebuilding and the weight-balance condition
Prerequisites
AVL and red-black trees are two answers to the same question. Others trade code size, update cost or memory differently, and each is a useful idea on its own.
2-3 trees
A **2-3 tree** is a search tree where every node has either one key and two children, or two keys and three children, and all leaves are at the **same depth**. Balance is perfect by construction: the tree only grows taller when the root splits. Insert into a leaf; if it now holds three keys, **split** it: the middle key moves up to the parent and the other two become separate nodes. A split may overflow the parent in turn, so the splitting can continue up to the root.
The code is a bottom-up insertion that returns the promoted key and the new right node when a split happens. It is the same idea as the B-tree insert of the next group with the smallest possible node size. In the B-tree animation, wider nodes work the same way.
1static class Node {2List<Integer> keys = new ArrayList<>(); // 1 or 2 keys after every operation3List<Node> kids = new ArrayList<>(); // empty in a leaf, else keys.size() + 1 children4}5static Node root = new Node();6static Object[] insert(Node n, int k) { // null, or {promoted key, new right node} after a split7int i = 0;8while (i < n.keys.size() && k > n.keys.get(i)) i++;9if (i < n.keys.size() && n.keys.get(i) == k) return null; // duplicate10if (n.kids.isEmpty()) n.keys.add(i, k);11else {12Object[] up = insert(n.kids.get(i), k);13if (up == null) return null;14n.keys.add(i, (Integer) up[0]);15n.kids.add(i + 1, (Node) up[1]);16}17if (n.keys.size() <= 2) return null;18Node right = new Node(); // three keys: split around the middle one19int mid = n.keys.get(1);20right.keys.add(n.keys.get(2));21if (!n.kids.isEmpty()) { right.kids.addAll(n.kids.subList(2, 4)); n.kids.subList(2, 4).clear(); }22n.keys.subList(1, 3).clear();23return new Object[]{mid, right};24}25static void add(int k) {26Object[] up = insert(root, k);27if (up != null) { // the root split: the tree grows taller28Node r = new Node();29r.keys.add((Integer) up[0]); r.kids.add(root); r.kids.add((Node) up[1]);30root = r;31}32}
B-tree: search, insertion with node splits, deletion with borrow and merge
Interactive · edit the inputB-tree (each box is one node) · 1 node
A B-tree of minimum degree t = 2: every node except the root holds 1 to 3 keys, every internal node with m keys has m + 1 children, and all leaves are at the same depth. With t = 2 this is the 2-3-4 tree.
1static int T = 2; // minimum degree: every node but the root holds T-1 .. 2T-1 keys2static class Node {3List<Integer> keys = new ArrayList<>();4List<Node> kids = new ArrayList<>(); // empty in a leaf5boolean leaf = true;6}7static Node root = new Node();8static boolean contains(Node x, int k) {9int i = 0;10while (i < x.keys.size() && k > x.keys.get(i)) i++; // first key >= k11if (i < x.keys.size() && x.keys.get(i) == k) return true;12return !x.leaf && contains(x.kids.get(i), k);13}14static void splitChild(Node x, int i) { // x.kids[i] is full (2T-1 keys)15Node y = x.kids.get(i), z = new Node();16z.leaf = y.leaf;17z.keys.addAll(y.keys.subList(T, 2 * T - 1)); // the upper T-1 keys move to the new node18int median = y.keys.get(T - 1);19y.keys.subList(T - 1, 2 * T - 1).clear(); // y keeps the lower T-1 keys20if (!y.leaf) { z.kids.addAll(y.kids.subList(T, 2 * T)); y.kids.subList(T, 2 * T).clear(); }21x.keys.add(i, median); // the median moves up22x.kids.add(i + 1, z);23}24static void insert(int k) {25if (contains(root, k)) return;26if (root.keys.size() == 2 * T - 1) { // a full root: the tree grows in height here27Node s = new Node(); s.leaf = false; s.kids.add(root); root = s; splitChild(s, 0);28}29insertNonFull(root, k);30}31static void insertNonFull(Node x, int k) {32int i = 0;33while (i < x.keys.size() && k > x.keys.get(i)) i++;34if (x.leaf) { x.keys.add(i, k); return; }35if (x.kids.get(i).keys.size() == 2 * T - 1) { // split a full child before descending36splitChild(x, i);37if (k > x.keys.get(i)) i++;38}39insertNonFull(x.kids.get(i), k);40}41static void delete(int k) {42delete(root, k);43if (root.keys.isEmpty() && !root.leaf) root = root.kids.get(0); // the tree shrinks in height44}45static void delete(Node x, int k) {46int i = 0;47while (i < x.keys.size() && k > x.keys.get(i)) i++;48if (i < x.keys.size() && x.keys.get(i) == k) {49if (x.leaf) x.keys.remove(i); // case 1: the key is in a leaf50else if (x.kids.get(i).keys.size() >= T) { // case 2a: use the predecessor51int pred = max(x.kids.get(i)); x.keys.set(i, pred); delete(x.kids.get(i), pred);52} else if (x.kids.get(i + 1).keys.size() >= T) { // case 2b: use the successor53int succ = min(x.kids.get(i + 1)); x.keys.set(i, succ); delete(x.kids.get(i + 1), succ);54} else { merge(x, i); delete(x.kids.get(i), k); } // case 2c: merge both children around the key55} else if (!x.leaf) {56if (x.kids.get(i).keys.size() == T - 1) { // the child needs a spare key before we descend57if (i > 0 && x.kids.get(i - 1).keys.size() >= T) borrowFromLeft(x, i); // case 3a58else if (i < x.keys.size() && x.kids.get(i + 1).keys.size() >= T) borrowFromRight(x, i);59else { if (i == x.keys.size()) i--; merge(x, i); } // case 3b60}61delete(x.kids.get(i), k);62}63}64static void merge(Node x, int i) { // kids[i] + keys[i] + kids[i+1] become kids[i]65Node y = x.kids.get(i), z = x.kids.get(i + 1);66y.keys.add(x.keys.remove(i));67y.keys.addAll(z.keys);68y.kids.addAll(z.kids);69x.kids.remove(i + 1);70}71static void borrowFromLeft(Node x, int i) {72Node c = x.kids.get(i), s = x.kids.get(i - 1);73c.keys.add(0, x.keys.get(i - 1));74x.keys.set(i - 1, s.keys.remove(s.keys.size() - 1));75if (!s.leaf) c.kids.add(0, s.kids.remove(s.kids.size() - 1));76}77static void borrowFromRight(Node x, int i) {78Node c = x.kids.get(i), s = x.kids.get(i + 1);79c.keys.add(x.keys.get(i));80x.keys.set(i, s.keys.remove(0));81if (!s.leaf) c.kids.add(s.kids.remove(0));82}83static int min(Node x) { while (!x.leaf) x = x.kids.get(0); return x.keys.get(0); }84static int max(Node x) { while (!x.leaf) x = x.kids.get(x.kids.size() - 1); return x.keys.get(x.keys.size() - 1); }
Live state
t
2
max keys
3
Execution trace (dry run table)Show 19 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | t=2 max keys=3 | A B-tree of minimum degree t = 2: every node except the root holds 1 to 3 keys, every internal node with m keys has m + 1 children, and all leaves are at the same depth. With t = 2 this is the 2-3-4 tree. |
| 2 | L34 | t=2 max keys=3 inserted=10 | 10 goes into the leaf at position 0. |
| 3 | L34 | t=2 max keys=3 inserted=20 | 20 goes into the leaf at position 1. |
| 4 | L34 | t=2 max keys=3 inserted=5 | 5 goes into the leaf at position 0. |
| 5 | L26 | t=2 max keys=3 inserted=6 | The root is full: create a new empty root above it and split the old root. This is the only way a B-tree grows taller. |
| 6 | L16 | t=2 max keys=3 median=10 | Split the full node: its median 10 moves up into the parent, the lower 1 keys stay and the upper 1 keys go to a new node. |
| 7 | L1 | t=2 max keys=3 inserted=6 | Descend into child 0 of this node. |
| 8 | L34 | t=2 max keys=3 inserted=6 | 6 goes into the leaf at position 1. |
| 9 | L1 | t=2 max keys=3 inserted=12 | Descend into child 1 of this node. |
| 10 | L34 | t=2 max keys=3 inserted=12 | 12 goes into the leaf at position 0. |
| 11 | L1 | t=2 max keys=3 inserted=30 | Descend into child 1 of this node. |
| 12 | L34 | t=2 max keys=3 inserted=30 | 30 goes into the leaf at position 2. |
| 13 | L1 | t=2 max keys=3 inserted=7 | Descend into child 0 of this node. |
| 14 | L34 | t=2 max keys=3 inserted=7 | 7 goes into the leaf at position 2. |
| 15 | L1 | t=2 max keys=3 inserted=17 | Descend into child 1 of this node. |
| 16 | L35 | t=2 max keys=3 inserted=17 | The child is full (3 keys): split it before descending, so there is always room to push a key up. |
| 17 | L16 | t=2 max keys=3 median=20 | Split the full node: its median 20 moves up into the parent, the lower 1 keys stay and the upper 1 keys go to a new node. |
| 18 | L34 | t=2 max keys=3 inserted=17 | 17 goes into the leaf at position 1. |
| 19 | L1 | keys=8 height=1 t=2 | 8 keys in a tree of height 1: 2 splits, 0 borrows, 0 merges. |
2-3-4 trees
A **2-3-4 tree** allows nodes of two, three or four children (one to three keys), and splits a full node **on the way down**, before descending, so no split ever has to propagate back up. It is exactly a **B-tree of minimum degree t = 2**. Run the B-tree animation with t = 2: the first preset (10, 20, 5, 6, 12, 30, 7, 17) ends at height 1 after 2 splits.
The link to red-black trees is the reason to know them. A red-black tree **is** a 2-3-4 tree drawn as a binary tree: a black node with no red child is a 2-node; a black node with one red child forms a 3-node; a black node with two red children forms a 4-node. Recolouring (case 1) is a node split, and rotations rearrange the inside of a node. Reading a red-black tree this way makes its fix-up cases easy to remember.
| 2-3-4 node | Red-black picture |
|---|---|
| 2-node: one key | a black node with two black (or NIL) children |
| 3-node: two keys | a black node with one red child |
| 4-node: three keys | a black node with two red children |
| Split of a 4-node | recolouring: parent and uncle black, grandparent red (insertion case 1) |
B-tree: search, insertion with node splits, deletion with borrow and merge
Interactive · edit the inputB-tree (each box is one node) · 1 node
A B-tree of minimum degree t = 2: every node except the root holds 1 to 3 keys, every internal node with m keys has m + 1 children, and all leaves are at the same depth. With t = 2 this is the 2-3-4 tree.
1static int T = 2; // minimum degree: every node but the root holds T-1 .. 2T-1 keys2static class Node {3List<Integer> keys = new ArrayList<>();4List<Node> kids = new ArrayList<>(); // empty in a leaf5boolean leaf = true;6}7static Node root = new Node();8static boolean contains(Node x, int k) {9int i = 0;10while (i < x.keys.size() && k > x.keys.get(i)) i++; // first key >= k11if (i < x.keys.size() && x.keys.get(i) == k) return true;12return !x.leaf && contains(x.kids.get(i), k);13}14static void splitChild(Node x, int i) { // x.kids[i] is full (2T-1 keys)15Node y = x.kids.get(i), z = new Node();16z.leaf = y.leaf;17z.keys.addAll(y.keys.subList(T, 2 * T - 1)); // the upper T-1 keys move to the new node18int median = y.keys.get(T - 1);19y.keys.subList(T - 1, 2 * T - 1).clear(); // y keeps the lower T-1 keys20if (!y.leaf) { z.kids.addAll(y.kids.subList(T, 2 * T)); y.kids.subList(T, 2 * T).clear(); }21x.keys.add(i, median); // the median moves up22x.kids.add(i + 1, z);23}24static void insert(int k) {25if (contains(root, k)) return;26if (root.keys.size() == 2 * T - 1) { // a full root: the tree grows in height here27Node s = new Node(); s.leaf = false; s.kids.add(root); root = s; splitChild(s, 0);28}29insertNonFull(root, k);30}31static void insertNonFull(Node x, int k) {32int i = 0;33while (i < x.keys.size() && k > x.keys.get(i)) i++;34if (x.leaf) { x.keys.add(i, k); return; }35if (x.kids.get(i).keys.size() == 2 * T - 1) { // split a full child before descending36splitChild(x, i);37if (k > x.keys.get(i)) i++;38}39insertNonFull(x.kids.get(i), k);40}41static void delete(int k) {42delete(root, k);43if (root.keys.isEmpty() && !root.leaf) root = root.kids.get(0); // the tree shrinks in height44}45static void delete(Node x, int k) {46int i = 0;47while (i < x.keys.size() && k > x.keys.get(i)) i++;48if (i < x.keys.size() && x.keys.get(i) == k) {49if (x.leaf) x.keys.remove(i); // case 1: the key is in a leaf50else if (x.kids.get(i).keys.size() >= T) { // case 2a: use the predecessor51int pred = max(x.kids.get(i)); x.keys.set(i, pred); delete(x.kids.get(i), pred);52} else if (x.kids.get(i + 1).keys.size() >= T) { // case 2b: use the successor53int succ = min(x.kids.get(i + 1)); x.keys.set(i, succ); delete(x.kids.get(i + 1), succ);54} else { merge(x, i); delete(x.kids.get(i), k); } // case 2c: merge both children around the key55} else if (!x.leaf) {56if (x.kids.get(i).keys.size() == T - 1) { // the child needs a spare key before we descend57if (i > 0 && x.kids.get(i - 1).keys.size() >= T) borrowFromLeft(x, i); // case 3a58else if (i < x.keys.size() && x.kids.get(i + 1).keys.size() >= T) borrowFromRight(x, i);59else { if (i == x.keys.size()) i--; merge(x, i); } // case 3b60}61delete(x.kids.get(i), k);62}63}64static void merge(Node x, int i) { // kids[i] + keys[i] + kids[i+1] become kids[i]65Node y = x.kids.get(i), z = x.kids.get(i + 1);66y.keys.add(x.keys.remove(i));67y.keys.addAll(z.keys);68y.kids.addAll(z.kids);69x.kids.remove(i + 1);70}71static void borrowFromLeft(Node x, int i) {72Node c = x.kids.get(i), s = x.kids.get(i - 1);73c.keys.add(0, x.keys.get(i - 1));74x.keys.set(i - 1, s.keys.remove(s.keys.size() - 1));75if (!s.leaf) c.kids.add(0, s.kids.remove(s.kids.size() - 1));76}77static void borrowFromRight(Node x, int i) {78Node c = x.kids.get(i), s = x.kids.get(i + 1);79c.keys.add(x.keys.get(i));80x.keys.set(i, s.keys.remove(0));81if (!s.leaf) c.kids.add(s.kids.remove(0));82}83static int min(Node x) { while (!x.leaf) x = x.kids.get(0); return x.keys.get(0); }84static int max(Node x) { while (!x.leaf) x = x.kids.get(x.kids.size() - 1); return x.keys.get(x.keys.size() - 1); }
Live state
t
2
max keys
3
Execution trace (dry run table)Show 19 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | t=2 max keys=3 | A B-tree of minimum degree t = 2: every node except the root holds 1 to 3 keys, every internal node with m keys has m + 1 children, and all leaves are at the same depth. With t = 2 this is the 2-3-4 tree. |
| 2 | L34 | t=2 max keys=3 inserted=10 | 10 goes into the leaf at position 0. |
| 3 | L34 | t=2 max keys=3 inserted=20 | 20 goes into the leaf at position 1. |
| 4 | L34 | t=2 max keys=3 inserted=5 | 5 goes into the leaf at position 0. |
| 5 | L26 | t=2 max keys=3 inserted=6 | The root is full: create a new empty root above it and split the old root. This is the only way a B-tree grows taller. |
| 6 | L16 | t=2 max keys=3 median=10 | Split the full node: its median 10 moves up into the parent, the lower 1 keys stay and the upper 1 keys go to a new node. |
| 7 | L1 | t=2 max keys=3 inserted=6 | Descend into child 0 of this node. |
| 8 | L34 | t=2 max keys=3 inserted=6 | 6 goes into the leaf at position 1. |
| 9 | L1 | t=2 max keys=3 inserted=12 | Descend into child 1 of this node. |
| 10 | L34 | t=2 max keys=3 inserted=12 | 12 goes into the leaf at position 0. |
| 11 | L1 | t=2 max keys=3 inserted=30 | Descend into child 1 of this node. |
| 12 | L34 | t=2 max keys=3 inserted=30 | 30 goes into the leaf at position 2. |
| 13 | L1 | t=2 max keys=3 inserted=7 | Descend into child 0 of this node. |
| 14 | L34 | t=2 max keys=3 inserted=7 | 7 goes into the leaf at position 2. |
| 15 | L1 | t=2 max keys=3 inserted=17 | Descend into child 1 of this node. |
| 16 | L35 | t=2 max keys=3 inserted=17 | The child is full (3 keys): split it before descending, so there is always room to push a key up. |
| 17 | L16 | t=2 max keys=3 median=20 | Split the full node: its median 20 moves up into the parent, the lower 1 keys stay and the upper 1 keys go to a new node. |
| 18 | L34 | t=2 max keys=3 inserted=17 | 17 goes into the leaf at position 1. |
| 19 | L1 | keys=8 height=1 t=2 | 8 keys in a tree of height 1: 2 splits, 0 borrows, 0 merges. |
AA trees
An **AA tree** is a red-black tree where only **right** red children are allowed, which cuts the number of cases to two operations. Each node stores a **level** (about its black height). Rules: a left child has a lower level than its parent; a right child has the same or a lower level; a right grandchild has a lower level than the grandparent; every leaf is on level 1. A right child at the same level plays the part of a red node.
Insert recursively and, on the way back, apply **skew** (remove a left horizontal link by a right rotation) and **split** (remove two consecutive right horizontal links by a left rotation and lift the middle node one level). Those two functions are the whole balancing code, which is why AA trees are popular for teaching and for small implementations.
1static class Node {2int key, level = 1;3Node left, right;4Node(int key) { this.key = key; }5}6static Node nil() { Node n = new Node(0); n.level = 0; n.left = n; n.right = n; return n; }7static final Node NIL = nil();89static Node skew(Node n) { // a left child on the same level: rotate right10if (n.left.level == n.level) { Node l = n.left; n.left = l.right; l.right = n; return l; }11return n;12}13static Node split(Node n) { // two right links on the same level: rotate left, promote the middle14if (n.right.right.level == n.level) { Node r = n.right; n.right = r.left; r.left = n; r.level++; return r; }15return n;16}17static Node insert(Node n, int key) {18if (n == NIL) { Node fresh = new Node(key); fresh.left = NIL; fresh.right = NIL; return fresh; }19if (key < n.key) n.left = insert(n.left, key);20else if (key > n.key) n.right = insert(n.right, key);21else return n;22return split(skew(n));23}
Scapegoat trees
A **scapegoat tree** stores no balance information at all, only subtree sizes. A parameter α between 0.5 and 1 (here 0.7) defines the target: the tree is allowed a depth of about log base 1/α of n. After an insertion, if the new node is too deep, walk back up to the first ancestor whose child holds more than α of its subtree: that ancestor is the **scapegoat**. **Rebuild** its whole subtree into a perfectly balanced one by flattening it with an inorder traversal and rebuilding from the middle.
A rebuild is expensive (linear in the subtree) but rare, so insertions cost O(log n) **amortised**, and searches are O(log n) worst case with no extra field per node but the size. (Supporting deletion adds a rule: rebuild the whole tree once it has shrunk enough since the last rebuild.) The method is attractive when memory is tight or when a rotation-free structure is preferred.
1static class Node {2int key, size = 1;3Node left, right;4Node(int key) { this.key = key; }5}6static final double ALPHA = 0.7;7static Node root;8static int n;9static int size(Node x) { return x == null ? 0 : x.size; }1011static void insert(int key) {12List<Node> path = new ArrayList<>();13Node cur = root;14while (cur != null) {15if (key == cur.key) return; // duplicate16path.add(cur);17cur = key < cur.key ? cur.left : cur.right;18}19Node fresh = new Node(key);20if (path.isEmpty()) { root = fresh; n = 1; return; }21Node p = path.get(path.size() - 1);22if (key < p.key) p.left = fresh; else p.right = fresh;23n++;24for (Node a : path) a.size++;25if (path.size() > Math.log(n) / Math.log(1 / ALPHA)) { // the new node is too deep26Node child = fresh;27for (int i = path.size() - 1; i >= 0; i--) {28Node parent = path.get(i);29if (size(child) > ALPHA * size(parent)) { // the scapegoat: a lopsided ancestor30Node rebuilt = rebuild(parent);31if (i == 0) root = rebuilt;32else { Node gp = path.get(i - 1); if (gp.left == parent) gp.left = rebuilt; else gp.right = rebuilt; }33return;34}35child = parent;36}37}38}39static Node rebuild(Node sub) {40List<Node> nodes = new ArrayList<>();41flatten(sub, nodes); // inorder: sorted42return build(nodes, 0, nodes.size() - 1); // middle as the root, recursively43}44static void flatten(Node x, List<Node> out) {45if (x == null) return;46flatten(x.left, out); out.add(x); flatten(x.right, out);47}48static Node build(List<Node> a, int lo, int hi) {49if (lo > hi) return null;50int mid = (lo + hi) >>> 1;51Node x = a.get(mid);52x.left = build(a, lo, mid - 1);53x.right = build(a, mid + 1, hi);54x.size = 1 + size(x.left) + size(x.right);55return x;56}
Weight-balanced trees
A **weight-balanced tree** balances by **subtree size** rather than height: for every node, neither child may hold more than a fixed fraction of the nodes, here at most three times as many as its sibling. The sizes double as order statistics, so rank and k-th smallest come for free. Functional libraries (Haskell's Data.Map and many Scala and OCaml sets) use this scheme because the persistent updates reuse unchanged subtrees.
After an insertion, balance compares the sizes of the children. If the right side is more than three times the left, rotate left, using a double rotation when the right child's inner grandchild is heavy. With the parameters 3 and 2 used here the invariant provably holds for insertions; deletion needs the same function with the mirrored checks.
1static class Node {2int key, size = 1;3Node left, right;4Node(int key) { this.key = key; }5}6static int size(Node n) { return n == null ? 0 : n.size; }7static void update(Node n) { n.size = 1 + size(n.left) + size(n.right); }8static Node rotateLeft(Node x) { Node y = x.right; x.right = y.left; y.left = x; update(x); update(y); return y; }9static Node rotateRight(Node y) { Node x = y.left; y.left = x.right; x.right = y; update(y); update(x); return x; }1011static Node balance(Node n) {12update(n);13int l = size(n.left), r = size(n.right);14if (l + r <= 1) return n; // tiny subtrees are always fine15if (r > 3 * l) { // too heavy on the right16if (size(n.right.left) >= 2 * size(n.right.right)) n.right = rotateRight(n.right); // double rotation17return rotateLeft(n);18}19if (l > 3 * r) {20if (size(n.left.right) >= 2 * size(n.left.left)) n.left = rotateLeft(n.left);21return rotateRight(n);22}23return n;24}25static Node insert(Node n, int key) {26if (n == null) return new Node(key);27if (key < n.key) n.left = insert(n.left, key);28else if (key > n.key) n.right = insert(n.right, key);29else return n;30return balance(n);31}
Practice ladder
Now try it yourself
Easy
- Convert Sorted Array to Binary Search Tree
- Balanced Binary Tree
Medium
- Balance a Binary Search Tree
- Implement a TreeSet with a scapegoat tree
Advanced
- Implement a persistent weight-balanced tree
- Implement an AA tree with deletion
Key takeaways
- 2-3 and 2-3-4 trees stay perfectly balanced by splitting nodes; the tree grows only when the root splits.
- A red-black tree is a 2-3-4 tree drawn as a binary tree.
- An AA tree needs only skew and split to keep the balance.
- A scapegoat tree rebuilds a lopsided subtree, giving amortised O(log n) with no balance field.
- Weight-balanced trees balance by subtree size and give order statistics for free.