Order-Statistics Tree: The Augmented BST That Knows Its Rank

May 26, 202629 min read
dsaalgorithmsinterview-prepdata-structures
Order-Statistics Tree: The Augmented BST That Knows Its Rank
TL;DR
  • Order-statistics tree augments a balanced BST with one size field per node, enabling O(log n) rank and select queries alongside inserts and deletes.
  • OS-SELECT(k) navigates to the k-th smallest by comparing k to size(left) + 1 at each node, descending in O(log n).
  • OS-RANK(key) counts elements smaller than a key by accumulating left subtree sizes along the search path, also O(log n).
  • The augmentation theorem (CLRS 14.1) guarantees any field computable from a node's children keeps insert/delete at O(log n), with O(1) extra work per rotation.
  • Count in range [lo, hi] reduces to rank(hi+1) - rank(lo), two O(log n) queries with no extra data structure.
  • In C++, use GCC's __gnu_pbds::tree with tree_order_statistics_node_update; for duplicate keys, use pair<int,int> as the key type.
  • The structure fits when queries ask about rank, position, or value-range counts on a dataset that changes between queries.

A regular binary search tree knows one thing: whether a key goes left or right. An order-statistics tree adds exactly one integer per node and turns that relative knowledge into absolute rank. Ask it for the 7th smallest element and it answers in O(log n). Ask how many elements fall below 42 and it answers in the same time, with insertions and deletions continuing in between.

The mental model: a red-black tree where every node also stores the size of its subtree. That single integer is all the augmentation you need. It sounds underwhelming. The payoff is not.

You reach for this structure when a problem demands dynamic rank queries. Static sorted data? Binary search is enough. Dynamic data where queries ask for element rank or the k-th smallest, with inserts and deletes in between? That's the order-statistics tree's domain.


The Problem a Plain BST Cannot Solve

A plain BST can answer "is 42 in the set?" in O(h). It can also answer "what is the rank of 42?" but only in O(n), by doing an inorder traversal and counting. Similarly, finding the k-th smallest requires a full traversal.

The root cause (pun not avoided): each node only knows its own key and where its children live. It has no idea how many nodes lurk in its subtrees. Without that count, every rank query starts from scratch, re-traversing the whole tree the way you re-check every pocket when you can't find your keys.

The fix is to make each node carry a subtree size, updated on every structural change. With it, navigating to rank k becomes a sequence of O(1) decisions, one per level. The tree has O(log n) levels. Problem solved.


One Integer Changes Everything

One field. Four bytes. That heading promises a paradigm shift and technically delivers one, just a very small one.

Every node gets a size field:

size[x] = 1 + size[x.left] + size[x.right]

A leaf has size 1. The root's size equals n, the total number of nodes. Empty sentinels (null pointers) have size 0.

That invariant must hold after every insert, delete, and rotation. Rotations are the tricky part, but it turns out they are also the easy part: a rotation only moves one edge, so only two nodes' sizes ever change.

The Node Layout

┌─────────────────────────────┐
│  key:   42                  │
│  size:  7   ← subtree count │
│  color: BLACK               │
│  left:  → [left child]      │
│  right: → [right child]     │
│  parent:→ [parent]          │
└─────────────────────────────┘

Compared to a plain red-black tree node, you are paying exactly one int (4 bytes) per node. Total overhead: O(n) bytes across the whole tree, which is already O(n) so the constant factor barely shifts.

BST node layout: key, size (highlighted, with subtree count label), color, left, right, parent fields The full node layout. The size field is the only addition. Four bytes per node, O(n) total.


A Tree With Sizes Labeled

Consider inserting 5, 3, 7, 1, 4, 6, 8 into an order-statistics tree. After balancing, the structure and size labels look like this:

               5  (size=7)
              / \
         3(3)    7(3)
         / \     / \
      1(1) 4(1) 6(1) 8(1)

Every node's size is 1 plus the sizes of its children. The root knows the tree has 7 elements. Node 3 knows its subtree has 3 elements (1, 3, 4). This is the entire database that powers O(log n) rank queries. Every query you could want, for the price of one integer per node.

Order-statistics tree with 7 nodes: keys 5, 3, 7, 1, 4, 6, 8 with blue subtree size labels at each node Blue number = subtree size. The root's size equals n. Leaves have size 1.


Core Operations

OperationTimeAuxiliary SpaceNotes
OS-SELECT(k)O(log n)O(log n) call stackk-th smallest, 1-indexed
OS-RANK(key)O(log n)O(log n) call stackcount of elements < key
InsertO(log n)O(log n)size incremented on path down
DeleteO(log n)O(log n)size decremented; rotations O(1) each
SearchO(log n)O(log n)standard BST, unmodified
Count in range [lo, hi]O(log n)O(log n)rank(hi+1) - rank(lo)
Min / MaxO(log n)O(log n)leftmost / rightmost node

All time complexities assume the underlying tree is balanced (height O(log n)). On an unbalanced BST, every bound degrades to O(n). The augmentation itself is correct regardless; it is the balance guarantee of AVL or red-black trees that delivers O(log n).


OS-SELECT: Navigating to Rank k

OS-SELECT(node, k):
  r = size(node.left) + 1        // rank of this node within its own subtree
  if k == r:
    return node.key
  if k < r:
    return OS-SELECT(node.left, k)
  else:
    return OS-SELECT(node.right, k - r)   // subtract entire left side + root

The key decision at each node: the current node's rank within its subtree is exactly size(left) + 1. If k equals that, you found it. If k is smaller, recurse left (keeping k unchanged). If k is larger, recurse right, but subtract r from k because you are leaving r nodes behind on the left.

Trace on the example tree above, finding the 3rd smallest:

At 5 (size=7):  r = sz(3-subtree) + 1 = 3 + 1 = 4.  k=3 < 4, go left.
At 3 (size=3):  r = sz(1-node) + 1   = 1 + 1 = 2.  k=3 > 2, go right with k=3-2=1.
At 4 (size=1):  r = sz(nil) + 1      = 0 + 1 = 1.  k=1 = r, return 4.

3rd smallest is 4. Each step descends exactly one level. The recursion depth is at most h = O(log n). No backtracking, no scanning, no second-guessing.

OS-SELECT traversal for k=3: path through nodes 5 and 3 to found node 4, with computation annotated at each visited node Blue nodes are visited. Amber node is the result. The step table below shows the r computation and decision at each level.


OS-RANK: How Many Elements Are Smaller

OS-RANK(node, key):          // returns count of keys strictly less than key
  if node is null:
    return 0
  if key <= node.key:
    return OS-RANK(node.left, key)
  else:
    return 1 + size(node.left) + OS-RANK(node.right, key)

When the key is larger than the current node, you can credit the entire left subtree plus the current node as "smaller" in one O(1) step. Then recurse right. When the key is smaller or equal, skip this node and its right subtree entirely.

Trace finding the rank of 6 in the same tree:

At 5: 6 > 5, add 1 + sz(left) = 1 + 3 = 4, recurse right.
At 7: 6 < 7, recurse left.
At 6: 6 ≤ 6, recurse left.
At nil: return 0.

Accumulate: 4 + 0 + 0 + 0 = 4. Four elements are strictly less than 6 (they are: 1, 3, 4, 5). The rank within the 1-indexed CLRS convention would be 5, which matches the sorted position of 6 in [1,3,4,5,6,7,8].


Why Insert and Delete Stay O(log n)

The Augmentation Theorem

Augmenting a balanced tree sounds like it should cost you something. CLRS 14.1 says it doesn't, under one condition.

CLRS Theorem 14.1 (the augmentation theorem): if you can compute a node's extra field f solely from f-values of the node's two children plus the node's own stored data, then a red-black tree augmented with f supports insert and delete in O(log n), with O(1) extra work per rotation.

The size field satisfies this exactly: size[x] = 1 + size[x.left] + size[x.right]. It depends only on the node and its children's size fields.

Why does this matter? A red-black tree insert or delete may trigger O(log n) rotations during rebalancing. If every rotation forced an O(n) recomputation of some field, the whole structure would crumble to O(n log n) per operation. But a rotation only restructures two nodes: the rotating node and its child.

Rotation Updates Cost O(1)

Consider a left rotation where node x with right child y rotates, making y the new root of this subtree and x its left child. Only two size values change:

Before left rotation:        After left rotation:
      x  (size=S)                 y  (size=S)
     / \                         / \
    α   y  (size=Q)             x   γ
       / \                     / \
      β   γ                   α   β

S (the total subtree size) does not change: y inherits it directly. Only x's size needs recomputing:

y.size = x.size                      // same total
x.size = 1 + size(α) + size(β)       // now holds only α and β

Two assignments. O(1). Right rotations are symmetric. The invariant is restored in constant time after each rotation, so all the rotations during a single insert or delete add up to O(log n) total extra work. The O(log n) bound holds.

Left rotation diagram: before shows x(S) with children alpha and y(Q), after shows y(S) at root and x recomputed as 1+sz(alpha)+sz(beta) y inherits the total size S unchanged. Only x needs recomputing, from its two new children. Two assignments, then you're done.


Count in Range: The Hidden Superpower

This derived operation is worth knowing separately because it solves a class of problems that seem unrelated to rank.

To count elements in [lo, hi], compute rank(hi + 1) - rank(lo). The result is the number of elements x in the tree satisfying lo ≤ x ≤ hi, computed in O(log n).

Two queries. That's it. The kind of result that makes you feel vaguely guilty about how easy it is.

This converts "how many elements in this window fall between these two bounds?" from a linear scan to two tree traversals. Sliding window problems with value-range constraints, database percentile queries, and streaming anomaly detection all reduce to this operation.


One Structure, Every Language

The implementations below demonstrate the size-augmented BST with insert, select, and rank. They use an unbalanced BST for clarity. In production, the same augmentation logic applies to a self-balancing tree (AVL or red-black). Every operation runs in O(h), which is O(log n) when the tree is balanced. The augmentation is the same in every language. The syntactic ceremony around it varies considerably.

The C++ implementation uses GCC's policy-based data structures, a production-ready balanced order-statistics tree available in competitive programming and systems code.

class Node: __slots__ = ('key', 'left', 'right', 'size') def __init__(self, key: int) -> None: self.key = key self.left = self.right = None self.size = 1 def _sz(n: 'Node | None') -> int: return n.size if n else 0 def _pull(n: 'Node | None') -> None: if n: n.size = 1 + _sz(n.left) + _sz(n.right) def insert(root: 'Node | None', key: int) -> 'Node': if root is None: return Node(key) if key < root.key: root.left = insert(root.left, key) elif key > root.key: root.right = insert(root.right, key) _pull(root) return root def select(root: 'Node', k: int) -> int: """Return the k-th smallest key (1-indexed).""" r = _sz(root.left) + 1 if k == r: return root.key if k < r: return select(root.left, k) # type: ignore[arg-type] return select(root.right, k - r) # type: ignore[arg-type] def rank(root: 'Node | None', key: int) -> int: """Return the count of keys strictly less than key.""" if root is None: return 0 if key <= root.key: return rank(root.left, key) return 1 + _sz(root.left) + rank(root.right, key) root = None for v in [5, 3, 7, 1, 4, 6, 8]: root = insert(root, v) print(select(root, 3)) # 4 (3rd smallest) print(rank(root, 6)) # 4 (four keys < 6: 1, 3, 4, 5)

What Problems It Solves

Dynamic rank queries

Any problem that asks "what is the position of this element in the current sorted order?" with insertions and deletions in between. The database equivalent is "what percentile is this value?" over a mutable dataset. Static data uses binary search; dynamic data uses the order-statistics tree.

k-th order statistics on a stream

You need the k-th smallest element at any moment in a stream of incoming values. Maintaining a sorted array is O(n) per insert; a heap gives you min or max but not an arbitrary rank; the order-statistics tree gives you the k-th smallest in O(log n) per insert and O(log n) per query.

Count inversions

Process an array right to left. For each element, query its rank in the tree built from elements to its right. That rank equals the count of elements to the right that are smaller. Insert the element afterward. Total: O(n log n), one insert and one rank query per element.

Sliding window median

Maintain two order-statistics trees: one for the lower half of the window, one for the upper half. Keep their sizes balanced to within one. The median is either the max of the lower tree or the average of the two roots. Window slides in O(log n) per step.

Count of range elements

Given a dynamic set and a query [lo, hi], how many elements fall in that range right now? The answer is rank(hi + 1) - rank(lo), two O(log n) queries. This appears in LeetCode 327 (Count of Range Sum), geometry problems, and financial analytics.


Recognizing the Order-Statistics Tree Pattern

The signal that an order-statistics tree applies is simpler than it looks. You have a dynamic collection. Queries ask about rank, position, or counts relative to a value. If those queries would be trivial on a sorted array but insertions and deletions happen too frequently to maintain a sorted array, reach for this structure.

Concretely:

  • The problem asks for the k-th smallest, the rank of a value, or the count of values in a range.
  • The data set changes between queries (insertions, deletions, or both).
  • O(n) per query is too slow; O(log n) is required.

Worked Example 1: Count of Smaller Numbers After Self (LeetCode 315)

Problem: Given an array nums, return counts where counts[i] is the number of elements to the right of nums[i] that are smaller.

Why the structure fits: Process elements right to left. Before inserting nums[i], ask "how many elements currently in the tree are strictly less than nums[i]?" That is exactly rank(nums[i]). Then insert nums[i]. Each step is O(log n).

def count_smaller(nums: list[int]) -> list[int]: root = None result = [] for v in reversed(nums): result.append(rank(root, v)) root = insert(root, v) return list(reversed(result))

The naive approach is O(n²) with a nested scan. The order-statistics tree turns it to O(n log n). The alternative of a Fenwick tree requires coordinate compression upfront, which fails when values are not known in advance.

Worked Example 2: Sliding Window Median (LeetCode 480)

Problem: Given an array and window size k, return the median of each window.

Why the structure fits: You need the median at each step after adding one element and removing another. The median is the element at rank (k+1)/2 in the window. A sorted structure with O(log n) insert, delete, and select(k) is exactly the interface required.

Use two order-statistics trees, lo and hi, maintaining len(lo) - len(hi) ∈ {0, 1}. For each window step:

  1. Delete the outgoing element from whichever half contains it.
  2. Insert the incoming element into the appropriate half.
  3. Rebalance if needed (one rotate between halves at most).
  4. The median is lo.select(lo.size) (the maximum of lo), or the average of both halves' endpoints.

Each window step costs O(log k). Total cost O(n log k).

The two-heap approach is simpler to code but does not support O(log n) deletion by value. The order-statistics tree handles deletion directly, which is why it fits this problem class even though it requires more implementation effort.


The Traps to Avoid

Unbalanced insertions destroy the complexity guarantee. Inserting [1, 2, 3, 4, 5] in sorted order into an unbalanced BST produces a linked list of height n. OS-SELECT and OS-RANK still work correctly, but in O(n) time. The augmentation itself is not the fix; the balance is. In production code, always use an AVL or red-black tree as the base. The C++ PBDS implementation is a balanced red-black tree and avoids this entirely. The size field does not care about balance. It is just counting. The tree has to earn O(log n) on its own.

Duplicate keys break C++ PBDS. The GCC ordered_set stores unique elements. It is a set in the mathematical sense, where 5 and 5 are the same 5. Inserting 5 twice leaves one copy, silently, with no error. For duplicates, use pair<int, int> where the second element is a unique ID (like the insertion index). Then order_of_key({v, 0}) returns the count of elements strictly less than v, regardless of how many copies exist. This is the kind of bug that hides cleanly in your solution for two or three wrong submissions before you remember it.

The rank of a key vs. the rank of a node. If there are duplicates, "the rank of key 5" is ambiguous (first copy or last?). The CLRS iterative OS-RANK takes a specific node pointer and is unambiguous. The recursive top-down rank(key) returns the count of keys strictly less than the given value, which is the rank of the first copy. Know which you need before you code.


Recap

  • An order-statistics tree augments a balanced BST with one size field per node.
  • size[x] = 1 + size[x.left] + size[x.right], maintained in O(1) per rotation.
  • OS-SELECT(k): navigate by comparing k to the current left subtree size. O(log n).
  • OS-RANK(key): accumulate left subtree sizes on the path to the target. O(log n).
  • The augmentation theorem guarantees insert/delete stay O(log n) as long as the field computes from node and children alone.
  • Count in range [lo, hi] = rank(hi + 1) - rank(lo). Two queries, O(log n) total.
  • In C++, use GCC's __gnu_pbds::tree with tree_order_statistics_node_update. For duplicates, use a pair key.
  • The structure applies when queries are about rank or position and the data set is dynamic.

If you want to practice explaining the augmentation theorem under pressure or walk through OS-SELECT on a live tree with an interviewer asking follow-up questions, SpaceComplexity runs voice-based DSA mock interviews with rubric scoring across all four dimensions. The select and rank operations are a natural follow-up after a BST problem, and hearing yourself explain the rotation invariant out loud is a different skill than reading about it.

For the underlying self-balancing trees that give the O(log n) guarantee, see the deep dive on AVL trees and red-black trees. For the BST fundamentals that order-statistics trees extend, start at the binary search tree reference. And if your universe of values is fixed and known upfront, a Fenwick tree gives rank and prefix-count queries in O(log n) with smaller constant factors and no pointer chasing.


Further Reading