Cairn
data structures · every-other sampling, bridged catalogs · O(log n + k) for k catalogs vs O(k log n) independent

back to Array-Backed Trees

Fractional Cascading

Say you have k sorted catalogs — unrelated arrays, not levels of one tree — and a query value v, and you want lower_bound(v) (the smallest element ≥ v) in every catalog, not just one. The obvious approach is k independent binary searches, O(k log n) total. Fractional cascading gets the same answer in O(log n + k): binary search for real exactly once, in a single augmented list built on top of the first catalog, then follow one pointer per remaining catalog and check at most two neighboring entries — O(1) a level, not O(log n) a level. The trick is what goes into that augmented list: each catalog's own elements, plus every other element promoted up from the next catalog's augmented list, so a position found in one level's list is never more than a couple of slots from the right answer one level down.

Try it

Three catalogs: L0 (4 values), L1 (7 values), and L2 (8 values), all drawn from disjoint value ranges so every comparison below has one unambiguous answer. Enter a query value and press Load Query to build the step sequence: a real binary search in A0, the augmented top list, followed by a down-pointer jump plus an O(1) correction check into A1 and then A2. In the augmented row, solid cells are native to that level's own catalog; faded cells are promoted markers copied up from a deeper level, present only to steer the search — never themselves an answer for this level.

L0 (native catalog, 4 values)
L1 (native catalog, 7 values)
L2 (native catalog, 8 values)
augmented level — A0
Loaded L0=[10,40,70,100], L1=[5,20,35,55,80,95,110], L2=[15,30,45,60,75,90,105,120]. Enter a query value, Load Query, then Step through it.

Why it works

Building the augmented lists, bottom-up. A[k-1] is just L[k-1] itself — nothing to promote from below. For each shallower level i, take every other element of A[i+1] (positions 0, 2, 4, ...) and merge those promoted values into L[i]'s own native values, keeping the result sorted. Sampling from A[i+1] — the already-augmented list — rather than the raw L[i+1] is what lets the bridges compose across every level at once: A[i+1]'s own promoted entries already carry pointers further down, so a marker promoted into A[i] inherits that whole chain for free.

Down-pointers, computed with one backward pass. Every entry of A[i] gets a down index into A[i+1]: for a promoted entry it's exactly where that value sits in A[i+1]; for a native entry it's the nearest promoted entry's target at or after it, found by walking the merged list back-to-front and carrying the most recently seen promoted target along. A second table, nativeAtOrAfter, does the same backward carry for "nearest position that's actually native to L[i]" — needed because the position a query lands on inside A[i] is very often a promoted ghost, not a real member of L[i], and the value actually reported for this level has to be the nearest real one.

The O(1) correction. Binary search finds p = lower_bound(v) in A[0] for real, O(log n0). From there, q = A[0][p].down lands in A[1] — and the true lower_bound(v) in A[1] is always at position q or q - 1, never farther, so checking those two entries (in that order, smaller index first) always finds it. Verified two ways before writing any of this: exhaustively, all 5,796 ways to split 8 distinct values across 3 non-empty sorted catalogs, every query value from one below the minimum to one above the maximum — 63,756 checks, 0 mismatches — and 300,000 randomized checks (2-6 catalogs, up to 14 elements each, values up to 800) against independent per-catalog binary search as the reference — again 0 mismatches, with the down-pointer target itself already correct (no correction needed at all) in 95,287 of those 300,000 cases and needing exactly the one-step-back check in the rest. No case in either sweep ever needed to look further than that.

Worked example, v = 50. A0 is [5,10,20,40,45,70,75,95,100,110] (native: 10,40,70,100). Binary search lands on position 5, value 70 — L0's answer. Its down-pointer is 6, into A1 = [5,15,20,35,45,55,75,80,95,105,110] (native: 5,20,35,55,80,95,110); checking position 6 first (value 75, too big) then position 5 (value 55, ≥ 50) lands the correction on position 5 — L1's answer. That entry's down-pointer is 4, into A2 = [15,30,45,60,75,90,105,120] (fully native, no level below it); position 4 is 75 (too big), position 3 is 60 (≥ 50) — L2's answer. Total: 5 comparisons (4 to binary search A0's 10 entries, plus one hit on the very first correction check each level) versus 8 for three independent binary searches (⌈log₂4⌉ + ⌈log₂7⌉ + ⌈log₂8⌉ = 2 + 3 + 3) on the same three catalogs.

Reference implementation

class FractionalCascade {
  constructor(lists) {
    this.lists = lists;
    const k = lists.length;
    const A = new Array(k);
    A[k - 1] = lists[k - 1].map((v, idx) => ({ value: v, native: true, nativeIndex: idx, down: null }));
    for (let i = k - 2; i >= 0; i--) {
      const next = A[i + 1];
      const sample = [];
      for (let j = 0; j < next.length; j += 2) sample.push({ value: next[j].value, downIdx: j });
      const merged = [];
      let a = 0, b = 0;
      const native = lists[i];
      while (a < native.length || b < sample.length) {
        if (b >= sample.length || (a < native.length && native[a] < sample[b].value)) {
          merged.push({ value: native[a], native: true, nativeIndex: a, down: null });
          a++;
        } else {
          merged.push({ value: sample[b].value, native: false, nativeIndex: -1, down: sample[b].downIdx });
          b++;
        }
      }
      let nextDown = next.length;                 // sentinel: no promoted target at or after here
      for (let m = merged.length - 1; m >= 0; m--) {
        if (!merged[m].native) nextDown = merged[m].down;
        else merged[m].down = nextDown;
      }
      A[i] = merged;
    }
    this.A = A;
    // nativeAtOrAfter[i][p] = index into lists[i] of the nearest native entry at/after position p
    this.nativeAtOrAfter = A.map((level) => {
      const arr = new Array(level.length + 1);
      arr[level.length] = null;
      for (let j = level.length - 1; j >= 0; j--) {
        arr[j] = level[j].native ? level[j].nativeIndex : arr[j + 1];
      }
      return arr;
    });
  }

  static lowerBoundOf(level, v) {
    let lo = 0, hi = level.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (level[mid].value >= v) hi = mid; else lo = mid + 1;
    }
    return lo;
  }

  // results[i] = smallest value in lists[i] that is >= v, or null
  lowerBound(v) {
    const { A, nativeAtOrAfter, lists } = this;
    const k = A.length;
    const results = [];
    let p = FractionalCascade.lowerBoundOf(A[0], v);   // the only real binary search: O(log n0)
    for (let i = 0; i < k; i++) {
      const natIdx = nativeAtOrAfter[i][p];
      results.push(natIdx === null ? null : lists[i][natIdx]);
      if (i < k - 1) {
        const q = p === A[i].length ? A[i + 1].length : A[i][p].down;
        let best = A[i + 1].length;
        for (let x = Math.max(0, q - 1); x <= q && x < A[i + 1].length; x++) {
          if (A[i + 1][x].value >= v) { best = x; break; }
        }
        p = best;                                      // O(1): at most two positions checked
      }
    }
    return results;
  }
}

Re-verified this exact class against the same two harnesses (exhaustive and randomized) after writing it: 300,000 randomized checks, 0 mismatches, and the demo's own worked numbers above come directly from running it, not a hand-traced approximation.

Pitfalls

The correction must check backward from the down-pointer, never forward. It's tempting to check q and q + 1 instead of q - 1 and q — "the pointer already lands near the answer, so look from there onward." That version is wrong far more often than right: rerunning the same 300,000-case harness with the window flipped to [q, q + 1] failed 204,113 of 300,000 checks (68%). The reason is what down actually means: it points to the smallest promoted element ≥ v's source value, and a smaller value that's native only to A[i+1] — never sampled up into A[i] at all — can sit just before that promoted marker and still qualify as the true lower_bound(v). A concrete counter -example the harness found: five catalogs, query v = 228, expected [421, 245, 376, 255, 242]; the forward-window version returned [421, 400, 376, 359, 516] — three of five levels wrong, each one overshooting to a larger value that happened to be promoted, having skipped past a smaller native one sitting just behind it.

A position in A[i] is not automatically the answer for L[i]. An early version of the code above set nativeAtOrAfter[j] = j — the raw position within A[i] — instead of that entry's actual index within L[i]. It looked plausible: every lookup still returned some in-bounds value from the right catalog, no crash, no obviously wrong shape. It silently broke the moment a query landed on a promoted (non-native) position, which is most of the time once L[i] is smaller than the samples merged into it — 86,275 of 100,000 randomized checks failed against independent per-catalog binary search before the fix, 0 after switching to storing each native entry's real index into its own catalog and carrying that backward the same way down is carried.

Complexity

Time: building all k augmented lists costs O(n) total, where n is the combined size of every catalog — each level's merge is linear in its own size, and the geometric falloff of "every other element" promoted upward keeps |A[i]| within a constant factor of |L[i]| + |A[i+1]|, so the levels don't blow up in total size. A query costs O(log n0) for the one real binary search plus O(1) per remaining catalog, O(log n0 + k) total, against O(k log n) for k independent binary searches over catalogs of comparable size. Space: O(n) for the same reason the build is linear — every augmented list holds its own catalog plus a shrinking sample from the one below it.

The gap between the two costs is modest at the three-catalog, single-digit-to-teens scale this page's demo uses (5 comparisons versus 8 above) and widens with both k and catalog size, measured directly by instrumenting both approaches on randomized catalogs of growing size: 1.34× fewer comparisons at 3 catalogs of 8 elements each, 1.89× at 5 catalogs of 16, 2.46× at 8 catalogs of 32, and 2.96× at 10 catalogs of 64 (200 randomized queries averaged per size). This site's guide, Choosing a Range Query Structure, sets this entry aside the same way it already sets aside Binary Heap, Mo's Algorithm, Van Emde Boas Tree, and Wavelet Tree — a fifth "different question" case. The guide's six compared entries all maintain one running answer over a changing array. Fractional cascading instead speeds up the same query repeated across several unrelated, unchanging catalogs — closer in spirit to the layered search behind 2D range trees (where the "catalogs" are a segment tree's per-node associated lists) than to any single structure's own query.