AVL Tree: The Original Self-Balancing BST Guarantees O(log n)

- Balance factor at each node stays in {-1, 0, +1}; reaching +2 or -2 triggers one of four rotation cases (LL, RR, LR, RL).
- Height bound of 1.44 log₂ n is proved via Fibonacci trees: N(h) = N(h-1) + N(h-2) + 1, tighter than red-black's 2 log₂ n.
- Insert triggers at most two rotations because a rotation restores the subtree to its pre-insertion height, stopping the cascade immediately.
- Delete can cascade O(log n) rotations to the root because rotations during deletion may shorten the subtree, unbalancing the parent above.
- Height update order is a common implementation bug: always update the node that moved down before the node that moved up in a rotation.
- Augment with a size field per node (updated in O(1) per rotation) to get O(log n) rank and select queries, forming an order-statistics tree.
- Prefer AVL over red-black for read-heavy workloads; the shorter guaranteed height means fewer cache misses per lookup.
Binary search trees are elegant in theory. In practice, you sort the data first and insert it in order, and suddenly your O(log n) tree is an O(n) linked list with extra steps. Georgy Adelson-Velsky and Evgenii Landis fixed this in 1962 with one rule: at every node, the two subtrees can differ in height by at most one. Enforce that on every insert and delete, and height is bounded at 1.44 log₂ n regardless of input order. No special-casing, no "well, unless your data is sorted." Just a hard guarantee.
The mental model: an AVL tree is a BST where every node tracks its own height, and after every mutation the tree checks and repairs itself using pointer swaps called rotations.
Reach for an AVL tree when you need an ordered, dynamic set or map with strict O(log n) on every individual operation, not just amortized. High read-to-write ratio, ordered operations like floor and ceiling, hard worst-case latency requirements: that's the profile where it beats a hash map.
The Invariant That Makes Everything Work
Every node in an AVL tree has a balance factor: height of its left subtree minus height of its right subtree. The invariant is simple: every node's balance factor must stay in {-1, 0, +1}.
A factor of 0 means both subtrees are the same height. A factor of +1 means the left is one level taller. A factor of -1 means the right is one taller. The moment any node reaches +2 or -2, the tree knows it needs to fix itself. It does so immediately, before you even know what happened.
Most implementations store height as an integer in each node rather than the balance factor directly. Balance factor is then computed on demand as height(left) - height(right). Storing height makes the rebalance logic cleaner, because you need the children's heights to compute your own anyway.
Height of null is 0. Height of a leaf is 1.

Every node carries a balance factor. The invariant is that none of them are allowed to hit ±2.
What a Node Looks Like in Memory

32 bytes. Three pointers, a key, and the height field that makes the whole thing work.
Three pointers, a key, and a height integer. The height field costs you 4 bytes of real data and 4 bytes of alignment padding. The net overhead compared to a plain BST node is one 32-bit integer per node. Red-black trees store a single color bit, but thanks to alignment they still end up at the same 32-byte node size in practice. The memory difference between AVL and red-black is negligible. Four bytes. That's the price of certainty.
The height value for a tree of n nodes fits comfortably in a 16-bit integer (max height ≈ 1.44 log₂(10^18) ≈ 86), but most code uses a 32-bit int for alignment reasons.
Why Height Is Capped at 1.44 log₂ n
The proof is worth following because it reveals exactly where the 1.44 constant comes from.
Let N(h) be the minimum number of nodes in any valid AVL tree of height h. To have the fewest possible nodes at height h, one subtree must be a minimum AVL tree of height h-1 and the other must be a minimum AVL tree of height h-2. Going any lower on either side would violate the balance invariant. So:
N(-1) = 0 (empty tree)
N(0) = 1 (single node)
N(h) = N(h-1) + N(h-2) + 1 for h ≥ 1
The sequence N(0)=1, N(1)=2, N(2)=4, N(3)=7, N(4)=12, N(5)=20 grows like Fibonacci numbers. Specifically, N(h) = F(h+3) - 1, where F is the standard Fibonacci sequence (F(1)=F(2)=1). You can verify: N(2) = F(5) - 1 = 5 - 1 = 4 ✓.
The trees that achieve this minimum are called Fibonacci trees, and they represent the worst case for an AVL tree's height at any given node count.
Fibonacci trees of height 0 through 4:
h=0 h=1 h=2 h=3 h=4
● ● ● ● ●
| / \ / \ / \
● ● ● ● ● ● ●
| / \ | / \ / \
● ● ● ● ● ● ● ●
| | |
● ● ●
Since F(k) ≈ φ^k / √5 where φ = (1 + √5) / 2 ≈ 1.618, and N(h) = F(h+3) - 1:
n ≥ N(h) ≈ φ^(h+3) / √5 - 1
Solving for h:
h ≤ log_φ(√5 × (n+1)) - 3
h ≤ (ln(√5 × (n+1)) / ln(φ)) - 3
h ≤ 1.4404 × log₂(n) + constant
An AVL tree with n nodes has height at most ≈ 1.44 log₂(n). For comparison, a red-black tree has maximum height 2 log₂(n+1). The AVL worst case is shorter. An AVL tree with one million nodes is at most 28 levels deep. Fibonacci sequences show up where you least expect them. Nature is relentless.

The minimum-node AVL trees grow like Fibonacci numbers. This is where the 1.44 constant actually comes from.
The Four Rotation Cases
When a node's balance factor hits +2 or -2, one of four cases applies. The naming convention describes which side is too heavy and which way the heavy child leans.

LL and RR are straightforward. LR and RL require two rotations because one rotation would just move the problem sideways.
Left-Left: A Single Right Rotation
Balance factor at z is +2 (left-heavy), and the left child y has balance factor ≥ 0 (leans left or balanced). One right rotation at z fixes it.
Before: After:
z (+2) y
/ \ / \
y T4 x z
/ \ → / \ / \
x T3 T1 T2 T3 T4
/ \
T1 T2
rotateRight(z): make y the new root, y's right subtree T3 moves to z's left, z drops to y's right.
def rotate_right(y): x = y.left t2 = x.right x.right = y y.left = t2 update_height(y) # y moved down: update first update_height(x) # x moved up: update second return x
Height update order is a common bug: always update the node that moved down before the node that moved up.
After the rotation, the subtree height returns to exactly what it was before the offending insertion. No ancestor sees a height change.
Right-Right: A Single Left Rotation
Mirror of Left-Left. Balance factor at z is -2 (right-heavy), right child y has balance factor ≤ 0. One left rotation at z.
Before: After:
z (-2) y
/ \ / \
T1 y z x
/ \ → / \ / \
T2 x T1 T2 T3 T4
/ \
T3 T4
Left-Right: A Zig-Zag Needs Two Rotations
Balance factor at z is +2, but the left child x has balance factor -1 (right-heavy). A single right rotation here would just move the imbalance sideways rather than fix it. Think of it like trying to straighten a bent wire by pushing from one end. You have to straighten the kink first. The fix is two steps.
Start: After step 1 After step 2
(rotateLeft x): (rotateRight z):
z (+2) z (+2) y
/ \ / \ / \
x T4 → y T4 → x z
/ \ / \ / \ / \
T1 y x T3 T1 T2 T3 T4
/ \ / \
T2 T3 T1 T2
Step 1: left-rotate x to convert it to the Left-Left shape. Step 2: right-rotate z.
Right-Left: The Mirror
Balance factor at z is -2, but the right child x has balance factor +1. Right-rotate x first, then left-rotate z. Same mechanics, opposite directions.
Core Operations
| Operation | Time | Worst-case notes |
|---|---|---|
| Search | O(log n) | height ≤ 1.44 log₂ n comparisons |
| Insert | O(log n) | at most 2 rotations total |
| Delete | O(log n) | up to O(log n) rotations |
| Min / Max | O(log n) | walk to leftmost / rightmost leaf |
| Floor / Ceiling | O(log n) | standard BST walk with tracking |
| Predecessor / Successor | O(log n) | with parent pointer: O(1) amortized |
| Range query [lo, hi] | O(log n + k) | k = elements in range |
| In-order traversal | O(n) | produces sorted output |
| Space per node | O(1) | 32-40 bytes typical |
Every bound in this table is worst case. This is the main practical reason to choose an AVL tree over a hash map: every operation is O(log n) even in pathological inputs, and the tree keeps elements in sorted order.
Why Search Is O(log n)
Standard BST search: compare the key at each node, go left or right. The only thing an AVL tree adds is the guarantee that the path length is bounded. At most 1.44 log₂ n comparisons, always. No degenerate case, no worst-case linear chain.
Why Insert Needs at Most Two Rotations
Insert the node via standard BST insertion (find the leaf slot, attach). Then walk back up the path to the root recalculating heights and checking balance factors.
The key insight: after you apply one rotation (single or double) at the first imbalanced ancestor, the subtree's height returns to exactly what it was before the insertion. Every ancestor above it sees the same height it always saw. Their balance factors don't change. Rebalancing stops, always after at most two single rotations.
Why Delete Can Need O(log n) Rotations
Deletion has a fundamentally different property: a rotation during deletion can decrease the subtree height. That height decrease can unbalance the parent above, which may need its own rotation, which may decrease height again and cascade further up. In the worst case you propagate all the way to the root. Insertion is polite. Deletion is a wrecking ball.
Insert: O(1) rotations. Delete: O(log n) rotations in the worst case. Both are O(log n) time overall since the walk is O(log n), but it is a real constant-factor difference in practice.
For the conditions during delete: the LL case triggers when balance > 1 and the left child's balance ≥ 0 (the balance = 0 subcase doesn't arise for insert but does for delete). The other three cases match the same conditions as insert.
Insert: Walk Down, Build Up
def insert(node, key): if not node: return AVLNode(key) if key < node.key: node.left = insert(node.left, key) elif key > node.key: node.right = insert(node.right, key) else: return node # duplicate: no-op return _rebalance(node) def _rebalance(node): _update_height(node) bf = _balance(node) if bf > 1 and _balance(node.left) >= 0: # LL return _rotate_right(node) if bf > 1 and _balance(node.left) < 0: # LR node.left = _rotate_left(node.left) return _rotate_right(node) if bf < -1 and _balance(node.right) <= 0: # RR return _rotate_left(node) if bf < -1 and _balance(node.right) > 0: # RL node.right = _rotate_right(node.right) return _rotate_left(node) return node
The recursive return pattern is idiomatic: each call returns the (possibly new) root of its subtree after rebalancing. The caller assigns the return value back into its child pointer. No parent pointers required. No global state. The whole tree fixes itself on the way back up the call stack, one frame at a time.
Delete: The Same Walk, More Rotations
def delete(node, key): if not node: return node if key < node.key: node.left = delete(node.left, key) elif key > node.key: node.right = delete(node.right, key) else: if not node.left: return node.right # 0 or 1 child if not node.right: return node.left # Two children: replace with inorder successor s = _min_node(node.right) node.key = s.key node.right = delete(node.right, s.key) return _rebalance(node)
The inorder successor is the leftmost node in the right subtree. By definition it has no left child, so deleting it is the simple case. After copying its key up and recursively deleting it, _rebalance is called at every level on the way back up. That's how the cascade of rotations happens: each recursive call returns a potentially shorter subtree, which may unbalance its parent. The node you deleted may be gone, but it leaves a chain reaction on the way out.
One Structure, Every Language
All implementations share the same interface: insert(root, key) and delete(root, key) return the new root pointer. search(root, key) returns the found node or null. Height is stored as an integer in each node. The implementations omit value storage to keep the structural logic visible; extending to a key-value store requires adding a value field alongside key.
class AVLNode: def __init__(self, key): self.key = key self.left = None self.right = None self.height = 1 class AVLTree: def _height(self, node): return node.height if node else 0 def _balance(self, node): return self._height(node.left) - self._height(node.right) if node else 0 def _update_height(self, node): node.height = 1 + max(self._height(node.left), self._height(node.right)) def _rotate_right(self, y): x, t2 = y.left, y.left.right x.right = y y.left = t2 self._update_height(y) self._update_height(x) return x def _rotate_left(self, x): y, t2 = x.right, x.right.left y.left = x x.right = t2 self._update_height(x) self._update_height(y) return y def _rebalance(self, node): self._update_height(node) bf = self._balance(node) if bf > 1 and self._balance(node.left) >= 0: return self._rotate_right(node) if bf > 1 and self._balance(node.left) < 0: node.left = self._rotate_left(node.left) return self._rotate_right(node) if bf < -1 and self._balance(node.right) <= 0: return self._rotate_left(node) if bf < -1 and self._balance(node.right) > 0: node.right = self._rotate_right(node.right) return self._rotate_left(node) return node def insert(self, node, key): if not node: return AVLNode(key) if key < node.key: node.left = self.insert(node.left, key) elif key > node.key: node.right = self.insert(node.right, key) else: return node return self._rebalance(node) def _min_node(self, node): while node.left: node = node.left return node def delete(self, node, key): if not node: return node if key < node.key: node.left = self.delete(node.left, key) elif key > node.key: node.right = self.delete(node.right, key) else: if not node.left: return node.right if not node.right: return node.left s = self._min_node(node.right) node.key = s.key node.right = self.delete(node.right, s.key) return self._rebalance(node) def search(self, node, key): if not node or node.key == key: return node if key < node.key: return self.search(node.left, key) return self.search(node.right, key)
What Problems It Solves
The AVL tree is the right tool whenever you need a sorted, dynamic collection with O(log n) on every access and you can't afford worst-case linear time.
Concrete use cases:
Ordered sets and maps. When you need contains, insert, and delete in sorted order. Hash maps give O(1) average case but O(n) worst case and no ordering. AVL gives O(log n) worst case with sorted order always available.
Range queries. Find all keys in [lo, hi]. In an AVL tree this is O(log n + k) where k is the number of results. You descend once to find the lower bound, then in-order traverse until you exceed the upper bound.
Floor and ceiling. Given a value v, find the largest key ≤ v (floor) or smallest key ≥ v (ceiling). Both are O(log n): traverse the tree tracking the best candidate so far, branch based on comparison.
Predecessor and successor. Find the element just before or just after a given key. O(log n) with just the tree, O(1) amortized with a parent pointer.
Rank and selection with augmentation. Add a size field to each node (count of nodes in the subtree). Update it during rotations in O(1) by recomputing from children: size[x] = size[left] + size[right] + 1. Then OS-SELECT (find kth smallest) and OS-RANK (count elements smaller than x) both run in O(log n). This is exactly the order-statistics tree.
Any problem requiring "insert, delete, and get Kth" in a dynamic set. The augmented AVL is one of the cleaner answers, since the strict balance makes the size field easy to maintain correctly through rotations.
Recognizing the Pattern
Five signals that point to an AVL tree over a hash map or sorted array:
- The collection changes dynamically. Sorted arrays give O(log n) search but O(n) insert/delete. If you're doing many inserts and deletes interleaved with queries, you need a balanced BST.
- You need ordered operations. Floor, ceiling, range queries, min, max, predecessor, successor. A hash map can't answer any of these.
- You need guaranteed worst-case O(log n), not just average. If you're building a real-time system or an adversarial-input-resistant service, hash maps with O(n) worst case are a liability.
- You need rank or selection in a dynamic set. "Kth smallest as elements are inserted and deleted."
- The problem mentions "maintain sorted order under insertions."
Worked Example 1: Count of Smaller Numbers After Self (LC 315)
Problem statement: Given array nums, for each nums[i] return the count of elements to its right that are smaller than nums[i].
Brute force: For each element, scan everything to its right. O(n²).
Why AVL fits: You process elements right to left. When you insert nums[i], you want to know "how many elements currently in the tree are smaller than nums[i]?" That's a rank query. An AVL tree augmented with subtree sizes answers rank in O(log n), and insert is O(log n), giving O(n log n) total.
The algorithm:
- Process
numsright to left. - Before inserting
nums[i], call OS-RANK to count elements strictly less thannums[i]. Record that count. - Insert
nums[i].
Each step is O(log n). Total O(n log n).
The same trick shows up in LC 493 (reverse pairs), LC 327 (count of range sum), and any problem that says "count how many elements to the right satisfy some condition" where you process elements in reverse and query a dynamic sorted structure.
Worked Example 2: Design a Leaderboard
Problem statement: Design a data structure that supports addScore(playerId, score), top(K) (return sum of top K scores), and reset(playerId). All operations should be efficient.
Naive approach: Store scores in a list, sort on every top(K) call. O(n log n) per query.
Why AVL fits: You need to dynamically update scores (insert and delete) and query order statistics (find top K elements). An AVL tree augmented with subtree sum lets you find the top K sum in O(log n + K) time, while insert and delete stay O(log n).
Each node stores its score and subtree_sum = left.subtree_sum + right.subtree_sum + score. To find the top K sum, walk right first: if the right subtree has ≥ K elements, recurse right; if it has exactly r < K elements, add right.subtree_sum to the answer and recurse left asking for the top K-r.
This maps to LeetCode 1244 and similar leaderboard design problems.
AVL Tree vs Standard Libraries
No major language ships an AVL tree in its standard library. Most ordered map/set implementations use red-black trees (C++ std::map, Java TreeMap, C# SortedDictionary). The tradeoff: red-black trees allow a more relaxed balance (height up to 2 log₂(n+1)) in exchange for simpler rotations and fewer rotations per delete.
Reach for an AVL tree over a red-black tree when your workload is read-heavy. A shorter tree means fewer cache misses per lookup. The comparison is explained in detail in AVL Tree vs Red-Black Tree.
For most interview problems, the recursive pattern above is sufficient. In production, use the standard library's ordered containers. You'd implement AVL from scratch in an interview, or when you need an augmentation the library doesn't provide. The context matters a lot.

The interview and the job have different threat models. In an interview, "I implemented AVL from scratch" is a flex. On the job, it means you have a dependency no one else on the team understands.
The Bug That Trips Everyone
Implement an AVL tree yourself and you'll hit one of these. Probably all three on the same attempt.
Wrong height update order in rotations. You must update the node that moved down before the node that moved up. In rotate_right(y), y moves down and x moves up. Update y's height first, then x's. Getting this backwards gives x a stale height that doesn't account for y's new subtree. The tree looks balanced but its heights are lies.
Forgetting rebalance calls cascade on delete. Beginners call rebalance only at the point of deletion, not at every node on the return path. That produces a tree that's locally balanced at the deletion site but broken above it. You'll write a test with 4 nodes, it'll pass, then 100 nodes will corrupt silently.
The LR/RL condition uses > and < not ≥ and ≤. The conditions for the double-rotation cases are strict: balance(node.left) < 0 for LR, not <= 0. If you use <= 0, the LL case never fires for balanced children, and you apply unnecessary double rotations. All your test cases with clean shapes will pass. Then someone inserts a balanced subtree and everything breaks.
Recap
- An AVL tree is a BST where every node's balance factor (height difference between left and right subtrees) stays in {-1, 0, +1}.
- Height is bounded at 1.44 log₂ n. The proof uses Fibonacci trees: N(h) = N(h-1) + N(h-2) + 1 gives N(h) = F(h+3) - 1.
- Four rotation cases: LL (single right), RR (single left), LR (left-rotate child then right-rotate root), RL (right-rotate child then left-rotate root).
- Insert triggers at most two rotations. A rotation restores the subtree to its pre-insertion height, so no ancestor needs fixing.
- Delete can trigger O(log n) rotations. A rotation during delete can shorten the subtree, unbalancing the parent and cascading up.
- Every search, insert, and delete is O(log n) worst case.
- Adding a
sizefield to each node (updated in O(1) per rotation) enables O(log n) rank and selection queries, making it an order-statistics tree. - Standard libraries use red-black trees. AVL trees are the better choice when lookup-to-write ratio is high or you need the shorter guaranteed height.
If you want to practice explaining these structures out loud, the way you'd need to in an actual interview, SpaceComplexity runs voice-based DSA mock interviews with rubric-based feedback on exactly this kind of design and complexity reasoning.
For more on how AVL and red-black trees compare in practice, see AVL Tree vs Red-Black Tree. For the augmented variant with rank and selection, Order-Statistics Tree covers the size-field augmentation and CLRS rotation-order trick in detail. The underlying BST operations are covered in The Binary Search Tree.
Further Reading
- AVL Tree (Wikipedia): history, formal proofs, and comparison with other balanced trees
- Introduction to AVL Tree (GeeksforGeeks): visual walkthrough of all four rotation cases
- Insertion in an AVL Tree (GeeksforGeeks): step-by-step insertion with traced examples
- Deletion in an AVL Tree (GeeksforGeeks): the trickier operation, with all rebalancing cases
- AVLTreeST (Princeton algs4): a clean, well-documented Java implementation with floor, ceiling, rank, and select