9.1 AVL: The Balanced BST
Chapter 6 laid out the dream (BSTs as fast ordered maps) and the nightmare (sorted input operations). Chapter 7.5 showed a probabilistic fix with treaps. This chapter does the rigorous, deterministic version. The structure: an AVL tree, named for its 1962 inventors Adelson-Velsky and Landis — the first self-balancing BST ever published.
Height is measured the usual way (number of edges on the longest root-to-leaf path in the subtree), with one convention: a null (absent) subtree has height . That makes a single-node tree have height (its children are both null with height ; ). Same convention as section 6.8.
Why bother: the Set Data Structure interface
Before defending the AVL invariant in detail, frame what we’re buying. A BST — balanced or not — implements the Set Data Structure interface from chapter 6: an ordered collection keyed by some comparable type. Every operation walks at most one root-to-leaf path, so cost is where is the tree’s height. The whole reason AVL exists is to pin at instead of letting it drift to on adversarial input. Compare:
::: center Operation Plain BST AVL tree
build(A) worst, random
find(k)
insert(x)
delete(k)
find_min / find_max
find_prev / find_next
:::
The plain-BST column has as a free parameter — in the worst case (sorted input, no rebalancing) and every operation degenerates to linear scan. The AVL column has pinned at by construction. That’s the entire chapter’s contribution: the rebalancing machinery in §9.2—§9.4 maintains the invariant cheaply enough that you never see the worst-case height. The Set interface is the same; only the cost column changes — and the change is from “possibly catastrophic” to “always logarithmic.”
Is it an AVL tree? Check every node.
You can’t eyeball AVL-ness by looking at the tree globally — you check the balance factor at every node. A single node whose balance factor is is enough to disqualify the whole tree.
::: center vs. :::
Left: every node’s two subtree heights differ by . AVL.
Right: node has left-subtree height and right-subtree height (null). Balance factor . Not AVL. Even though and would look locally acceptable, the root violates the property.
Height guarantee
Why does this restriction matter? Because it forces .
The proof uses the minimum-node AVL tree at each height: the smallest AVL of height has nodes where , the Fibonacci recurrence. Inverting this gives . The 1.44 constant is the price you pay for AVL-balanced instead of perfectly-balanced — cheap, and well worth it since perfect balance is unreachable during live insertion/removal.
Compare:
::: center Min height (perfect) AVL max height
9 14 19 28 29 43 :::
So even in the worst case, an AVL tree operation on a billion keys costs at most 43 comparisons — still emphatically , not even close to the raw BST’s potential .
Storing height at each node
AVL algorithms need to compute balance factors cheaply. Computing a subtree’s height by recursively walking it is — unaffordable when insert/remove has to check balance factors at every ancestor. The fix: cache the height at each node.
template <typename T>
struct AVLNode {
T data;
AVLNode* left = nullptr;
AVLNode* right = nullptr;
AVLNode* parent = nullptr; // usually wanted for retrace (section 9.3)
int height = 0; // height of THIS subtree; null children = -1
};
template <typename T>
int height(const AVLNode<T>* n) { return n ? n->height : -1; }
template <typename T>
int balanceFactor(const AVLNode<T>* n) {
return height(n->left) - height(n->right);
} With height stored in the node, balance factor is . The cost: after every structural change (insertion, rotation, removal) we must recompute the height of every affected ancestor. This recomputation is why AVL needs parent pointers (or an iterative walk back up from the modified node).
Updating heights after a change
When node changes (gains a child, loses one, is rotated), the heights of and every ancestor up to the root may change. Update rule: Apply from upward toward the root. As soon as you reach an ancestor whose height didn’t change, you can stop — the heights further up are unaffected.
::: center :::
Insert under . After the insertion: is a leaf, . At : . At : . Balance factors: has , has . Still everywhere. Still AVL.
Why this invariant, and not something else?
The “differ by at most 1” constraint is a specific compromise:
-
Stricter (“differ by at most 0” = perfectly balanced): impossible to maintain under arbitrary insertion, because you can’t turn into with perfect symmetry.
-
Looser (“differ by at most 2” or more): allows more imbalance before triggering rebalancing, but height grows faster and search slows down.
-
: tightest strictness that can be maintained with a constant number of local rotations after each modification. It’s the sweet spot.
Red-black trees (the main chapter splits roughly evenly: §9.1—§9.4 AVL, §9.5—§9.8 red-black) use a weaker balance invariant that allows larger height differences but cheaper rebalancing. They’re strictly less balanced than AVL (worst-case height vs. AVL’s ), but their rebalancing has lower overhead on average. std::map uses red-black; competitive programmers and strict-performance workloads sometimes prefer AVL.
Road map for chapter 9
Sections 9.2—9.7 formalize rotations (the single primitive AVL uses to fix violations), give the full insert/remove algorithms, and walk through the four rebalancing cases (left-left, left-right, right-right, right-left). This section established what AVL is; the rest of the chapter is how.
9.2 Rotations: The Only Primitive AVL Needs
Section 9.1 said “we restore balance with local rotations.” This section defines the rotation precisely and gives all the helper procedures AVL operations will need. One operation — the rotation — is the whole mechanical toolkit. AVL insert, AVL remove, and AVL rebalance all boil down to “figure out which rotation to apply, then apply it.”
Rotations were teased in chapter 7.5 with treaps — same operation, different trigger. In a treap the trigger is “a child has a higher priority than its parent.” In an AVL tree the trigger is “this node’s balance factor is .” The rotation primitive is identical in both.
Right rotation
A right rotation at node requires to have a left child . After the rotation, is the new subtree root and becomes ‘s right child. ’s former right child (call it ) becomes ‘s new left child — that’s the move that preserves BST ordering.
::: center :::
Ordering check: before the rotation, the in-order sequence is . After: . Identical — rotations are identity on in-order sequence. Height behavior: if the original tree was left-heavy (left subtree taller), the rotation reduces the height difference; that’s why this fixes AVL violations on the left side.
The generalization to subtrees (not just single nodes) is what makes rotations useful for arbitrary tree sizes:
::: center :::
are arbitrary subtrees. The rotation only rewires three pointers at the top; the subtrees ride along unchanged. That’s why each rotation is .
Left rotation
The mirror image: a left rotation at node requires to have a right child. It promotes the right child to the subtree root.
::: center :::
A left rotation is literally the inverse of the matching right rotation.
Utility: update-height, set-child, replace-child, balance-factor
Before showing rotation code, we need the housekeeping helpers. These all operate on AVLNode<T> from section 9.1 (with cached height and parent fields).
template <typename T>
void avlUpdateHeight(AVLNode<T>* n) {
int lh = (n->left != nullptr) ? n->left->height : -1;
int rh = (n->right != nullptr) ? n->right->height : -1;
n->height = 1 + std::max(lh, rh);
}
template <typename T>
int avlBalance(const AVLNode<T>* n) {
int lh = (n->left != nullptr) ? n->left->height : -1;
int rh = (n->right != nullptr) ? n->right->height : -1;
return lh - rh;
}
// Set `child` as parent's left/right. Fix child's parent pointer.
// Recompute parent's cached height.
template <typename T>
void avlSetChild(AVLNode<T>* parent, bool isLeft, AVLNode<T>* child) {
if (isLeft) parent->left = child;
else parent->right = child;
if (child != nullptr) child->parent = parent;
avlUpdateHeight(parent);
}
template <typename T>
void avlReplaceChild(AVLNode<T>* parent,
AVLNode<T>* currentChild,
AVLNode<T>* newChild) {
if (parent->left == currentChild) avlSetChild(parent, true, newChild);
else avlSetChild(parent, false, newChild);
} avlSetChild does the four things you always forget together: (1) wire parent’s child slot, (2) wire child’s parent pointer, (3) the caller picks which slot, (4) recompute height. Any AVL implementation that tries to inline these will eventually get them out of sync and ship a bug.
Right rotation algorithm
template <typename T>
AVLNode<T>* avlRotateRight(AVLNode<T>*& root, AVLNode<T>* D) {
AVLNode<T>* B = D->left; // must be non-null
AVLNode<T>* BR = B->right; // "C" in the diagram -- may be null
if (D->parent != nullptr) avlReplaceChild(D->parent, D, B);
else { root = B; B->parent = nullptr; }
avlSetChild(B, false, D); // B's new right is D
avlSetChild(D, true, BR); // D's new left is B's old right
return B; // B is the new subtree root
} Three rewires at the top of the subtree, heights recomputed as part of each setChild, and we return the new root so callers can continue walking upward if needed.
Left rotation algorithm
Symmetric — swap every “left” for “right”:
template <typename T>
AVLNode<T>* avlRotateLeft(AVLNode<T>*& root, AVLNode<T>* B) {
AVLNode<T>* D = B->right;
AVLNode<T>* DL = D->left;
if (B->parent != nullptr) avlReplaceChild(B->parent, B, D);
else { root = D; D->parent = nullptr; }
avlSetChild(D, true, B);
avlSetChild(B, false, DL);
return D;
} The rebalance procedure
After any insertion or removal, walk from the modified node up to the root; at each ancestor, update its cached height and check its balance factor. If the balance factor is , apply one or two rotations to fix it. This is the procedure. All four cases (left-left, left-right, right-right, right-left) flow through it:
template <typename T>
AVLNode<T>* avlRebalance(AVLNode<T>*& root, AVLNode<T>* n) {
avlUpdateHeight(n);
int bf = avlBalance(n);
if (bf == 2) { // left-heavy
if (avlBalance(n->left) == -1) // left-right case: pre-rotate
avlRotateLeft(root, n->left);
return avlRotateRight(root, n);
}
if (bf == -2) { // right-heavy
if (avlBalance(n->right) == 1) // right-left case: pre-rotate
avlRotateRight(root, n->right);
return avlRotateLeft(root, n);
}
return n; // -1, 0, or 1: already balanced
} The two inner checks detect the double-rotation cases. If the left child’s balance factor has the opposite sign from the parent’s (i.e., when ), a single right rotation at would not fix the imbalance — we first left-rotate ‘s left child to turn a “zig-zag” into a “zig-zig”, then right-rotate . These are the four cases, collapsed into six lines of code.
The four imbalance cases
::: center Name Trigger (balance factors) Fix
Left-Left , single right-rotate at Left-Right , left-rotate at , then right-rotate at Right-Right , single left-rotate at Right-Left , right-rotate at , then left-rotate at :::
Left-Left and Right-Right are the “easy” cases: the imbalance is already aligned and a single rotation fixes it. Left-Right and Right-Left are the “zig-zag” cases: the imbalance is shaped like a kink in the path from parent to grandchild, and you first rotate to straighten the kink, then rotate again to fix the height.
Why rotations alone are sufficient
It’s worth marveling at: every AVL rebalancing is one or two rotations. The shape adjustment you need to fix an imbalance is always local — contained within a grandparent-parent-child trio — because AVL has such a tight invariant that violations can’t be more than height-two deep before they’re caught. This is what makes AVL fast: worst-case two rotations per insert, one rotation per removal iteration. All per fix; walking back up the tree is , giving total per modification.
9.3 AVL Insertion
Section 9.2 gave us the rotation primitives; this section assembles them into a full insertion algorithm. The structure is simple to state and slightly fiddly to implement:
Retrace: what changes when
Inserting a leaf increases the height of some ancestors by 1 but not others. Specifically, the height of ‘s parent becomes . If previously had a non-null sibling child at the same height, ‘s height doesn’t change — the new node just filled the other slot. If was a leaf before, ‘s height increased from to , and that change propagates upward.
This cascading-height behavior is why we walk all the way up: we don’t know in advance how far the height increase propagates. We stop propagating (for height-update purposes) at the first ancestor whose height didn’t change. But for balance-factor checking we still walk all the way up — even if heights stabilize, a descendant rotation can shift things.
The canonical recipe is simpler in code: “walk to root, call avlRebalance at every ancestor.” avlRebalance updates height and checks balance factor on its own, so a single procedure does both jobs.
The four insertion cases (by imbalance shape)
After inserting, the first unbalanced ancestor (balance factor ) is fixed by one of four cases, named for the direction to the grandchild where the insertion happened:
::: center Shape Signal Fix
Left-Left bf, bf right-rotate at Left-Right bf, bf left-rotate at , then right-rotate at Right-Right bf, bf left-rotate at Right-Left bf, bf right-rotate at , then left-rotate at :::
Left-Left and Right-Right are “straight” imbalances: the insertion went deeper on an already-heavy side, and a single rotation fixes the heights. Left-Right and Right-Left are “zig-zag” imbalances where the first rotation straightens the shape into a Left-Left or Right-Right, and the second rotation then fixes it.
Left-Left example
Insert 12 into this tree:
::: center Before:
after insert 12, before rebalance: :::
After insertion, height of is , height of is . bf. Problem. , , bf: Left-Left case. Single right-rotate at :
::: center :::
Every balance factor is now in . Tree is back to AVL in one rotation.
Left-Right example (the double-rotation case)
Insert 38 into this tree:
::: center Before:
after insert 38: :::
bf, bf: Left-Right case. Two-step fix:
-
Left-rotate at (the left child). This straightens the L-R zig-zag.
-
Right-rotate at .
After step 1 (left-rotate at ):
::: center :::
Now the shape is Left-Left (bf, bf). Step 2, right-rotate at :
::: center :::
Balanced. Right-Right and Right-Left are the mirror images.
The insertion algorithm, with retrace
template <typename T>
void avlInsert(AVLNode<T>*& root, AVLNode<T>* node) {
node->left = node->right = node->parent = nullptr;
node->height = 0;
if (root == nullptr) { root = node; return; }
// Phase 1: standard BST insertion.
AVLNode<T>* cur = root;
while (true) {
if (node->data < cur->data) {
if (cur->left == nullptr) {
cur->left = node; node->parent = cur; break;
}
cur = cur->left;
} else {
if (cur->right == nullptr) {
cur->right = node; node->parent = cur; break;
}
cur = cur->right;
}
}
// Phase 2: retrace. Walk from the parent up to the root,
// rebalancing each ancestor.
for (AVLNode<T>* n = node->parent; n != nullptr; n = n->parent) {
avlRebalance(root, n);
// After a rotation, n->parent might have changed. Re-read via the
// node that came out of rebalance; but if rebalance returned the
// new root, we want to keep going via its parent pointer. The
// for-loop update reads n->parent, so this works as-is because
// setChild/rotations keep parent pointers consistent.
}
} Two passes down-then-up, both . Inserting a duplicate is handled by the "" branch going right — equal keys end up in the right subtree.
Cost
::: center Phase Cost
BST descent (height is AVL-bounded) Retrace ancestors visited Rebalance each per ancestor (at most 2 rotations total) Total :::
Space is auxiliary (a handful of temporaries for the rotation helpers). Compare this to the raw BST insert in section 6.5: identical descent cost, identical insertion cost, plus an retrace that guarantees the tree never degrades. A small constant-factor tax for a permanent worst-case guarantee.
Why the “first unbalanced ancestor” is enough
An important theorem (not proved in the textbook but worth knowing): during AVL insertion, fixing the first unbalanced ancestor restores the AVL property everywhere. Higher ancestors will have their cached heights touched during the retrace, but their balance factors will fall back into automatically because a rotation at the first bad ancestor reduces that subtree’s height by 1, undoing the height increase the insertion caused.
This is why insertion needs at most two rotations total (one pair for the Left-Right or Right-Left case, one for Left-Left or Right-Right). Insertion is cheaper than removal in AVL, which in the worst case may require rotations along the path.
Phone book: why AVL matters
The motivating example is a phone book loaded from already-sorted names. In a raw BST this is the pathological case — height grows to . In an AVL tree, each insertion may trigger a rotation, and after loading sorted keys you end up with height , even though no permutation-shuffling happened. The AVL invariant silently transforms a degenerate input into a balanced structure, at a cost of one or two rotations per insert.
9.4 AVL Removal
Removal is where AVL earns a slightly worse worst-case-rotation count than insertion. For insertion, at most one cascade (one single or one double rotation) handles everything. For removal, a single rotation can shorten a subtree, which can trigger another imbalance one level up, which can trigger another, and so on — up to rotations along the retrace path.
The two-children shortcut
Chapter 6 §6.6 showed that removing a node with two children is handled by copying the in-order successor’s data over the doomed node, then recursively removing the successor. That trick still works for AVL — the successor always has at most one child (it’s the leftmost of the right subtree), so the recursive call lands in the leaf / one-child case and rebalances from there. Recursion handles the retrace; you don’t need to rebalance twice.
The algorithm
template <typename T>
bool avlRemoveNode(AVLNode<T>*& root, AVLNode<T>* node) {
if (node == nullptr) return false;
// Case 1: two children -- copy successor's data, recurse to remove it.
if (node->left != nullptr && node->right != nullptr) {
AVLNode<T>* succ = node->right;
while (succ->left != nullptr) succ = succ->left;
node->data = succ->data;
return avlRemoveNode(root, succ);
}
AVLNode<T>* parent = node->parent;
AVLNode<T>* only = (node->left != nullptr) ? node->left : node->right;
// Case 2: node is the root (0 or 1 child).
if (node == root) {
root = only;
if (root != nullptr) root->parent = nullptr;
}
// Cases 3 & 4: splice `only` (possibly null) into node's slot below parent.
else {
avlReplaceChild(parent, node, only);
}
delete node;
// Retrace: walk up from parent to root, rebalancing every ancestor.
for (AVLNode<T>* n = parent; n != nullptr; n = n->parent) {
avlRebalance(root, n);
}
return true;
}
template <typename T>
bool avlRemoveKey(AVLNode<T>*& root, const T& key) {
AVLNode<T>* node = bstSearch(root, key);
return avlRemoveNode(root, node);
} Notice the retrace walks all the way to the root. For insertion, we could stop at the first rebalanced ancestor; for removal, we can’t. A single rotation might fix one level and shorten the subtree, which exposes a new imbalance at the next level up.
Why removal can cascade
::: center Consider a narrow AVL tree where a rebalancing rotation reduces a subtree’s height by 1. That subtree’s parent, which was balanced before, might now see bf and need its own rotation. That rotation might again reduce height by 1 and propagate. :::
In the worst case this cascade runs the entire height of the tree, so rotations. Each rotation is , so total cost is . Still logarithmic; just a slightly worse constant than insertion.
Walk-through: simple rotation after removal
Start with this AVL tree and remove node :
::: center Before: :::
After removing (case 1 — leaf), the right subtree of is null. Height of ‘s left subtree is , right is . bf. Left-Left imbalance (bf — note this counts as Left-Left because the condition on the child is bf ). Right-rotate at :
::: center After: :::
All balance factors in . Done.
Walk-through: successor case
Remove from:
::: center :::
Node has two children. Successor = leftmost of right subtree = . Copy ‘s data over , then recursively remove (which is now a leaf at its original position):
::: center :::
Retrace from (where was attached): bf. bf, the new root. No rotations. Tree is balanced. Notice: the successor copy only moves data, not the node itself; all parent/child relationships stay intact below.
Cost
::: center Phase Cost
BST search + remove Retrace ancestors Rotations along retrace Up to total (each ) Total :::
Still . The worst-case rotation count is higher than insertion’s (up to instead of at most 2), but each rotation is still constant work and the retrace path is the same length.
Implementation pitfalls
9.5 Red-Black Trees: A Second Balanced BST
Sections 9.1—9.4 covered AVL. Sections 9.5—9.7 cover red-black trees — the other famously self-balancing BST, and the one used by std::map, std::set, Java’s TreeMap, the Linux kernel’s completely-fair scheduler, and most production ordered-map structures.
Both AVL and red-black trees solve the same problem (guaranteed operations on a BST despite arbitrary input). They differ in:
-
The invariant. AVL uses a strict height-balance rule; red-black uses a looser color/black-height rule.
-
The rebalancing cost. Red-black typically needs fewer rotations per insert/remove on average, at the cost of slightly taller trees.
-
Who uses them. Red-black has won in the standard libraries; AVL is more common in competitive programming and legacy academic code.
The five rules, one by one
Rule 1: every node is red or black. Colors are auxiliary state, like AVL’s height field. They’re usually stored as a single bit in each node.
Rule 2: the root is black. A pure convenience — makes some cases easier. If a rebalancing step leaves the root red, you recolor it black.
Rule 3: no red node has a red child. This is the core “can’t cluster” rule. Red nodes are “extra” nodes slotted between black nodes; two reds in a row would distort the balance.
Rule 4: null children count as black leaves. These are the “nil” sentinels. Some implementations create one shared nil node with color black and point every null child at it; others just treat nullptr as “black.”
Rule 5: equal black-height on every root-to-null path. This is the balance invariant. Define black-height bh as the number of black nodes on the path from down to any descendant null sentinel, counting the sentinel but not itself. Rule 5 says bh is well-defined (doesn’t depend on which path you pick).
Why five rules?
Rules 3 and 5 together do the real work. Rule 3 prevents red clusters, so any path has alternating reds and blacks in the worst case. Rule 5 pins down the count of blacks. Combined, the longest path (alternating red-black-red-black-…) is at most twice the shortest (all black), because between any two black nodes along a path you can have at most one red.
Compare:
::: center Structure Worst-case height Typical overhead per node
Perfect BBST impossible to maintain AVL integer height field (4 bytes) Red-black 1 color bit (usually stolen from pointer) Raw BST (no balance) none :::
Red-black trees allow roughly 1.4 more height than AVL, which sounds worse. In practice, red-black wins for the following reasons:
-
The color bit costs less than AVL’s integer height field.
-
Red-black needs fewer rotations per update in practice. Deletion in particular: AVL can cascade rotations, red-black is bounded by 3.
-
Red-black’s amortized cost is lower on mixed workloads; AVL’s strict balance costs more constant work to maintain.
Example: validating a red-black tree
Is this a valid red-black tree? Red nodes are in red text; black nodes plain. Assume every null is a black sentinel.
::: center :::
Check each rule:
-
Every node colored — yes.
-
Root () is black — yes.
-
No red-red: and are red; their children () are black — OK.
-
Nulls are black leaves — by convention.
-
Black-heights: from to any null sentinel under , , , or , we pass through , sentinel = 3 black nodes. Same on every path — OK.
All rules hold. Valid red-black tree.
Counterexample: a red-red violation
::: center :::
Node is red, and its child is also red. Rule 3 violated. Not a valid red-black tree. Insertion or deletion code has to fix this kind of situation with recolorings and rotations — that’s what sections 9.6 and 9.7 will cover.
The bit-stealing trick
In practice, the color bit is not stored as a separate byte. Every pointer on a modern architecture is 8-byte aligned, which means the low three bits of every node pointer are zero. Implementations “steal” one of those bits to encode color: uintptr_t(parent) | 1 means red, uintptr_t(parent) & ~1 means black. Saves a full word per node and is standard technique in std::_Rb_tree_node.
This isn’t required for correctness — you can use a bool isRed field — but it shows how mature implementations pay zero storage overhead for the color state.
Looking ahead
Section 9.6 will cover red-black insertion: attach a new node (always colored red initially), then fix any red-red violation by recoloring or rotating. Section 9.7 will cover red-black removal: harder, because the deleted node’s “blackness” can propagate upward as a “double-black” that needs careful resolution. Both operations end up with a small number of named cases — six for insert, eight for delete — which is why people find red-black trees intimidating. The good news: every case is a constant-work local fix.
9.6 Red-Black Rotations
Red-black rotations are the same operation as AVL rotations: rewire three pointers to preserve BST ordering while reshaping a local subtree. The implementation differs only in what auxiliary state is maintained (color instead of height) and what triggers a rotation (a red-red violation or a black-height violation, not a balance factor).
This section states the primitives. Sections 9.7 uses them to implement red-black insert (and would cover remove).
No colors are rotated; they stay on their original nodes. This is subtle: in a rotation the node identities don’t change, only the parent/child edges do. So if was red before a left rotation, is still red after; only its position in the tree moved.
Utility helpers
The helper signatures mirror AVL’s (section 9.2) minus the avlUpdateHeight call. Red-black doesn’t cache a per-node height; it maintains color instead, which changes via explicit recolor operations (section 9.7), not automatically from structural changes.
enum class Color { Red, Black };
template <typename T>
struct RBNode {
T data;
Color color = Color::Red; // new nodes default red (see 9.7)
RBNode* left = nullptr;
RBNode* right = nullptr;
RBNode* parent = nullptr;
};
template <typename T>
void rbSetChild(RBNode<T>* parent, bool isLeft, RBNode<T>* child) {
if (isLeft) parent->left = child;
else parent->right = child;
if (child != nullptr) child->parent = parent;
}
template <typename T>
void rbReplaceChild(RBNode<T>* parent,
RBNode<T>* currentChild,
RBNode<T>* newChild) {
if (parent->left == currentChild) rbSetChild(parent, true, newChild);
else rbSetChild(parent, false, newChild);
} No height recomputation. No balance-factor check. Just pointer rewiring.
Left rotation algorithm
template <typename T>
void rbRotateLeft(RBNode<T>*& root, RBNode<T>* X) {
RBNode<T>* Y = X->right; // must be non-null
RBNode<T>* YL = Y->left; // may be null
if (X->parent != nullptr) rbReplaceChild(X->parent, X, Y);
else { root = Y; Y->parent = nullptr; }
rbSetChild(Y, true, X); // Y's left becomes X
rbSetChild(X, false, YL); // X's right becomes Y's old left
} Right rotation algorithm
The mirror image:
template <typename T>
void rbRotateRight(RBNode<T>*& root, RBNode<T>* X) {
RBNode<T>* Y = X->left; // must be non-null
RBNode<T>* YR = Y->right; // may be null
if (X->parent != nullptr) rbReplaceChild(X->parent, X, Y);
else { root = Y; Y->parent = nullptr; }
rbSetChild(Y, false, X);
rbSetChild(X, true, YR);
} Side-by-side with AVL rotation
::: center AVL Red-Black
Pointer rewires 3 3 Update cached field height(X), height(Y) none during rotate Triggered by bf red-red or black-height violation Preserves BST ordering yes yes Preserves balance no — it fixes balance no — it helps fix balance along with recoloring :::
The shape-changing part is identical. Red-black’s twist is that rotations alone don’t restore the invariant — you need rotations + recolorings together. Section 9.7 will show how those two primitives compose.
Example: a rotation as standalone visualization
::: center Before left-rotate at :
:::
In-order sequence before: . After: . Identical. That’s the invariant rotations preserve.
When rotations alone aren’t enough
A single rotation on a red-black tree typically does not restore all five invariants. For example, a red node rotated to the top of a subtree might violate rule 2 (root must be black) or rule 3 (no red-red). That’s why red-black insert and remove always combine rotations with explicit recoloring steps.
The cleanest way to think about it: rotations change structure, recolorings change color state, and the balanced-search-tree operations use both together to fix whatever rule was broken. Section 9.7 makes the dance explicit.
9.7 Red-Black Insertion
Red-black insertion has a reputation for being intimidating. The reputation is partially deserved — there are more cases than in AVL — but the logic breaks into a handful of symmetric pairs once you see the pattern. This section walks through the full algorithm and the five cases that can arise.
Why color new nodes red?
A color choice has to be made. If we colored new nodes black, rule 5 (equal black-height on every path) would immediately break — the path through the new node now has one more black than the rest. Fixing that violation is hard; it cascades globally.
Red breaks only rule 3 (no red child of a red parent), and rule 3 is local — you can see if it’s violated by checking the new node’s parent. If the parent is black, no violation, done. If the parent is red, local fix with one of five named cases. Much easier.
Terminology: parent, uncle, grandparent
template <typename T>
RBNode<T>* rbGrandparent(RBNode<T>* n) {
return (n->parent != nullptr) ? n->parent->parent : nullptr;
}
template <typename T>
RBNode<T>* rbUncle(RBNode<T>* n) {
RBNode<T>* g = rbGrandparent(n);
if (g == nullptr) return nullptr;
return (g->left == n->parent) ? g->right : g->left;
} The five cases
Let = the newly-inserted node, = ‘s parent, = ‘s grandparent, = ‘s uncle.
Case 1: is the root. Just color it black. Done. (Rule 2.)
Case 2: is black. No red-red violation. Done.
Case 3: is red and is red. Recolor: and both become black, becomes red. Now black-heights are unchanged below , but might now violate rule 3 with its own parent. Recursively apply fix-up to .
Case 4: is red, is black (or null), is on the “inside.” Inside means is a right child and is a left child, or vice versa. Rotate at to straighten the zig-zag into a zig-zig. Then fall through into case 5.
Case 5: is red, is black (or null), is on the “outside.” Outside means and are on the same side (both lefts, or both rights). Recolor black and red. Rotate at (right if was left, left if was right). Done.
The pattern: cases 3 and 5 recolor; cases 4 and 5 rotate; case 3 recurses; cases 4 and 5 terminate in .
Case 3 in detail: uncle is red
::: center Before: parent red, uncle red, grandparent black.
After recolor: :::
No structure changed; only colors. But is now red, which may conflict with ‘s parent. Recurse the fix-up on . Black-height from any ancestor of is preserved because ‘s path had one black (itself) replaced by a red (itself) but both paths through and gained a black (those nodes became black), so the net along every path stayed the same.
Cases 4 and 5: uncle is black
This is where rotations come in. If the uncle is black, recoloring alone would break rule 5 — the uncle’s path was already black-height balanced, and changing colors without a rotation would shift the count.
Case 4 (zig-zag zig-zig): suppose is the right child of , and is the left child of . Left-rotate at ; this swings into ‘s old position with as ‘s left child. Now and its new left child () are both “on the left” of — we’ve transformed into case 5. Relabel: what was "" we now call ”,” and fall through.
::: center :::
Now apply case 5.
Case 5 (zig-zig fixed): Recolor black, red, then rotate right at :
::: center :::
Now is black (satisfies root or whatever parent had), and are red (but neither has a red parent), and the black-height through every path is the same as before the insertion. Done in .
The full fix-up loop
template <typename T>
void rbBalance(RBNode<T>*& root, RBNode<T>* n) {
while (true) {
if (n->parent == nullptr) { // case 1: n is root
n->color = Color::Black;
return;
}
if (n->parent->color == Color::Black) // case 2
return;
RBNode<T>* p = n->parent;
RBNode<T>* g = rbGrandparent(n);
RBNode<T>* u = rbUncle(n);
if (u != nullptr && u->color == Color::Red) { // case 3
p->color = Color::Black;
u->color = Color::Black;
g->color = Color::Red;
n = g; // recurse upward
continue;
}
// Cases 4/5: uncle is black (or null).
// Case 4: straighten zig-zag into zig-zig.
if (n == p->right && p == g->left) {
rbRotateLeft(root, p);
n = n->left; // which is the old p
} else if (n == p->left && p == g->right) {
rbRotateRight(root, p);
n = n->right; // which is the old p
}
// Case 5: zig-zig fix.
p = n->parent;
g = rbGrandparent(n);
p->color = Color::Black;
g->color = Color::Red;
if (n == p->left) rbRotateRight(root, g);
else rbRotateLeft(root, g);
return; // O(1) termination
}
}
template <typename T>
void rbInsert(RBNode<T>*& root, RBNode<T>* node) {
// Phase 1: BST insert. (Identical to chapter 6 insert; omitted.)
bstInsert(root, node);
node->color = Color::Red;
rbBalance(root, node);
} Cost
::: center Phase Cost
BST descent Fix-up loop iterations in the worst case (case 3 recurses) Rotations per insertion At most 2 (from case 4/5, one shot) Recolorings per insertion Up to (case 3 cascade) Total :::
Insert is always and — notably — does at most two rotations, regardless of how many recolorings happen. That low rotation count (compared to AVL’s worst case) is part of why red-black is preferred in practice: rotations are more expensive than recolorings (three pointer writes vs. one bit flip), and capping rotations at 2 per insert is a solid guarantee.
A worked insertion sequence
Start empty. Insert 22, 11, 33, 55, 44:
-
insert(22): root, colored black. Tree: {22(B)}. -
insert(11): BST puts it left of 22, colored red. Parent black, case 2. Tree: {22(B), 11(R)}. -
insert(33): right of 22, red. Parent black, case 2. Tree: {22(B), 11(R), 33(R)}. -
insert(55): right of 33, red. Parent 33 is red, uncle 11 is red — case 3. Recolor: 11(B), 33(B), 22(R). Recurse at 22. 22 is root, case 1 forces it back to black. Tree: {22(B), 11(B), 33(B), 55(R)}. -
insert(44): left of 55, red. Parent 55 red, uncle = right child of 33 = null (black). 44 is left child of 55, 55 is right child of 33 — case 4 (zig-zag). Right-rotate at 55: 44 becomes 33’s right child, 55 becomes 44’s right child. Then case 5: recolor 44(B), 33(R). Left-rotate at 33. Done.
The whole sequence involves one cascading recolor and one case-4-plus-5 double-rotation. Total for five insertions: at most 2 rotations and a handful of color flips. Tree stays tall throughout.
9.8 Red-Black Tree: Removal
Red-black removal is the most intricate operation in any red-black implementation. Insertion has five cases, rotations are a few lines — removal has six named cases on top of the BST removal logic. This section walks through all of it, but keep the big picture in mind while the details fly by:
Step 1: High-level remove
The entry point is rbRemove(tree, key), which is just BST search + dispatch to node-removal:
void rbRemove(RBTree& t, int key) {
RBNode* n = bstSearch(t.root, key);
if (n) rbRemoveNode(t, n);
} rbRemoveNode handles the classic BST “two-children” reduction: if n has both children, copy the predecessor’s key into n and recursively remove the predecessor node instead. Predecessors are leaves or have only a left child, so we always end up removing a node with child:
void rbRemoveNode(RBTree& t, RBNode* n) {
if (n->left && n->right) {
RBNode* pred = rbPredecessor(n);
int predKey = pred->key;
rbRemoveNode(t, pred); // recurse on a <= 1-child node
n->key = predKey;
return;
}
if (n->color == Color::Black)
rbPrepareForRemoval(t, n); // fix black-height BEFORE removing
bstRemove(t, n->key); // ordinary BST splice-out
}
RBNode* rbPredecessor(RBNode* n) {
n = n->left;
while (n->right) n = n->right;
return n;
} Why red is easy, black is hard
Removing a node means splicing out n and replacing it with its (single or null) child. Two rules could break:
-
Rule 3 (no two reds in a row). Removing a node can’t create a red-red pair as long as we replace with the correct child. Insertion worries about this; removal doesn’t.
-
Rule 5 (equal black-heights). Every root-to-null path must have the same count of black nodes. Removing a black node decrements that count along one path.
If n is red, the black count doesn’t change, and rule 3 is safe because n’s child must have been black (rule 3). Nothing to do.
If n is black, rule 5 breaks. Classical textbooks solve this by imagining n’s replacement carries an extra “double-black” token, and the fix-up pushes that token around until it reaches the root or cancels against a red. The textbook here takes an equivalent but more operational view: classify the local neighborhood into six cases and rewrite it until removal is safe.
Utility functions
Four helpers make the case logic readable. Critically, a null pointer counts as a black node in all of them — matching rule 2 (null leaves are black):
RBNode* rbSibling(RBNode* n) {
if (!n->parent) return nullptr;
return (n == n->parent->left) ? n->parent->right : n->parent->left;
}
bool rbIsRed(RBNode* n) {
return n && n->color == Color::Red;
}
bool rbIsBlack(RBNode* n) {
return !n || n->color == Color::Black; // null is black
}
bool rbBothChildrenBlack(RBNode* n) {
return rbIsBlack(n->left) && rbIsBlack(n->right);
} Step 2: Prepare-for-removal, the six cases
The prepare-for-removal routine classifies the local neighborhood of n (its color, parent, sibling, and sibling’s children) and applies one of six rewrites. Cases 1, 3, 4 are terminal — once they fire, prep is done. Cases 2, 5, 6 are transitional — they massage the tree and fall through to later cases.
The driver
void rbPrepareForRemoval(RBTree& t, RBNode* n) {
if (rbTryCase1(n)) return;
RBNode* s = rbSibling(n);
if (rbTryCase2(t, n, s)) s = rbSibling(n);
if (rbTryCase3(t, n, s)) return;
if (rbTryCase4(n, s)) return;
if (rbTryCase5(t, n, s)) s = rbSibling(n);
if (rbTryCase6(t, n, s)) s = rbSibling(n);
// Fall-through: the final rotation (always runs if we got here).
s->color = n->parent->color;
n->parent->color = Color::Black;
if (n == n->parent->left) {
s->right->color = Color::Black;
rbRotateLeft(t, n->parent);
} else {
s->left->color = Color::Black;
rbRotateRight(t, n->parent);
}
} Note the structure: after case 2, the sibling may have changed (it rotated out), so we refetch. Same after cases 5 and 6. Cases 3 and 4 return immediately. Cases 5 and 6 prepare the configuration for the final fall-through rotation.
Case 1 – trivially removable
bool rbTryCase1(RBNode* n) {
return (n->color == Color::Red) || (n->parent == nullptr);
} A red node’s removal doesn’t disturb black-heights. A root node’s removal — after the BST splice reduces it to a trivial case — is also fine (any single remaining child becomes the new root, will be recolored black implicitly by the fix-up of its children). No action required.
Case 2 – red sibling
bool rbTryCase2(RBTree& t, RBNode* n, RBNode* s) {
if (!rbIsRed(s)) return false;
n->parent->color = Color::Red;
s->color = Color::Black;
if (n == n->parent->left) rbRotateLeft(t, n->parent);
else rbRotateRight(t, n->parent);
return true;
} If the sibling is red, the parent must be black (rule 3), and the sibling’s two children must be black (rule 3 again). Rotate at the parent to promote the sibling up, recolor so the old parent is red and the old sibling is black. n now has a black sibling (which is one of the former sibling’s children) — that’s the precondition for cases 3—6, which do the real work.
Case 3 – push the deficit up
bool rbTryCase3(RBTree& t, RBNode* n, RBNode* s) {
if (n->parent->color != Color::Black) return false;
if (!rbBothChildrenBlack(s)) return false;
s->color = Color::Red;
rbPrepareForRemoval(t, n->parent); // recurse one level up
return true;
} Parent black, sibling black, sibling’s children black. Recolor the sibling red — that drops the black count on the sibling’s side by 1, matching the drop we’re about to cause on n’s side. Now the subtree rooted at the parent has consistent black-heights internally, but the parent as a whole has one fewer black on all paths through it than before. That’s the same problem we started with, one level higher — so we recurse on the parent.
Case 4 – swap colors
bool rbTryCase4(RBNode* n, RBNode* s) {
if (n->parent->color != Color::Red) return false;
if (!rbBothChildrenBlack(s)) return false;
n->parent->color = Color::Black;
s->color = Color::Red;
return true;
} Parent red, sibling branch all black. Swap: parent becomes black, sibling becomes red. On n’s path, we lose a black (from removing n) but gain one (parent flipped to black) — net zero. On the sibling’s path, we lose one (sibling flipped to red) but gain one (parent flipped to black) — net zero. Rule 3 holds because the sibling’s children were already black. Done.
Case 5 – straighten zig-zag (mirror of insertion case 4)
bool rbTryCase5(RBTree& t, RBNode* n, RBNode* s) {
if (!rbIsRed(s->left)) return false;
if (!rbIsBlack(s->right)) return false;
if (n != n->parent->left) return false;
s->color = Color::Red;
s->left->color = Color::Black;
rbRotateRight(t, s);
return true;
} This preps for case 6. If n is a left child and the sibling’s “outer” child (sibling’s right) is black while its “inner” child is red, a rotation at the sibling moves the red to the outer position — now case 6 (or the fall-through) can do its single final rotation.
Case 6 – mirror of case 5
bool rbTryCase6(RBTree& t, RBNode* n, RBNode* s) {
if (!rbIsBlack(s->left)) return false;
if (!rbIsRed(s->right)) return false;
if (n != n->parent->right) return false;
s->color = Color::Red;
s->right->color = Color::Black;
rbRotateLeft(t, s);
return true;
} Same as case 5 but for when n is a right child. Prepares for the fall-through rotation.
The fall-through rotation
After case 5 or 6 (or if neither applied), the sibling has a red child in the “outer” position. The driver’s tail runs:
-
Sibling inherits the parent’s color.
-
Parent becomes black.
-
The outer red child of the sibling becomes black.
-
Rotate at the parent (left if
nwas left child, right otherwise).
The effect: the sibling becomes the new subtree root, painted the old parent’s color (so external black count is preserved). The parent goes down to n’s side, now black (compensating for n’s upcoming removal). The sibling’s outer red child goes to the opposite side, now black (compensating for the shift on that side). All black-heights are restored — and n can now be BST-removed without any violation.
Why this many cases?
Cost analysis
-
Find the node: BST search.
-
Two-children reduction: at most one recursive descent to the predecessor, another .
-
Prepare-for-removal: at most 3 rotations (case 2 + case 5 + fall-through, or case 2 + case 6 + fall-through). Case 3 can recurse up the tree times, but each iteration is recoloring. So the total fix-up work is .
-
Total: time, stack depth (can be rewritten iteratively with parent pointers).
Putting it all together
The complete removal interface:
void rbRemove(RBTree& t, int key) {
RBNode* n = bstSearch(t.root, key);
if (n) rbRemoveNode(t, n);
}
void rbRemoveNode(RBTree& t, RBNode* n) {
if (n->left && n->right) {
RBNode* pred = rbPredecessor(n);
int k = pred->key;
rbRemoveNode(t, pred);
n->key = k;
return;
}
if (n->color == Color::Black)
rbPrepareForRemoval(t, n);
bstRemove(t, n->key);
} 9.9 Augmenting balanced trees
§9.4’s framing notebox hinted at it; this section makes it explicit. The cached height trick that powers AVL rebalancing is one example of a much more general pattern: a balanced BST is a substrate on which you can attach a per-subtree property and maintain it for free. Once you see the pattern, every specialised balanced tree (order-statistic trees, interval trees, range-sum trees) becomes “a balanced BST with one more cached field per node, plus an updated rotation routine.”
Worked example: subtree size for order-statistic queries
The canonical example is order-statistic trees (CLRS §14.1). We want two new operations on a balanced BST:
-
select(k)— return the -th smallest key, -indexed. -
rank(x)— return the number of keys strictly less than .
Both run in with the right augmentation. The augmented property is size(n), the number of nodes in ‘s subtree. Computable from children: size(n) = 1 + size(left) + size(right) — treating null subtrees as size . Constant time per node.
template <typename T>
struct OSNode {
T data;
OSNode* left = nullptr;
OSNode* right = nullptr;
OSNode* parent = nullptr;
int height = 0; // existing AVL augmentation
int size = 1; // NEW: subtree size, including this node
};
template <typename T>
int osSize(const OSNode<T>* n) { return n ? n->size : 0; }
template <typename T>
void osUpdate(OSNode<T>* n) {
// Combined update of every cached subtree property.
int lh = n->left ? n->left->height : -1;
int rh = n->right ? n->right->height : -1;
n->height = 1 + std::max(lh, rh);
n->size = 1 + osSize(n->left) + osSize(n->right);
} The change to the rotation routine is one extra line per rotation: after the three pointer rewires, call osUpdate on both the old root and the new root (in that order — the new root reads the old root’s freshly-updated size). Everything else — BST insert, BST remove, the retrace loop, the four AVL rebalance cases — is untouched.
With size maintained, select(k) walks the tree: descend left if size(left); return the current node if size(left); descend right with size(left) otherwise. One root-to-leaf path . rank(x) is the inverse: standard BST search for , accumulating size(left) + 1 every time the search descends right. Same .
Sequence AVL Tree: same tree, different interface
Order-statistic trees are the Set interface (keyed by value) augmented with rank queries. There’s a sibling interpretation — the Sequence Data Structure interface — that uses the exact same augmentation but exposes a different API. Instead of find(k) which looks up a key, expose get_at(i) which returns the element at position in the in-order traversal. The implementation is identical to select(i) above; only the API contract changes.
This is the framing OCW 6.006 lec 7 leads with: AVL trees are a single substrate that supports two interface families simultaneously, depending on which augmentation you maintain.
-
Set-AVL. Augment with cached height (for rebalancing). API is
find(k),insert(x),delete(k),find_min/max,find_prev/next. All . -
Sequence-AVL. Augment with cached height + subtree size. API is
get_at(i),set_at(i, x),insert_at(i, x),delete_at(i). All .
The Sequence-AVL is the logarithmic-time dynamic array: it lets you splice into the middle of a long sequence without paying the shift that std::vector::insert pays in chapter 4. The price is constant-factor overhead per element (node + parent pointer + size + height field) and a slower sequential scan than a packed array. Worth it when the workload hits middle-of-sequence inserts heavily; not worth it for read-mostly access patterns.