Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Balanced Trees  ›  Lesson

AVL and Red-Black Trees

AVL & Red-Black Trees 32 minA balance factor caught after every insert, fixed by one of four rotation recipes
You're building a piece ofQuarry — the index layer
This piece — avl_insert_sequence(): Inserts and rebalances so the whole index never grows past O(log n) deep.
Scenario Quarry's index layer gets keys in every order a real workload produces — sorted batches, random inserts, bursts of near-duplicates — and it has to guarantee O(log n) lookups regardless.
Your task
Build avl_insert_sequence(values): insert values one at a time into an AVL tree, rebalancing with rotations after every insert, and return {"inorder": [...], "height": ...}. Example: avl_insert_sequence([1,2,3,4,5,6,7,8]) -> {"inorder": [1,2,3,4,5,6,7,8], "height": 4}, avl_insert_sequence([3, 2, 1]) -> {"inorder": [1, 2, 3], "height": 2}.

AVL and Red-Black Trees

In short: an AVL tree rebalances after every insert so that for every node, the heights of its two subtrees never differ by more than 1 — its balance factor (bf = height(left) - height(right)) always stays in {-1, 0, 1}. An insert can push several ancestors' bf to ±2 at once, but fixing the lowest unbalanced one with one of four rotation recipes — LL, LR, RR or RL — restores that subtree to its pre-insert height, which makes every ancestor above it balanced again too. A red-black tree relaxes the rule (a looser "never more than twice as deep on one side") and fixes violations with cheaper recolouring most of the time, trading a slightly taller tree for fewer rotations per insert.

Four complete before/after case cards — LL and RR as straight lines fixed with one rotation, LR and RL as zigzags fixed with two — each node badged with its balance factor

Keeping a leaderboard fast after a losing streak

A game's leaderboard indexes players by score. Scores update constantly, and a bad run of matches can insert a long streak of similar, nearly-sorted scores — exactly the pattern that degrades a plain BST. An AVL-backed index keeps lookups at O(log n) no matter how the scores arrive, by checking the balance factor after every single insert and fixing it immediately if it's broken.

python

bf(70) comes out -1 — its right child 90 makes that side one level taller — and that extra level echoes up to the root: bf(50) is -1 too, while bf(30), a leaf, is still 0. None of these cross ±2 yet, so no rotation fires. That's the check an AVL insert runs after every single node on its way back up, not just at the root.

An order book sorted by price

A trading system keeps active buy orders in a structure sorted by price, so it can answer "what's the best bid right now?" in O(1) and insert a new order in O(log n). Order arrivals cluster — a price move brings a burst of orders near the same level — which is exactly the nearly-sorted pattern that needs rebalancing, not just balancing once at startup.

The mistake people actually make implementing this: forgetting to call update(node) on the way back up, so every node's own height silently stays frozen at its initial value of 1 — which means bf() never sees a real imbalance, and rotations simply never fire.

broken — fix it

This prints 1 (the root's height never updated past its initial value) instead of 3, the real AVL height for 5 descending inserts. Add the missing `update(node)` call.

The four shapes, and why there are exactly four

An insert can leave several ancestors on its path with bf at ±2 at once — not just the nearest one. But fixing the lowest unbalanced ancestor first restores that whole subtree to the height it had before the insert, so every ancestor above it sees no height change at all, and needs no fix of its own. One rotation (or two, for a zigzag) is always enough for the entire path, applied once, at the lowest violation.

Which of the four shapes applies depends only on that lowest node's two children's relative heights. Heavy on the left, and the left child is itself left-heavy (or balanced) → LL. Heavy on the left, but the left child is right-heavy → LR, a zigzag needing two rotations. RR and RL mirror these on the right.

watch it move · tree

LL case. Node 3 has bf=1 before the insert lands under its left child, 2.

Before: 3's left subtree (through 2) is one level taller than its empty right side. Binary tree, 4 nodes, height 3.

1 / 5
watch it move · tree

RR case, mirroring LL on the right. Node 8 has bf=-1 before the insert lands under its right child, 9.

Before: 8's right subtree (through 9) is one level taller than its empty left side. Binary tree, 4 nodes, height 3.

1 / 5

LR and RL are the trickier pair — the imbalance is real, but a single rotation at the top would just rotate the zigzag sideways, not straighten it:

watch it move · tree

LR case. Node 3 has bf=1 before the insert lands as 1's RIGHT child — a zigzag, not a line.

Before: 3's left subtree (through 1) is one level taller. Binary tree, 4 nodes, height 3.

1 / 6
watch it move · tree

RL case, mirroring LR on the right. Node 8 has bf=-1 before the insert lands as 12's LEFT child — a zigzag.

Before: 8's right subtree (through 12) is one level taller. Binary tree, 4 nodes, height 3.

1 / 6
python

A real sequence, run for real

Here's every case, caught live, inserting 10, 20, 30, 25, 28, 5, 4 one at a time:

step through it
1class Node:
2 def __init__(self, v):
3 self.v = v
4 self.left = self.right = None
5 self.h = 1
6 
7def h(n):
8 return n.h if n else 0
9 
10def update(n):
11 n.h = 1 + max(h(n.left), h(n.right))
12 
13def bf(n):
14 return h(n.left) - h(n.right)
15 
16def rotate_right(y):
17 x = y.left
18 y.left = x.right
19 x.right = y
20 update(y); update(x)
21 return x
22 
23def rotate_left(x):
24 y = x.right
25 x.right = y.left
26 y.left = x
27 update(x); update(y)
28 return y
29 
30def insert(node, v):
31 if node is None:
32 return Node(v), None
33 if v < node.v:
34 node.left, case = insert(node.left, v)
35 else:
36 node.right, case = insert(node.right, v)
37 update(node)
38 balance = bf(node)
39 if balance > 1:
40 if bf(node.left) < 0:
41 node.left = rotate_left(node.left)
42 case = ("LR", node.v)
43 else:
44 case = ("LL", node.v)
45 node = rotate_right(node)
46 elif balance < -1:
47 if bf(node.right) > 0:
48 node.right = rotate_right(node.right)
49 case = ("RL", node.v)
50 else:
51 case = ("RR", node.v)
52 node = rotate_left(node)
53 return node, case
54 
55root = None
56for v in [10, 20, 30, 25, 28, 5, 4]:
57 root, case = insert(root, v)
58 label = f"{case[0]} at {case[1]}" if case else "no rotation"
59 print(f"insert {v:>2}: {label}")

Three of the four cases fire in this one short sequence: 30 triggers RR at 10 (two ascending inserts in a row), 28 triggers LR at 30 (a zigzag landing under 25), and 4 triggers LL at 10 (two descending inserts). RL doesn't happen to come up here — see the viz above for it in isolation — but the mechanism is identical: find the lowest unbalanced node, read which shape its children make, apply the matching recipe.

Red-black recolouring: the cheaper alternative

AVL keeps the tightest possible balance (height ≤ ~1.44 log₂n), which makes lookups a hair faster but can mean more rotations while inserting or deleting. A red-black tree colours every node red or black and enforces a looser rule instead: no red node has a red child, and every path from a node to its descendant leaves passes through the same number of black nodes. Fixing a violation after an insert is usually just recolouring a parent and uncle red-to-black (O(1), no pointers move) and only escalates to a rotation when recolouring alone can't fix it. The tree that comes out can be up to twice as deep as an AVL tree in the worst case, but the average insert is cheaper — which is why most language standard libraries (C++'s map, Java's TreeMap) use red-black trees, not AVL.

AVL red-black
balance rule ` bf
worst-case height ~1.44 log2(n) ~2 log2(n)
typical fix cost 1–2 rotations O(1) recolouring, rotation only sometimes
best for read-heavy (lookups dominate) write-heavy (inserts/deletes dominate)

Quick practice

What does this print?
def rotation_case(bf_node, bf_child):
    if bf_node > 1:
        return "LL" if bf_child >= 0 else "LR"
    if bf_node < -1:
        return "RR" if bf_child <= 0 else "RL"
    return "balanced"

print(rotation_case(2, -1), rotation_case(-2, 1))
broken — fix it

This prints 4 1 instead of 2 1 — x's height comes out far too tall because it was recalculated using y's old, pre-rotation height. Swap the order of the two update() calls.

What it costs

operation AVL red-black
search O(log n) O(log n)
insert O(log n), ≤ 2 rotations O(log n), O(1) amortised recolouring
delete O(log n), up to O(log n) rotations O(log n), O(1) amortised recolouring
height bound for n nodes ~1.44 log2(n+2) ~2 log2(n+1)

🎯 Your turn

Build avl_insert_sequence(values): insert values one at a time into an AVL tree, rebalancing with rotations after every insert, and return {"inorder": [...], "height": ...}.

  • avl_insert_sequence([1, 2, 3, 4, 5, 6, 7, 8]) → {"inorder": [1, 2, 3, 4, 5, 6, 7, 8], "height": 4}
  • avl_insert_sequence([3, 2, 1]) → {"inorder": [1, 2, 3], "height": 2}

Hint: reuse insert, rotate_left, rotate_right, bf and update from this lesson; after every insert, check the current node's balance factor and fix LL/LR/RR/RL as it unwinds back up to the root.

Press ▶ Run, then ✓ Check.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upRead the balance factor

Write balance_factor_of(values): insert values into a PLAIN BST (no balancing), and return the root's balance factor (height(left) - height(right)).

binary-search-trees
DrillName the case

Write rotation_case(bf_node, bf_child): given an unbalanced node's balance factor and its HEAVIER child's balance factor, return "LL", "LR", "RR", "RL", or "balanced" if bf_node is within [-1, 1].

balanced-trees
BuildWhich fix did this insert trigger?

Write single_insert_rotation(values, new_value): build an AVL tree from values (already balanced), then insert new_value and return which rotation case it triggered ("LL", "LR", "RR", "RL"), or "none" if the tree stayed balanced without one.

balanced-trees
BossHow many rotations for the whole sequence?

Write count_total_rotations(values): insert every value into an AVL tree, and return the TOTAL number of individual rotations performed across the whole sequence (an LR or RL fix counts as 2).

balanced-treescomplexity
CapstoneThe tallest an AVL tree of n nodes can be

Write max_avl_height(n): the greatest height ANY valid AVL tree with n nodes could have. Use the fact that the minimum number of nodes an AVL tree of height h can have follows N(0)=0, N(1)=1, N(h)=N(h-1)+N(h-2)+1.

balanced-treescomplexity
avl_insert_sequence.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
avl_insert_sequence(values) → dictIn-order sequence and final height after inserting every value into an AVL tree.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.