DSA DSA In Depth
0%
build build-labllrborder-statistics

Build Lab: Left-Leaning Red-Black (LLRB) Order-Statistic Tree with rank and select

7 min read Module 3.2: Self-Balancing, Augmented, Disk-Backed & Persistent Trees Updated Oct 23, 2026
📄 Cheat Sheet
Left-Leaning Red-Black Trees (LLRB): 2-3 Tree Isomorphism & 3-Line Rebalancing
Richard Wang Left-Leaning Red-Black Trees (LLRB): 2-3 Tree Isomorphism & 3-Line Rebalancing Watch on YouTube ↗

Synthesizes Sedgewick's LLRB 2-3 rotation invariants with subtree size augmentation to support O(log N) rank, select, and range count queries.

0:00 / --:--

Architectural Synthesis: LLRB Rebalancing + Subtree Size Invariant

Let us trace how sz (subtree node count) interacts with the three LLRB primitive transformations:

  1. rotateLeft(h) (promotes $x = h.\text{right}$ above $h$): Before rotation, $h$ was the root of the local subtree; after rotation, $x$ is the root of the exact same set of nodes! Therefore, we set x->sz = h->sz and recompute h->sz = 1 + size(h->left) + size(h->right) in $O(1)$ time.
  2. rotateRight(h) (promotes $x = h.\text{left}$ above $h$): Symmetric—set x->sz = h->sz and recompute h->sz = 1 + size(h->left) + size(h->right).
  3. flipColors(h): Only flips boolean color bits without changing any parent-child links, so all sz fields remain untouched!

With sz maintained at every node, we unlock three $O(\log N)$ analytical queries:

  • rank(key): Returns the exact number of elements in the set strictly less than key ($|\{x \in S : x < \text{key}\}|$).
  • select(k) ($0 \le k < N$): Returns the unique key $x \in S$ whose rank(x) == k.
  • rangeCount(lo, hi) ($lo \le hi$): Returns the number of keys in the closed interval $[lo, hi]$ in $O(\log N)$ time via rank(hi) - rank(lo) + (contains(hi) ? 1 : 0)!
Order-Statistic Method LLRB Order-Statistic Tree Unaugmented std::set / TreeMap Sorted std::vector
insert(key) / erase(key) O(log N) O(log N) O(N) Shift
rank(key) (Count $< \text{key}$) O(log N) O(N) std::distance O(log N) lower_bound
select(k) ($k$-th Smallest) O(log N) O(N) std::advance O(1) vec[k]
rangeCount(lo, hi) O(log N) O(N) O(log N)

Exact Dual Identity Between rank and select

For any Order-Statistic Tree $T$ storing $N$ distinct keys:

$$\forall k \in \{0, 1, \dots, N-1\}: \quad \text{rank}(\text{select}(k)) == k$$

$$\forall x \in T: \quad \text{select}(\text{rank}(x)) == x$$

Furthermore, for any query key $q$ (even if $q \notin T$), $\text{rank}(q)$ satisfies the recursive invariant at node $u$:

$$\text{rank}(u, q) = \begin{cases} 0 & \text{if } u == \text{null} \\ \text{rank}(u.\text{left}, q) & \text{if } q < u.\text{key} \\ \text{size}(u.\text{left}) & \text{if } q == u.\text{key} \\ 1 + \text{size}(u.\text{left}) + \text{rank}(u.\text{right}, q) & \text{if } q > u.\text{key} \end{cases}$$

Self-Balancing BST: AVL Balance Factors & LLRB 2-3-4 Rotations
Height ≤ 1.44 log₂N (AVL) / 2 log₂N (RB)

Trace how AVL LL/LR/RR/RL rotations and Sedgewick Left-Leaning Red-Black (LLRB) local transformations (rotateLeft, rotateRight, flipColors) preserve in-order sequence and subtree sizes while bounding tree height.

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Inserted 20: Red right-leaning link on 10 -> violates LLRB left-leaning invariant!
10(B) 20(R)
Operation
Insert 20 (Red)
Rebalance Action
Pending rotateLeft(10)
Black-Height (bh)
1
In-Order Keys
[10, 20, 30]
[Step 1] Inserted 20: Red right-leaning link on 10 -> violates LLRB left-leaning invariant!
LLRB Order-Statistic Node (Key | sz): Red links lean left 20|6 10|3 30|2 5|1 15|1 25|1 Trace select(k = 4) (0-indexed 5th smallest): 1. At (20|6): leftSz = sz(10|3) = 3. Since k=4 > 3: k' = 4 - (3 + 1) = 0; descend right to (30|2) 2. At (30|2): leftSz = sz(25|1) = 1. Since k'=0 < 1: descend left to (25|1) 3. At (25|1): leftSz = 0 == k' ⇒ Return 25! Rotation Invariant: x.sz = h.sz; h.sz = 1 + sz(h.L) + sz(h.R)
flowchart TD R["rank(key)"] --> Loop["u = root; count = 0"] Loop --> Check{"u != null ?"} Check -- "No" --> Ret["return count"] Check -- "key < u.key" --> GoL["u = u.left"] --> Check Check -- "key == u.key" --> Hit["return count + size(u.left)"] Check -- "key > u.key" --> GoR["count += 1 + size(u.left) u = u.right"] --> Check

Hardware Reality: Iterative rank and select Without Recursion

While LLRB insert uses bottom-up recursion along the $O(\log N)$ path so rotations can unwind cleanly, rank(key), select(k), and contains(key) are read-only downward walks! Writing rank, select, and contains as tight while (u != nullptr) loops avoids all stack-frame push/pop overhead and executes in $\le 2 \log_2 N$ branch-predicted pointer hops.

Production Reference Implementation: Complete LLRB Order-Statistic Tree

// C++20: Complete Left-Leaning Red-Black (LLRB) Order-Statistic Tree
#include <cstdint>
#include <cassert>

template <typename Key>
class LLRBOrderStatisticTree {
    struct Node {
        Key key;
        int32_t sz = 1;
        bool red = true;
        Node *left = nullptr, *right = nullptr;
        explicit Node(Key k) : key(k) {}
    };

    Node* root = nullptr;

    static bool isRed(const Node* u) noexcept { return u != nullptr && u->red; }
    static int32_t size(const Node* u) noexcept { return u ? u->sz : 0; }
    static void pull(Node* u) noexcept { u->sz = 1 + size(u->left) + size(u->right); }

    static Node* rotateLeft(Node* h) noexcept {
        Node* x = h->right;
        h->right = x->left;
        x->left = h;
        x->red = h->red;
        h->red = true;
        x->sz = h->sz; // x replaces h as root of this exact subtree
        pull(h);        // Recompute demoted node h
        return x;
    }

    static Node* rotateRight(Node* h) noexcept {
        Node* x = h->left;
        h->left = x->right;
        x->right = h;
        x->red = h->red;
        h->red = true;
        x->sz = h->sz;
        pull(h);
        return x;
    }

    static void flipColors(Node* h) noexcept {
        h->red = !h->red;
        h->left->red = !h->left->red;
        h->right->red = !h->right->red;
    }

    static Node* insertRec(Node* h, const Key& key) {
        if (!h) return new Node(key);
        if (key < h->key)      h->left  = insertRec(h->left, key);
        else if (h->key < key) h->right = insertRec(h->right, key);

        if (isRed(h->right) && !isRed(h->left))      h = rotateLeft(h);
        if (isRed(h->left)  && isRed(h->left->left))  h = rotateRight(h);
        if (isRed(h->left)  && isRed(h->right))       flipColors(h);

        pull(h);
        return h;
    }

public:
    int32_t size() const noexcept { return size(root); }

    void insert(const Key& key) {
        root = insertRec(root, key);
        root->red = false; // Root is always Black
    }

    bool contains(const Key& key) const noexcept {
        const Node* u = root;
        while (u) {
            if (key < u->key)      u = u->left;
            else if (u->key < key) u = u->right;
            else return true;
        }
        return false;
    }

    // Number of keys strictly less than 'key' in O(log N) time
    int32_t rank(const Key& key) const noexcept {
        const Node* u = root;
        int32_t count = 0;
        while (u) {
            if (key < u->key) {
                u = u->left;
            } else if (u->key < key) {
                count += 1 + size(u->left);
                u = u->right;
            } else {
                return count + size(u->left);
            }
        }
        return count;
    }

    // 0-indexed k-th smallest key in O(log N) time
    const Key& select(int32_t k) const noexcept {
        assert(k >= 0 && k < size(root));
        const Node* u = root;
        while (u) {
            int32_t leftSz = size(u->left);
            if (k == leftSz) return u->key;
            if (k < leftSz) {
                u = u->left;
            } else {
                k -= (leftSz + 1);
                u = u->right;
            }
        }
        return root->key;
    }

    // Count of keys in closed interval [lo, hi] in O(log N) time
    int32_t rangeCount(const Key& lo, const Key& hi) const noexcept {
        if (hi < lo) return 0;
        return rank(hi) - rank(lo) + (contains(hi) ? 1 : 0);
    }
};

Failure Modes & Adversarial Pitfalls

  • Unconditionally Incrementing h->sz++ on Descent When Inserting a Duplicate Key: If insert(key) is called with a key that already exists in the set, no new leaf is created! Incrementing sz on the way down corrupts every ancestor's size whenever a duplicate is inserted. Always recompute h->sz = 1 + size(h->left) + size(h->right) bottom-up on the way back up (pull(h)).
  • Off-By-One in rangeCount(lo, hi): Because rank(key) counts elements strictly less than key, rank(hi) - rank(lo) counts keys in the half-open interval $[lo, hi)$. To count the closed interval $[lo, hi]$, you must add $+1$ if contains(hi) is true (or compute rank(hi_successor) - rank(lo)).
Check yourself Question 1 of 4
Concept Check Order of Size Recomputations During a Left Rotation recall

In your augmented LLRB Order-Statistic Tree, `rotateLeft(h)` promotes `x = h.right` to be the new root of the subtree, with `h` becoming `x.left`.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Select the complete invariant verification suite for a 2-3 LLRB Order-Statistic Tree:

Key Takeaways

Combining Sedgewick's LLRB 2-3 tree with subtree size augmentation sz(u) = 1 + sz(L) + sz(R) yields a self-balancing Order-Statistic Tree in under 95 lines of code.
Inside rotateLeft(h) and rotateRight(h), the promoted node x inherits x.sz = h.sz while the demoted node h recomputes h.sz = 1 + sz(h.left) + sz(h.right); flipColors(h) does not alter sizes.
Always recompute sz bottom-up via pull(h) after recursive insertion so duplicate key insertions never inflate subtree counts.
Using rank(key) (strictly less than key), closed-interval range counts [lo, hi] equal rank(hi) - rank(lo) + (contains(hi) ? 1 : 0) in O(log N) time.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue