DSA DSA In Depth
0%
lesson fractional-cascadingsearchdata-structures

Fractional Cascading: Searching K Sorted Lists in O(log N + K)

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
MIT 6.851 Lecture 3: Geometric Data Structures & Fractional Cascading
MIT OpenCourseWare MIT 6.851 Lecture 3: Geometric Data Structures & Fractional Cascading Watch on YouTube ↗

Erik Demaine explains Chazelle and Guibas's Fractional Cascading technique, showing how propagating every second element upward eliminates K-1 binary searches.

0:00 / --:--

The $O(k \log n) \to O(\log n + k)$ Iterative Search Bottleneck

In computational geometry (2D orthogonal range trees, segment trees of sorted vectors), database multi-column index lookups, and packet classification, we often store a catalog of $k$ sorted arrays $L_0, L_1, \dots, L_{k-1}$, each of size $\approx n$, and need to compute $\text{lower\_bound}(L_i, q)$ for the same query value $q$ in every list $i = 0 \dots k-1$.

Executing $k$ independent binary searches takes $O(k \log n)$ time. Could we store a pointer from each element of $L_0$ to its successor in $L_1$, $L_2$, etc.? If we simply merged all $k$ lists into $L_0$, $L_0$ would blow up to size $k n$, and propagating full lists across $k$ levels would consume $\Theta(k^2 n)$ space! Bernard Chazelle and Leonidas Guibas (1986) solved this space-time trade-off with Fractional Cascading: propagate only a $\frac{1}{2}$-fraction of each augmented list upward!

Fractional Cascading Construction & Geometric Space Bound

We construct augmented lists $M_0, M_1, \dots, M_{k-1}$ bottom-up from $i = k - 1$ down to $0$:

  1. Base List ($i = k - 1$): Set $M_{k-1} = L_{k-1}$.
  2. Inductive Step ($i = k - 2 \text{ down to } 0$): Form $M_i$ by merging $L_i$ with every second element (even indices $0, 2, 4, \dots$) of $M_{i+1}$!
  3. Two Pointers per Entry in $M_i$: Each entry $e \in M_i$ stores:
    • real_idx: The index of the smallest element $\ge e.\text{val}$ in the original list $L_i$.
    • next_cascade_idx: The index of the smallest element $\ge e.\text{val}$ in the next augmented list $M_{i+1}$.

Why Space Remains Strictly Linear $\sum_{i=0}^{k-1} |M_i| \le 2 \sum_{i=0}^{k-1} |L_i|$:

Each list $L_j$ contributes $|L_j|$ elements to $M_j$, $\frac{1}{2}|L_j|$ elements to $M_{j-1}$, $\frac{1}{4}|L_j|$ elements to $M_{j-2}$, and so on. Summing the geometric series $1 + \frac{1}{2} + \frac{1}{4} + \frac{1}{8} + \dots = 2$ proves that the total size of all augmented lists is at most $2N$ ($O(kn)$ space, zero asymptotic blowup)!

Why Query Time per Level Is $O(1)$ (At Most 1 Comparison!):

Because every second element of $M_{i+1}$ was promoted into $M_i$, any two promoted elements in $M_i$ have at most one unpromoted element of $M_{i+1}$ sitting between them! Thus, once we know the successor of $q$ in $M_i$ (at index $p$), following $p$'s bridge pointer into $M_{i+1}$ lands at an index $c$ such that the true successor of $q$ in $M_{i+1}$ is guaranteed to be either $c - 1$ or $c$—requiring just a single if (c > 0 && M_{i+1}[c - 1].val >= q) --c; comparison!

Fractional Cascading O(log N + k) Bridges & Eytzinger Cache-Line Search
O(log N + k) / Branchless BFS Layout

Compare Chazelle-Guibas Fractional Cascading (propagating every 2nd element from L_{i+1} into L_i to replace k binary searches with O(1) bridge pointers) and Eytzinger 1-indexed BFS layout.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Fractional Cascading Invariant: Promoting every 2nd element of M_{i+1} into M_i ensures at most 1 unpromoted element lies between consecutive bridges, so each lower level requires at most 1 comparison!
📊 Live Magnitude & Pointer Stage — Fractional Cascading O(log N + k) Bridges & Eytzinger Cache-Line Search Active Pivot Match/Sorted
* = Pro L₁ Aug… [0] O(log N Binary… [1] O(1) Br L₂: [1… [2]
[0]L₁ Augmented: [10, *18, 25, *42, 60]* = Promoted from L₂
[1]Binary Search L₁(x=22) ⇒ 25 (bridge→idx 2)O(log N) once at L₁
[2]L₂: [12, 18, 30, 42] (Check idx 1..2 in O(1)!)O(1) Bridge Hop!
Naive k Catalogs
O(k log N)
Fractional Cascading
O(log N + k)
Space Overhead
∑ N_i · (1 + 1/2 + 1/4 + ...) ≤ 2N
[Step 1] Binary searched L₁ once for x=22, then followed O(1) bridge pointer into L₂ and checked 1 neighbor.
Catalog Search Method Total Space Query Time ($k$ Lists of Size $n$) application in Range Trees
$k$ Independent Binary Searches $\Theta(kn)$ $\Theta(k \log n)$ 2D Orthogonal Range Query in $O(\log^2 N + K)$
Full Cross-List Unification $\Theta(k^2 n)$ (Quadratic!) $\Theta(\log(kn) + k)$ Impractical memory blowup
Fractional Cascading ($1/2$-Sample) $\le 2kn = \Theta(kn)$ $\mathbf{\Theta(\log n + k)}$ Upgrades 2D Range Tree to $\mathbf{O(\log N + K)}$!
Graph Fractional Cascading (Degree $d$) $\Theta(kn)$ (Sample $1/(2d)$) $\Theta(\log n + k \log d)$ Used in Segment Trees ($d=2 \implies O(\log N)$)
Fractional Cascading Invariant: Every 2nd Element of M_{i+1} Is Promoted to M_i (Gap ≤ 1) Augmented M_i: 10 (from L_i) 18 (promoted) 25 (from L_i) 34 (p: ≥ q=27) next_cascade_idx = c Augmented M_{i+1}: idx 0: 18 idx 1: 22 c-1: 29 (≥ 27!) idx c: 34 Query q = 27 lands at 34 in M_i → Bridge jumps to 34 (idx c) in M_{i+1} → Check c-1 (29 ≥ 27) in O(1)!
flowchart LR A["Query q"] --> B["Binary Search in Top Augmented List M_0 O(log N) Time → Index p_0"] B --> C["Emit L_0[M_0[p_0].real_idx] Follow c = M_0[p_0].next_cascade_idx"] C --> D["In M_1: If c > 0 and M_1[c-1].val >= q, --c O(1) Time (Single Comparison!)"] D --> E["Repeat across M_2 ... M_{k-1} Total Time: O(log N + k)"]
Hardware Reality: Cache Misses per List vs. Computational Geometry Range Trees

In a 2D Orthogonal Range Tree (answering "count or report all points in $[x_1, x_2] \times [y_1, y_2]$"), a standard tree builds a sorted $Y$-array at every internal node of the primary $X$-tree. A query visits $O(\log N)$ canonical $X$-nodes and runs binary search on each node's $Y$-array in $O(\log^2 N)$ time. By applying Fractional Cascading from each parent $Y$-array to its left and right child $Y$-arrays, we run binary search only once at the root $Y$-array ($O(\log N)$) and follow $O(1)$ bridge pointers down the $O(\log N)$ tree levels—shaving a full $\log N$ factor off both asymptotic complexity ($O(\log N + K)$) and L3 cache misses!

Production C++20 Implementation: Complete FractionalCascadingCatalog

#include <algorithm>
#include <concepts>
#include <cstddef>
#include <limits>
#include <vector>

template <std::totally_ordered T>
class FractionalCascadingCatalog {
public:
    struct Node {
        T val;
        std::size_t real_idx;         // Index of lower_bound(val) in original list L_i
        std::size_t next_cascade_idx; // Index of lower_bound(val) in next augmented list M_{i+1}
    };

    explicit FractionalCascadingCatalog(const std::vector<std::vector<T>>& lists, T sentinel_max)
        : k_(lists.size()), sentinel_(sentinel_max), orig_(lists), aug_(k_) {
        if (k_ == 0) return;

        // Build bottom-up from level k - 1 down to 0
        for (std::ptrdiff_t lvl = static_cast<std::ptrdiff_t>(k_) - 1; lvl >= 0; --lvl) {
            const auto& L = orig_[lvl];
            std::vector<T> merged;
            if (static_cast<std::size_t>(lvl) == k_ - 1) {
                merged = L;
            } else {
                // Extract every 2nd element (indices 1, 3, 5, ...) of M_{lvl+1}
                std::vector<T> promoted;
                const auto& M_next = aug_[lvl + 1];
                for (std::size_t j = 1; j < M_next.size(); j += 2) {
                    if (M_next[j].val < sentinel_) promoted.push_back(M_next[j].val);
                }
                merged.resize(L.size() + promoted.size());
                std::merge(L.begin(), L.end(), promoted.begin(), promoted.end(), merged.begin());
                merged.erase(std::unique(merged.begin(), merged.end()), merged.end());
            }
            merged.push_back(sentinel_); // +infinity sentinel at end

            // Populate real_idx (into L) and next_cascade_idx (into M_{lvl+1}) via two-pointer sweep
            auto& M = aug_[lvl];
            M.resize(merged.size());
            std::size_t r_ptr = 0, c_ptr = 0;
            for (std::size_t idx = 0; idx < merged.size(); ++idx) {
                const T v = merged[idx];
                while (r_ptr < L.size() && L[r_ptr] < v) ++r_ptr;
                if (static_cast<std::size_t>(lvl) + 1 < k_) {
                    const auto& M_next = aug_[lvl + 1];
                    while (c_ptr + 1 < M_next.size() && M_next[c_ptr].val < v) ++c_ptr;
                }
                M[idx] = Node{v, r_ptr, c_ptr};
            }
        }
    }

    // Returns the lower_bound index in each of the k original lists in O(log N + k) time!
    std::vector<std::size_t> query_all_lower_bounds(const T& q) const {
        if (k_ == 0) return {};
        std::vector<std::size_t> results(k_);

        // 1. Single O(log N) binary search in top augmented list M_0
        const auto& M0 = aug_[0];
        auto it = std::lower_bound(M0.begin(), M0.end(), q,
                                   [](const Node& nd, const T& val) { return nd.val < val; });
        std::size_t cur = static_cast<std::size_t>(it - M0.begin());
        results[0] = M0[cur].real_idx;

        // 2. O(1) cascade step for each subsequent list 1 .. k - 1
        for (std::size_t lvl = 0; lvl + 1 < k_; ++lvl) {
            std::size_t next_pos = aug_[lvl][cur].next_cascade_idx;
            const auto& M_next = aug_[lvl + 1];
            // Because every 2nd element of M_{lvl+1} was promoted to M_lvl,
            // the exact lower_bound in M_{lvl+1} is either next_pos - 1 or next_pos!
            if (next_pos > 0 && !(M_next[next_pos - 1].val < q)) {
                --next_pos;
            }
            cur = next_pos;
            results[lvl + 1] = M_next[cur].real_idx;
        }
        return results;
    }

private:
    std::size_t k_;
    T sentinel_;
    std::vector<std::vector<T>> orig_;
    std::vector<std::vector<Node>> aug_;
};
Promoting From $M_{i+1}$ vs. Promoting Only From $L_{i+1}$

A common implementation mistake is merging $L_i$ with every second element of the original list $L_{i+1}$ instead of the already-augmented list $M_{i+1}$. If you only sample $L_{i+1}$, a dense cluster of elements in $L_{i+2}$ can create an arbitrarily large gap of promoted elements in $M_{i+1}$ that never reaches $M_i$, breaking the $O(1)$ one-step-back invariant! Always sample every second element of $M_{i+1}$ (built bottom-up from $k-1$ down to $0$).

Check yourself Question 1 of 4
Concept Check Asymptotic Speedup of Fractional Cascading recall

You have a catalog of k sorted lists L_0, L_1, ..., L_{k-1}, each of length n (total N = k * n elements), and need to find the lower_bound of a query value q in all k lists.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

How does Fractional Cascading accelerate 2D Orthogonal Range Reporting from O(log^2 N + K) to O(log N + K), and why does a Promotion Fraction of 1/2 not even need bottom-up sampling here?

Key Takeaways

Fractional Cascading builds augmented lists M_i bottom-up by merging each original list L_i with every 2nd element of M_{i+1}.
Because the promotion fraction is 1/2, the geometric series 1 + 1/2 + 1/4 + ... = 2 bounds the total size of all augmented lists to at most 2x the original input size.
Every pair of consecutive promoted elements in M_i sandwiches at most 1 unpromoted element in M_{i+1}, so following a bridge pointer from M_i to M_{i+1} requires at most 1 local comparison (next_pos - 1 vs next_pos).
Total query time across k lists drops from O(k log n) to O(log n + k), reducing 2D Orthogonal Range Tree queries from O(log^2 N + K) to O(log N + K).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue