Cairn
algorithms · searching · O(n log(range)) — n items per feasibility check

back to Searching

Binary Search on Answer

Seven of this site's other ten Searching entries answer the same question — does this exact value exist in the array — differing only in what's assumed about the data. Two more, Ternary Search and Quickselect, each answer a different question about an actual array: where a unimodal sequence peaks, and which rank an element holds. One more, Saddleback Search, answers the same value-exists question as the first seven, just on a matrix sorted along both axes instead of an array. This entry doesn't search an array at all either. Given a yes/no question that only gets easier to satisfy as some numeric parameter grows — a monotonic feasibility check — it binary-searches the space of possible answers themselves, the exact same halving trick aimed at a boundary between "no" and "yes" instead of a location inside a data structure.

The worked example below is the classic ship packages within D days problem: a conveyor belt loads packages onto a ship in the given order, one day's worth at a time, and a day's load can't exceed the ship's capacity. Given a fixed number of days D, find the smallest capacity that still gets every package shipped within D days. There's no array of candidate capacities sitting in memory to search — capacity can be any integer from the heaviest single package (the hard floor; anything smaller can never load that package at all) up to the sum of every package's weight (the trivial ceiling; that capacity ships everything in one day). But "can this capacity finish in D days or fewer" only gets easier to satisfy as capacity grows — never harder — so that entire range of integers is binary-searchable exactly like a sorted array would be.

Try it

Each cell is one candidate capacity, not a package — the full row spans every integer from the heaviest single package up to the sum of all of them. Step through and watch the range close in on the smallest capacity that still ships everything within the given number of days.

Press Load, then Step through the search.

Why it works

The key fact isn't about the packages — it's about the shape of feasible(capacity) itself: if some capacity c can already ship everything within D days, every larger capacity can too (more room per day never makes a load harder to fit, and a load that already fit inside fewer days still fits inside D). That means the boolean sequence "is this capacity feasible," read in order from the smallest candidate to the largest, looks exactly like false, false, …, false, true, true, …, true — never a stray true before the switchover, never a stray false after it. That's the same invariant plain Binary Search relies on for a sorted array, just phrased over a feasibility check instead of an ordering:

if feasible(mid):
    # mid works — the answer is mid or something smaller. Keep mid, discard everything above it.
    hi = mid
else:
    # mid doesn't work, and nothing smaller can either (feasibility only improves upward).
    lo = mid + 1

Each step still halves the range exactly like array-based binary search, and the loop still ends with lo === hi pointing at the single boundary value — it's just that "the array" here is the implicit list of integers from the floor to the ceiling, and the single comparison per step (arr[mid] === target) is replaced by a whole function call (feasible(mid)) that can do arbitrary work to answer one yes/no question.

Reference implementation

function minCapacity(weights, days) {
  function daysNeeded(cap) {
    let d = 1, load = 0;
    for (const w of weights) {
      if (load + w > cap) { d++; load = w; }
      else load += w;
    }
    return d;
  }
  let lo = Math.max(...weights);           // floor: the heaviest single package
  let hi = weights.reduce((a, b) => a + b, 0); // ceiling: everything in one day
  while (lo < hi) {
    const mid = lo + Math.floor((hi - lo) / 2);
    if (daysNeeded(mid) <= days) hi = mid;
    else lo = mid + 1;
  }
  return lo;
}

Pitfalls

The predicate has to be monotonic in exactly the direction the search assumes — checking the wrong condition breaks that silently, with no error and no crash. Suppose the question were "does this capacity ship everything in exactly D days" instead of "D days or fewer." That sounds like a reasonable, even more precise thing to ask — but it isn't monotonic the way the search needs. On the ten-package, five-day example above, the true days-needed figure falls from 7 at capacity 10 down to 1 at capacity 55, hitting exactly 5 only for capacities 15 and 16 — a narrow island in the middle of the range, with "not exactly 5" on both sides of it, not a clean false-then-true split. Running the exact same lo/hi-narrowing loop above with that predicate substituted in doesn't error or loop forever — it just converges confidently on capacity 55 (which actually finishes in 1 day, nowhere near exactly 5) instead of the real answer, 15. Every probed midpoint along the way happens to fall on the "too many days" side of the island, so the loop keeps raising lo past the island entirely and lands on the ceiling, having never once evaluated a capacity where the exact-match predicate was even true. Checked by running both predicates against the identical loop and comparing to a brute-force scan of every capacity from 10 to 55: the "≤ D" predicate matches the brute-force answer every time, the "== D" predicate returns a capacity that doesn't remotely satisfy the question it was supposedly answering.

The midpoint formula and the branch direction have to agree, or the loop never terminates. The reference implementation above always rounds mid down (lo + Math.floor((hi - lo) / 2)) and, on success, narrows to hi = mid (keeping mid itself as a still-live candidate). That combination is required: rounding mid up instead (Math.ceil((lo + hi) / 2)) while still using hi = mid on success looks like a harmless style choice, but once the range narrows to lo = 14, hi = 15, the ceiling formula computes mid = 15 every single time — feasible, so hi = mid sets hi to the value it already was. Neither pointer ever moves again. Run it with a 100-iteration safety cap on the exact same ten-package, five-day example and it hits the cap still stuck at lo = 14, hi = 15, having made zero progress across the final 96 of those iterations — a genuine infinite loop, not a slow one, and nothing about the output looks like an error while it's happening. Rounding down with hi = mid or rounding up with lo = mid both work; mixing a round-up formula with the hi = mid branch is the specific combination that fails.

Complexity

The range from floor to ceiling shrinks by half every step, so the loop runs O(log(range)) times — on the ten-package example, a range of 46 candidate capacities takes at most 6 iterations, matching the demo above exactly. Each iteration calls feasible once, and here that costs O(n) (one pass over the packages to simulate a day's worth of loading), for a total of O(n log(range)) — dramatically cheaper than checking every candidate capacity one at a time from the floor upward, which would cost O(n · range) in the worst case. The technique itself doesn't care what feasible checks; the packages-and-days example is one concrete instance of a much more general shape — any question of the form "what's the smallest (or largest) parameter value for which this yes/no check passes," as long as that check is verified monotonic first. Skipping that verification is exactly what the first pitfall above is about. See Choosing a Search Algorithm for how this fits alongside the site's other ten Searching entries.