Skip to main content

Balanced Trees and Ordered Sets

Editorial

A short tree on purpose

Introduction to Binary Search Trees ended on a chain: insert sorted values and every one hangs below the last, so the height, and with it every search, grows to n. The fix is one small move that changes a tree's shape without moving any value out of order: a rotation. Balanced trees rotate whenever a path gets too long, which keeps the height near log n, and the sorted sets and maps your language gives you are such trees:

Two trees side by side. Left: 6 at the top with 3 as its left child and a subtree C on its right; 3 has subtree A on its left and subtree B, amber, on its right. Right, after the rotation: 3 at the top with A on its left and 6 on its right; 6 has B on its left and C on its right, and an arrow shows B moving across. Under each tree the same inorder, A, 3, B, 6, C. Focal line: the shape changes, the inorder doesn't. Title: a rotation keeps the order.

Rotations

Take a node, 6, and its left child, 3. Everything in 3's left subtree is below 3, everything in its right subtree is between 3 and 6, and everything on 6's right is above 6. A right rotation lifts 3 into 6's place: 6 becomes 3's right child, and 3's right subtree, the values between them, moves across to become 6's left. Every value is still on the correct side of every node, so the inorder is exactly the same. A left rotation is the mirror. Each one is three links, O(1):

// Lift node's left child into its place and return it: its right subtree moves across.
TreeNode* rotateRight(TreeNode* node) {
    TreeNode* top = node->left;
    node->left = top->right;                 // between top and node, so it stays between them
    top->right = node;
    return top;
}

// The mirror: lift node's right child.
TreeNode* rotateLeft(TreeNode* node) {
    TreeNode* top = node->right;
    node->right = top->left;
    top->left = node;
    return top;
}
def rotate_right(node):
    """Lift node's left child into its place and return it: its right subtree moves across."""
    top = node.left
    node.left = top.right                    # between top and node, so it stays between them
    top.right = node
    return top


def rotate_left(node):
    """The mirror: lift node's right child."""
    top = node.right
    node.right = top.left
    top.left = node
    return top
// Lift node's left child into its place and return it: its right subtree moves across.
static TreeNode rotateRight(TreeNode node) {
    TreeNode top = node.left;
    node.left = top.right;                   // between top and node, so it stays between them
    top.right = node;
    return top;
}

// The mirror: lift node's right child.
static TreeNode rotateLeft(TreeNode node) {
    TreeNode top = node.right;
    node.right = top.left;
    top.left = node;
    return top;
}
// Lift node's left child into its place and return it: its right subtree moves across.
function rotateRight(node) {
    const top = node.left;
    node.left = top.right;                   // between top and node, so it stays between them
    top.right = node;
    return top;
}

// The mirror: lift node's right child.
function rotateLeft(node) {
    const top = node.right;
    node.right = top.left;
    top.left = node;
    return top;
}

Each printed [2, 1, 3] for the chain [1, null, 2, null, 3] rotated left, so 2 rises and the height drops from 2 to 1, and [3, 1, 6, null, null, 4, 7] for [6, 3, 7, 1, 4] rotated right: 3 rises, and 4, which sat between 3 and 6, is now 6's left child.

Below, click a node to lift it over its parent. The inorder under the tree never changes; the height does. Get the chain down to the least height its values allow, or ask the hint, which lifts the middle value of each subtree to its top.

Rotate the Tree: lift nodes until the tree is as short as it can be

When to rotate: AVL and red-black trees

A balanced tree is a binary search tree with a rule about its shape, checked after every insert and delete and restored by rotations. You won't write one in an interview, but you should know the two rules:

  • AVL trees keep, at every node, the heights of its two subtrees within 1 of each other. After an insert, walk back up the path; the first node out of balance is fixed with one rotation when the new value went down a straight line (left then left), and with two when the path bends (left then right): the first rotation straightens the bend, the second lifts the middle value to the top.
  • Red-black trees colour every node red or black: the root is black, a red node never has a red child, and every path from a node down to an empty place passes the same number of black nodes. So the longest path is at most twice the shortest, and the height stays under 2 log₂(n + 1). Fixing the colours takes at most a few rotations per insert.

Step through an AVL tree taking 1 to 7 in order, the input that made a chain. Each rotation lifts the middle of a straight line, and the tree never gets more than one level out of balance:

Inserting 1 to 7 into an AVL tree — Indigo is the value just inserted and orange the node out of balance; in a rotation, green rises over amber.

When the path to the new value bends instead, one rotation isn't enough:

Three small trees in a row. First, 1 with a right child 3, and 3 with a left child 2: a bend, height 2. Lifting 2 over 3 gives a straight line, 1, 2, 3, still height 2. Lifting 2 over 1 gives 2 with 1 on its left and 3 on its right, height 1. Each step is labelled with the rotation. Focal line: a bend takes two rotations, a straight line one. Title: a zig-zag takes two rotations.

Measured on sorted inserts, the worst case for a plain tree: the plain tree's height is n − 1, while the AVL tree's stays near log₂ n, 16 for 100,000 values:

Sorted valuesPlain heightAVL heightPlain / AVL time
1,000999948.8 ms / 11.7 ms
2,0001,99910109.8 ms / 23.6 ms
4,0003,99911432.9 ms / 51.5 ms

Java's TreeMap and TreeSet are red-black trees, and so, in practice, are C++'s map and set. When a problem needs sorted data that changes, use them instead of writing one.

Ordered sets in your language

Each ordered set answers the two questions that come up most, the floor (the largest key at or below x) and the ceiling (the smallest key at or above x):

LanguageSorted set and mapFloor and ceiling of xInsert
JavaTreeSet, TreeMapfloor(x), ceiling(x); floorKey, ceilingKey on a mapO(log n)
C++set, mapthe key before upper_bound(x); lower_bound(x)O(log n)
Pythonnone built in: a sorted lista[bisect_right(a, x) - 1]; a[bisect_left(a, x)]O(n), the list shifts
JavaScriptnone built in: a sorted arrayyour own binary searchO(n), the array shifts
  • Java: floor and ceiling return null when there is no such key; keep the Integer, check it, then unbox. lower and higher are the strict versions.
  • C++: lower_bound(x) is the first key not below x, which is the ceiling, not the floor. The floor is the key just before upper_bound(x), and only if that isn't begin().
  • Python: bisect_right for the floor (it passes keys equal to x), bisect_left for the ceiling. insort keeps a list sorted, but the insert shifts everything after it.
  • JavaScript: there's no sorted container, and sort() with no comparator compares numbers as strings: pass (a, b) => a - b.
// The largest key at or below x, or "none": the one just before the first key above x.
string floorOf(const set<int>& keys, int x) {
    auto it = keys.upper_bound(x);           // the first key bigger than x
    return it == keys.begin() ? "none" : to_string(*prev(it));
}

// The smallest key at or above x, or "none".
string ceilingOf(const set<int>& keys, int x) {
    auto it = keys.lower_bound(x);           // the first key not below x
    return it == keys.end() ? "none" : to_string(*it);
}
from bisect import bisect_left, bisect_right


def floor_of(keys, x):
    """The largest of the sorted keys at or below x, or None."""
    i = bisect_right(keys, x)                # keys[:i] are all at or below x
    return keys[i - 1] if i else None


def ceiling_of(keys, x):
    """The smallest of the sorted keys at or above x, or None."""
    i = bisect_left(keys, x)                 # keys[i:] are all at or above x
    return keys[i] if i < len(keys) else None
// TreeSet answers both directly; each returns null when there is no such key.
static String floorOf(TreeSet<Integer> keys, int x) {
    Integer f = keys.floor(x);
    return f == null ? "none" : f.toString();
}

static String ceilingOf(TreeSet<Integer> keys, int x) {
    Integer c = keys.ceiling(x);
    return c == null ? "none" : c.toString();
}
// No sorted set is built in, so binary-search a sorted array. lo ends at the first key above x.
function floorOf(keys, x) {
    let lo = 0, hi = keys.length;
    while (lo < hi) {
        const mid = (lo + hi) >> 1;
        if (keys[mid] <= x) lo = mid + 1; else hi = mid;
    }
    return lo > 0 ? keys[lo - 1] : "none";
}

// Here lo ends at the first key at or above x.
function ceilingOf(keys, x) {
    let lo = 0, hi = keys.length;
    while (lo < hi) {
        const mid = (lo + hi) >> 1;
        if (keys[mid] < x) lo = mid + 1; else hi = mid;
    }
    return lo < keys.length ? keys[lo] : "none";
}

Each printed 20 30 none none 30 for the keys 10, 20, 30, 40: the floor and ceiling of 25, no floor of 5, no ceiling of 45, and 30 as its own floor.

A sorted list's O(n) insert is fast at first, because the shift is one block move in memory, but it grows with the list. Inserting random values one at a time:

ValuesPython, sorted list and insortJava, TreeSet
50,000466 ms65 ms
100,0001,783 ms108 ms
200,0006,945 ms277 ms

Python's time grew 15 times for 4 times the values, the O(n²) of n shifting inserts; Java's 4 times. At a few thousand inserts the list is fine; at 100,000 it already took 1,783 ms. Past that, Python solutions sort once at the end when they can, or use a third-party sorted container where the judge has one.

Booking with a floor and a ceiling

My Calendar I books [start, end) only if it overlaps nothing booked already. The bookings never overlap each other, so sorted by start they're sorted by end too, and a new booking can only clash with two of them: its floor, the last booking that starts at or before start, which must end by start, and its ceiling, the first one that starts after start, which must begin at or after end:

A timeline with three bookings, 5 to 10, 15 to 20 and 25 to 32, and a new booking 22 to 28 below them. The floor, 15 to 20, ends at 20, before 22, and is ticked green. The ceiling, 25 to 32, starts at 25, before 28, so the overlap from 25 to 28 is shaded red and the new booking is refused. 5 to 10 is greyed: never checked. Focal line: check the floor and the ceiling, nothing else. Title: only two neighbours can clash.
class MyCalendar {
    map<int, int> booked;                    // start → end, sorted by start
public:
    MyCalendar() {}

    bool book(int start, int end) {
        auto after = booked.upper_bound(start);                           // the first booking starting after start
        if (after != booked.end() && after->first < end) return false;
        if (after != booked.begin() && prev(after)->second > start) return false;   // the last one at or before start
        booked[start] = end;
        return true;
    }
};
from bisect import bisect_right

class MyCalendar:
    def __init__(self):
        self.starts = []                     # the bookings' starts, sorted
        self.ends = []                       # each start's end, at the same index

    def book(self, start: int, end: int) -> bool:
        i = bisect_right(self.starts, start)          # the bookings before i start at or before start
        if i > 0 and self.ends[i - 1] > start:        # the floor still runs at start
            return False
        if i < len(self.starts) and self.starts[i] < end:   # the ceiling begins before end
            return False
        self.starts.insert(i, start)                  # O(n): the list shifts
        self.ends.insert(i, end)
        return True
class MyCalendar {
    private final TreeMap<Integer, Integer> booked = new TreeMap<>();    // start → end, sorted by start

    public MyCalendar() {}

    public boolean book(int start, int end) {
        Map.Entry<Integer, Integer> floor = booked.floorEntry(start);     // the last booking starting at or before start
        if (floor != null && floor.getValue() > start) return false;
        Integer ceiling = booked.ceilingKey(start);                       // the first booking starting at or after start
        if (ceiling != null && ceiling < end) return false;
        booked.put(start, end);
        return true;
    }
}
var MyCalendar = function() {
    this.starts = [];                        // the bookings' starts, sorted
    this.ends = [];                          // each start's end, at the same index
};

MyCalendar.prototype.book = function(start, end) {
    let lo = 0, hi = this.starts.length;     // the first booking that starts after start
    while (lo < hi) {
        const mid = (lo + hi) >> 1;
        if (this.starts[mid] <= start) lo = mid + 1; else hi = mid;
    }
    if (lo > 0 && this.ends[lo - 1] > start) return false;                 // the floor still runs at start
    if (lo < this.starts.length && this.starts[lo] < end) return false;    // the ceiling begins before end
    this.starts.splice(lo, 0, start);        // O(n): the array shifts
    this.ends.splice(lo, 0, end);
    return true;
};

Each ran on the problem's own judge with its first example, book(10, 20), book(15, 25), book(20, 30), and returned [null, true, false, true]. In Java and C++ each booking is O(log n). The Python and JavaScript versions find the neighbours in O(log n) too, but their insert shifts the list; at this problem's 1,000 calls that costs nothing.

Spotting it, and when it breaks

The clues. "The largest value at or below", "the closest", "the next bigger", "is there an overlap with what's booked", over data that keeps changing: an ordered set's floor and ceiling. "Sorted, with inserts and deletes": a balanced tree, which your language's ordered set already is.

The near-misses. Data that doesn't change: sort it once and binary-search an array (Binary Search). Only the smallest or the largest, never a floor: a heap (Heaps). Exact lookups with no order: a hash set (Hashing).

When it breaks. A plain binary search tree on sorted or nearly sorted input becomes a chain. A sorted list's O(n) insert dominates by 10^5 inserts (1,783 ms in Python). TreeSet and set keep one copy of each value: count duplicates in a TreeMap or a multiset.

Bugs that cost a submission

Unboxing a missing floor (Java).

static int floorOf(TreeSet<Integer> keys, int x) {
    int f = keys.floor(x);                   // null when nothing is at or below x
    return f;
}

For the floor of 5 in {10, 20} it threw a NullPointerException: there is no key at or below 5, so floor returned null. Keep the Integer and check it.

lower_bound as the floor (C++).

int floorOf(const set<int>& keys, int x) {
    return *keys.lower_bound(x);             // the first key NOT BELOW x: that's the ceiling
}

For 25 in {10, 20, 30, 40} it returned 30, the ceiling; the floor is 20. Step back from upper_bound instead, checking for begin().

bisect_left for the floor (Python).

from bisect import bisect_left

def floor_of(keys, x):
    i = bisect_left(keys, x)                 # stops before a key equal to x
    return keys[i - 1] if i else None

For 30 in [10, 20, 30, 40] it returned 20: bisect_left stops before the 30 itself. The floor uses bisect_right.

Sorting numbers with the default sort (JavaScript).

const keys = [10, 9, 100, 25];
keys.sort();                                 // compares them as strings
console.log(keys.join(" "));

It printed 10 100 25 9, string order, and every binary search on that array is wrong. Pass (a, b) => a - b.

Check yourself

Quiz

1 / 6

Multiple choice

A rotation lifts a child over its parent. What changes?

Practice problems

  1. My Calendar I: one floor and one ceiling per booking.

Next, Binary Search Trees Quiz: the whole section in sixteen questions.