DSA DSA In Depth
0%
lesson exponential-searchternary-searchunimodal

Exponential (Galloping) Search, Rotated Arrays & Ternary Search on Unimodal Functions

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
Ternary Search on Unimodal Functions & Convex Optimization
CodeDigger Ternary Search on Unimodal Functions & Convex Optimization Watch on YouTube ↗

Covers peak-finding invariants on bitonic/unimodal sequences, rotated sorted array midpoint classification, and unbounded search.

1. Exponential (Galloping / Bentley-Yao) Search in $O(\log i)$ Time

Suppose you want to search for a target $x$ in a sorted array of unknown (or enormous) size $N$, where $x$ is known to sit near the beginning (at index $i \ll N$)—such as advancing a posting-list cursor in an inverted search index (Lucene/ripgrep) or TimSort's galloping merge. Standard binary search across $[0, N)$ always pays $\lceil \log_2 N \rceil$ comparisons. Exponential Search (Bentley & Yao, 1976) finds $x$ in $\mathbf{2\lfloor \log_2 i \rfloor + 2 = O(\log i)}$ comparisons:

  1. Doubling Phase: Probe indices $b = 1, 2, 4, 8, \dots, 2^k$ until $A[2^k] \ge x$ (or $2^k \ge N$). Since $2^{k-1} \le i \le 2^k$, this takes $\lfloor \log_2 i \rfloor + 1$ comparisons.
  2. Binary Search Phase: Run standard binary search inside the bracketed half-open window $(2^{k-1}, \min(2^k, N)]$, which has length $\le 2^{k-1} \le i$ and takes at most $\lfloor \log_2 i \rfloor + 1$ comparisons!

2. Searching Cyclically Rotated Sorted Arrays

When a sorted array of distinct keys $A$ is cyclically rotated (e.g., $[40, 50, 60, 10, 20, 30]$), it splits into an Upper Sorted Prefix (all elements $\ge A[0]$) followed by a Lower Sorted Suffix (all elements $< A[0]$, or $\le A[n-1]$). We can either:

  • Find the Minimum Pivot Index in $\log_2 n$ Steps: Apply monotonic binary search with predicate $P(i) := (A[i] \le A[n-1])$, which evaluates to $[F, F, \dots, F, T, T, \dots, T]$ and returns the exact rotation minimum index!
  • Single-Pass Direct Rotated Search: At any midpoint $mid$, at least one of the two halves $[lo, mid]$ or $[mid, hi]$ is guaranteed to be strictly sorted! If $A[lo] \le A[mid]$, the left half is sorted (so check if $target \in [A[lo], A[mid])$); otherwise the right half $[mid, hi]$ is sorted (so check if $target \in (A[mid], A[hi]]$).
3. Unimodal Optimization: Discrete Slope Binary Search vs. Continuous Ternary Search

A function $f(x)$ on $[L, R]$ is strictly unimodal (peak-shaped) if it strictly increases on $[L, x^*]$ and strictly decreases on $[x^*, R]$ (or symmetrically valley-shaped / convex):

  • Discrete Unimodal / Bitonic Array $A[0 \dots n-1]$: Do not use Ternary Search ($2$ probes per step)! Instead, compare adjacent elements $A[mid]$ and $A[mid + 1]$ in a standard binary search:
    • If $A[mid] < A[mid + 1]$, we are on the rising left slope ($mid < i^*$), so lo = mid + 1.
    • If $A[mid] > A[mid + 1]$, we are on the falling right slope ($mid \ge i^*$), so hi = mid.
    This locates the peak in $1 \cdot \log_2 n$ array accesses instead of Ternary Search's $2 \log_{3/2} n \approx 3.42 \log_2 n$!
  • Continuous Unimodal Function $f(x)$: On a continuous interval $[lo, hi]$, we probe $m_1 = lo + (hi - lo)/3$ and $m_2 = hi - (hi - lo)/3$. If $f(m_1) < f(m_2)$, the maximum cannot lie in $[lo, m_1)$, so set $lo = m_1$; otherwise set $hi = m_2$, shrinking the interval to $\frac{2}{3}$ of its length per step (or use Golden Section Search with ratio $\varphi - 1 \approx 0.618$ to reuse one of the two interior function evaluations from the previous iteration!).
Half-Open [lo, hi) Monotonic Predicate Decision Boundary Search
O(log N) · Zero Off-By-One

Evaluate predicate P(x) over a monotonic boolean space [F, F, ..., F, T, T, ..., T]. Maintain the loop invariant P(lo−1) == False and P(hi) == True until lo == hi.

Step 1 / 11
Custom Input:
Timeline

Invariant & State

Initial Sorted Monotonic Domain: [0, 8) | Threshold P(x): x ≥ 47 (Boundary index = 5)
📊 Live Magnitude & Pointer Stage — Half-Open [lo, hi) Monotonic Predicate Decision Boundary Search Active Pivot Match/Sorted
P=False 10 [0] P=False 18 [1] P=False 24 [2] P=False 31 [3] P=False 39 [4] P=True 47 [5] P=True 55 [6] P=True 68 [7]
[0]10P=False
[1]18P=False
[2]24P=False
[3]31P=False
[4]39P=False
[5]47P=True
[6]55P=True
[7]68P=True
Interval [lo, hi)
[0, 8)
Predicate P(x)
x ≥ 47
Monotonic Split
F×5 then T×3
Max Probes ⌈log₂N⌉
4
[Step 1] Initialized half-open interval [lo=0, hi=8) over sorted array [10, 18, 24, 31, 39, 47, 55, 68] for predicate P(x): x ≥ 47.
Search Problem Probes per Iteration Interval Shrink Factor Total Evaluations
Exponential (Galloping) Search $1$ (doubling + binary) $2\times$ expand, $1/2$ shrink $2\lfloor \log_2 i \rfloor + 2 = O(\log i)$
Rotated Array (Distinct Keys) $1$ $1/2$ $\lceil \log_2 n \rceil$
Rotated Array (With Duplicates) $1$ $1/2$ avg, $n-1$ worst ($A[lo]==A[mid]==A[hi]$) $\Theta(\log n)$ avg, $\Theta(n)$ worst case
Discrete Bitonic Peak (Slope Binary) $2$ adjacent ($A[m], A[m+1]$) $1/2$ $2 \log_2 n$ array reads
Continuous Ternary vs. Golden Section $2$ (Ternary) / $1$ reused (Golden) $2/3 \approx 0.667$ vs. $0.618$ $O(\log(1/\varepsilon))$
Left: Rotated Sorted Array Partition (P(i): A[i] ≤ A[n-1]) | Right: Unimodal Peak Search Upper Sorted (> A[n-1]) Lower Sorted (≤ A[n-1]) Pivot Min m₁ m₂ f(m₁) < f(m₂) ⇒ Discard [lo, m₁)
flowchart TD A["Rotated Array Search: mid = (lo + hi)/2"] --> B{"A[lo] == A[mid] == A[hi]?"} B -- "Yes (Ambiguous Duplicates)" --> C["++lo; --hi; (O(N) worst case)"] B -- "No" --> D{"Is Left Half Sorted? (A[lo] <= A[mid])"} D -- "Yes" --> E{"A[lo] <= target < A[mid]?"} E -- "Yes" --> F["hi = mid - 1"] E -- "No" --> G["lo = mid + 1"] D -- "No (Right Half [mid..hi] is Sorted)" --> H{"A[mid] < target <= A[hi]?"} H -- "Yes" --> G H -- "No" --> F
Hardware Reality: Why Exponential Search Beats Binary Search on Inverted Indexes

In full-text search engines (Lucene, Tantivy) and columnar databases (ClickHouse, DuckDB), intersecting two sorted posting lists $L_1$ and $L_2$ requires advancing the cursor in $L_2$ to the first document ID $\ge d_1$. Because successive jumps $i = \text{next\_pos} - \text{cur\_pos}$ are usually small ($i \in [1, 64]$), Exponential Search probes $cur + 1, cur + 3, cur + 7, \dots$—staying completely inside the current 64-byte L1d cache line! By contrast, a full binary search over $L_2[cur \dots N]$ immediately jumps to the midpoint $(cur + N)/2$ megabytes away in DRAM, suffering an guaranteed L3/DRAM cache miss on the very first comparison!

Production C++20 Implementation: Exponential Search, Rotated Search & Golden-Section Unimodal Optimizer

#include <algorithm>
#include <cmath>
#include <concepts>
#include <cstddef>
#include <numbers>

// 1. Exponential (Galloping) Search from cursor hint: O(log(result - hint)) time & cache-local!
template <std::totally_ordered T>
constexpr std::size_t exponential_lower_bound(const T* a, std::size_t n, const T& target, std::size_t hint = 0) noexcept {
    if (hint >= n || !(a[hint] < target)) {
        // Fallback if target <= a[hint]
        return static_cast<std::size_t>(std::lower_bound(a, a + std::min(hint + 1, n), target) - a);
    }
    std::size_t step = 1;
    std::size_t lo = hint;
    std::size_t hi = hint + 1;
    while (hi < n && a[hi] < target) {
        lo = hi;
        step <<= 1;
        hi = (n - hi > step) ? (hi + step) : n;
    }
    return static_cast<std::size_t>(std::lower_bound(a + lo + 1, a + hi, target) - a);
}

// 2. Discrete Unimodal Peak Finder in log2(n) iterations (1 slope comparison per step)
template <std::totally_ordered T>
constexpr std::size_t find_bitonic_peak(const T* a, std::size_t n) noexcept {
    std::size_t lo = 0, hi = n - 1;
    while (lo < hi) {
        const std::size_t mid = lo + (hi - lo) / 2;
        if (a[mid] < a[mid + 1]) {
            lo = mid + 1; // Rising slope: peak is strictly to the right
        } else {
            hi = mid;     // Falling slope: mid could be the peak
        }
    }
    return lo;
}

// 3. Continuous Golden-Section Search: maximizes unimodal f(x) on [lo, hi] reusing 1 evaluation/step!
template <typename Func>
double golden_section_maximize(Func f, double lo, double hi, int iterations = 70) {
    constexpr double inv_phi  = 1.0 / std::numbers::phi;        // (sqrt(5) - 1) / 2 ≈ 0.618034
    constexpr double inv_phi2 = 1.0 - inv_phi;                  // (3 - sqrt(5)) / 2 ≈ 0.381966

    double m1 = lo + inv_phi2 * (hi - lo);
    double m2 = lo + inv_phi  * (hi - lo);
    double f1 = f(m1);
    double f2 = f(m2);

    for (int it = 0; it < iterations; ++it) {
        if (f1 < f2) {
            lo = m1;
            m1 = m2; f1 = f2; // Reuse previous m2 evaluation!
            m2 = lo + inv_phi * (hi - lo);
            f2 = f(m2);       // Only ONE new f() call per step!
        } else {
            hi = m2;
            m2 = m1; f2 = f1; // Reuse previous m1 evaluation!
            m1 = lo + inv_phi2 * (hi - lo);
            f1 = f(m1);
        }
    }
    return 0.5 * (lo + hi);
}
Why Rotated Search With Duplicates Cannot Beat $\Omega(N)$ in the Worst Case

Suppose a rotated sorted array allows duplicate keys and the input is $[1, 1, 1, \dots, 1, 0, 1, 1]$ (a single $0$ hidden among $n - 1$ ones). At the initial step, $A[lo] = A[mid] = A[hi] = 1$, which gives zero information about whether the $0$ lies in the left half $[lo, mid-1]$ or the right half $[mid+1, hi]$! Any comparison-based algorithm must inspect all $n$ positions in the worst case to find the $0$, proving an information-theoretic $\Omega(n)$ worst-case lower bound when duplicates are present.

Check yourself Question 1 of 4
Concept Check Bitonic Peak Search Invariant recall

An array A[0..n-1] of distinct integers is strictly increasing up to an unknown peak index p and strictly decreasing from p to n - 1.

Key Takeaways

Exponential (Galloping) Search doubles a step offset 1, 2, 4, ..., 2^k before binary searching [2^{k-1}, 2^k], finding an element at distance i in 2 log_2(i) + O(1) comparisons with high L1 cache locality.
In any cyclically rotated sorted array of distinct elements, comparing A[mid] with A[lo] (or A[n-1]) immediately identifies which half is strictly sorted.
When duplicates are allowed in a rotated array, A[lo] == A[mid] == A[hi] forces an Ω(N) worst-case fallback (++lo; --hi).
On a discrete unimodal/bitonic array, binary searching the adjacent difference A[mid] < A[mid+1] finds the peak in log_2(N) steps; on continuous unimodal functions, Golden Section Search improves on Ternary Search by reusing one interior point per iteration.