Abstract flowing gradient in deep indigo and blue tones, smooth and luminous, evoking a modern digital learning atmosphere

Computer Science and programming articles. We do not sell courses.

Building a van Emde Boas tree for fast predecessor queries

A predecessor query asks which stored integer is closest to, but not greater than, a supplied key. It appears whenever an application must find the last event before a timestamp, the nearest occupied position before a requested index, or the previous price level allowed by a rule. Fast answers matter because these lookups often sit directly on an interaction path.

A van Emde Boas tree, usually written vEB, serves a known universe of integers from zero to a fixed power-of-two bound. It divides keys into high and low parts and maintains a summary of occupied clusters. This supports insertion, deletion, membership, successor and predecessor operations in O(log log U) time, where U is the universe size. The problem-solving practice material on hello ML links these ideas to programming exercises.

A Python implementation for U=256 makes the invariants and query paths concrete. Predecessor(x) returns the greatest key less than or equal to x; successor(x) is strict, returning the smallest key greater than x. The example also shows when a bitmap or ordered tree is more practical.

When predecessor queries become the bottleneck

Predecessor searches become costly when a service repeatedly scans a sorted collection. A departure board may need the last vehicle record before a requested time; an auction engine may need the highest earlier bid below a limit; a scheduler may need the latest interval that does not overlap a booking. Each task needs one boundary answer, not a complete scan.

The structure must balance update and query costs. A balanced binary search tree gives O(log n) for sparse keys and arbitrary insertions, but a van Emde Boas tree exploits dense integer keys to reach O(log log U). Its recursion and fixed universe are worthwhile only when those properties match the workload.

The recursive cluster layout

Choose U as a power of two whose exponent is also a power of two. For U=256, the high and low halves are 16 bits. The root splits x into high(x)=x div 16 and low(x)=x mod 16, then selects one of 16 clusters. Each cluster owns a 16-value universe, which splits into four clusters of four values. A two-value node is the base case, so a lookup makes at most three recursive descents.

Every node stores its smallest and largest keys. A summary bitmask marks occupied child clusters. At the root, a set bit at index 1 means cluster 1 contains a key; inside a cluster, the same rule applies to smaller groups. These summaries let a query jump over empty regions instead of visiting them one by one.

Each node uses its own universe size as an empty sentinel, outside that node’s valid range. A named constant or None would also work. The key invariant is that each cluster’s extrema match the keys beneath it, and every occupied child has its summary bit set.

Managing membership and structural changes

Insertion descends to the cluster selected by the high part. If that child was empty, its summary bit is enabled before the recursive insertion. The node then refreshes its minimum and maximum from the first and last occupied clusters. This implementation treats keys as a set and ignores duplicates.

Deletion follows the same path, then clears the child’s summary bit if the child became empty. Recomputing extrema upwards handles deletion of a minimum, maximum or only member. The base case distinguishes nodes holding zero, one or two keys.

Because summaries belong to nodes rather than individual keys, they stay small relative to the full universe. In the U=256 example, the largest summary has 16 bits. This compactness suits dense bounded domains such as bitmap positions or compact schedule identifiers.

A working Python implementation

The implementation uses Python integers as bitmasks and recursively creates child nodes. It accepts 2, 4, 16, 256 and compatible larger powers, although the examples target 256 values. Writes outside the universe raise an error rather than becoming negative indices.

from math import isqrt


class VEBTree:
    def __init__(self, universe_size=256):
        exponent = universe_size.bit_length() - 1
        valid = (
            universe_size >= 2
            and universe_size == (1 << exponent)
            and (exponent & (exponent - 1)) == 0
        )
        if not valid:
            raise ValueError("Universe must be 2**(2**k)")

        self.u = universe_size
        self.minimum = self.maximum = self.u

        if self.u > 2:
            self.high = isqrt(self.u)
            self.low = self.u // self.high
            self.summary = 0
            self.clusters = [
                VEBTree(self.low) for _ in range(self.high)
            ]

    @property
    def empty(self):
        return self.minimum == self.u

    def _refresh(self):
        if not self.summary:
            self.minimum = self.maximum = self.u
            return

        first = (self.summary & -self.summary).bit_length() - 1
        last = self.summary.bit_length() - 1
        self.minimum = first * self.low + self.clusters[first].minimum
        self.maximum = last * self.low + self.clusters[last].maximum

    def insert(self, x):
        if not 0 <= x < self.u:
            raise ValueError("Key outside universe")

        if self.u == 2:
            if self.empty:
                self.minimum = self.maximum = x
            else:
                self.minimum = min(self.minimum, x)
                self.maximum = max(self.maximum, x)
            return

        high, low = divmod(x, self.low)
        if self.clusters[high].empty:
            self.summary |= 1 << high
        self.clusters[high].insert(low)
        self._refresh()

    def delete(self, x):
        if not 0 <= x < self.u:
            raise ValueError("Key outside universe")

        if self.u == 2:
            if self.empty:
                return
            if x == self.minimum:
                if x == self.maximum:
                    self.minimum = self.maximum = self.u
                else:
                    self.minimum = self.maximum
            elif x == self.maximum:
                self.maximum = self.minimum
            return

        high, low = divmod(x, self.low)
        self.clusters[high].delete(low)
        if self.clusters[high].empty:
            self.summary &= ~(1 << high)
        self._refresh()

    def contains(self, x):
        if not 0 <= x < self.u:
            return False
        if self.u == 2:
            return self.minimum <= x <= self.maximum

        high, low = divmod(x, self.low)
        return self.clusters[high].contains(low)

    def predecessor(self, x):
        if self.empty or x < self.minimum:
            return None
        if x >= self.maximum:
            return self.maximum
        if self.u == 2:
            return self.minimum

        high, low = divmod(x, self.low)
        local = self.clusters[high].predecessor(low)
        if local is not None:
            return high * self.low + local

        lower = self.summary & ((1 << high) - 1)
        if not lower:
            return None

        previous = lower.bit_length() - 1
        return previous * self.low + self.clusters[previous].maximum

    def successor(self, x):
        if self.empty:
            return None
        if x < self.minimum:
            return self.minimum
        if x >= self.maximum:
            return None
        if self.u == 2:
            return self.maximum

        high, low = divmod(x, self.low)
        local = self.clusters[high].successor(low)
        if local is not None:
            return high * self.low + local

        upper = self.summary >> (high + 1)
        if not upper:
            return None

        following = high + (upper & -upper).bit_length()
        return following * self.low + self.clusters[following].minimum

After inserting 4, 19, 23, 71 and 200, a predecessor query for 70 returns 23, while one for 71 returns 71. Deleting 23 changes the answer for 70 to 19. These examples make inclusive and strict boundary behaviour easy to inspect.

Python’s arbitrary-precision integers are convenient, but their operations are not automatically constant-time as a universe grows. For U=256 the masks are tiny; the O(log log U) claim describes the classical recursive layout under its usual machine-model assumptions. Larger domains require profiling of mask operations and object allocation.

Tracing a query through the hierarchy

For x=70, the root computes high(x)=4 and low(x)=6. Cluster 4 contains 71 but nothing at or below local key 6, so its recursive predecessor returns None. The root then finds the highest occupied lower cluster and takes its maximum, 23. It never scans the gap between 23 and 70.

The successor path mirrors this but excludes x. It searches the current cluster above the local key; if that fails, it finds the next occupied cluster and returns its minimum. Shortcuts handle values below the global minimum or above the global maximum, avoiding recursion at common boundaries.

Correctness rests on ordered clusters and accurate extrema. If a suitable key exists in the current cluster, recursion finds it; otherwise only a lower cluster can hold the predecessor. The summary identifies that cluster, whose maximum is the answer. Random differential tests against a sorted list can verify the invariant over long update sequences.

A practical implementation checklist

Before adopting this structure, check that its assumptions match the workload and that its constant factors justify the recursion. A focused checklist separates design choices from validation.

Design choices:

Performance claims should use the production key distribution. Dense data favours cluster summaries, while sparse data can leave most bits clear and still pay for the hierarchy.

Validation cases:

Documentation should state whether the universe is fixed, how out-of-range reads behave and whether deletion preserves tombstones. Those semantics matter when several components share an index.

Australian deployment considerations and alternatives

For an Australian deployment, a vEB index might sit behind a live journey planner. Sydney’s Opal, Melbourne’s Myki and Brisbane’s Translink support capped, contactless travel, and many commuters check disruptions on a phone. An index could find the latest vehicle event before a requested timestamp while leaving transport rules outside the data structure.

Australian daylight-saving transitions reinforce storing timestamps in UTC instead of repeating local clock values. When operational feeds are linked to accounts, the Privacy Act 1988 and Australian Privacy Principles make data minimisation, access control and identifier separation important design concerns.

Australian banks, payment providers and superannuation platforms often evaluate threshold rules that fit this model. Reuse still depends on universe density, update frequency and audit requirements. Adaptation questions can be directed through the editorial contact channel at hello ML. The alternatives differ in complexity and constant factors; here, w denotes the machine-word size used by a bit operation.

Structure Predecessor query Insert or delete Space Strong fit
van Emde Boas tree O(log log U) O(log log U) O(U) Dense, bounded integer keys
Binary trie O(log U) O(log U) O(U) worst case Variable-width integer keys
Fenwick tree O(log U) O(log U) O(U) Prefix counts and rank queries
Balanced search tree O(log n) O(log n) O(n) Sparse or unbounded keys
Python integer bitmap O(U/w) O(U/w) O(U) bits Very small fixed universes

At U=256, a query visits at most four nodes and every summary remains a small Python integer mask. That makes the structure particularly clear for teaching and for workloads with a genuinely compact key space.