Sequences & Strings/Binary Search on the Answer

Lesson 5.44,189 words

Binary Search on the Answer

Binary search locates the boundary of a monotone predicate p(x)p(x) in O(log(range))O(\log(\text{range})) probes; sorted arrays are only one instance. We first establish the half-open `while (lo < hi)` template for lower_bound\textsc{lower\_bound} and upper_bound\textsc{upper\_bound}, then generalize to "binary search on the answer": whenever feasibility is monotone in a numeric parameter, we binary search the parameter itself, calling a feasibility check at each step.

╌╌╌╌

We have seen binary search as the canonical divide-and-conquer search: a sorted array of keys, a target , and a halving loop that finds (or proves it absent) in comparisons. That framing is correct but narrow. What binary search actually needs is a monotone predicate: a boolean test that is false up to some boundary and true from there on. Sorted membership is just one instance, where . Once we see binary search as locating the boundary of a monotone predicate, we can search ranges that no array ever materialises, the technique known as binary search on the answer.

Recap: binary search on a sorted array

Fix a sorted array and a target . The loop maintains an interval guaranteed to contain if it is present at all.

Because the interval length drops geometrically, the loop runs times. The plain does occur? version is easy; the subtle and far more useful versions are the boundary queries.

lower_bound and upper_bound

Two queries answer almost every practical question about a sorted array:

  • is the first index with .
  • is the first index with .

Their difference is the count of elements equal to ; itself is the insertion point that keeps sorted. Both are boundary searches over the monotone predicate (respectively ): a sorted array makes go false, …, false, true, …, true exactly once.

The reliable template is half-open and uses lo < hi, never lo <= hi. We search for the smallest index in at which the predicate holds, treating index as a virtual past the end sentinel that is always feasible.

Algorithm:lower_bound(A,x)\textsc{lower\_bound}(A, x) — first index ii with A[i]xA[i] \ge x
  1. 1
    lo0lo \gets 0
  2. 2
    hinhi \gets n
    half-open: hi is past-the-end
  3. 3
    while lo<hilo < hi do
  4. 4
    midlo+(hilo)/2mid \gets lo + \lfloor (hi - lo)/2 \rfloor
    floor, and overflow-safe
  5. 5
    if A[mid]xA[mid] \ge x then
  6. 6
    himidhi \gets mid
    mid may be the answer
  7. 7
    else
  8. 8
    lomid+1lo \gets mid + 1
    mid infeasible
  9. 9
    return lolo
    lo=hilo = hi: boundary
lower_bound.pypython
from typing import Sequence, TypeVar

from comparable import Comparable

Key = TypeVar("Key", bound=Comparable)

def lower_bound(sorted_values: Sequence[Key], target: Key) -> int:
  """
    The first index `i` with `sorted_values[i] >= target`.\n
    This is the insertion point that keeps the array sorted; it equals\n
    len(sorted_values) when every element is strictly less than target.\n
  """
  low: int = 0
  high: int = len(sorted_values)  # half-open: high is past-the-end.

  # narrow to the first index whose value reaches target.
  while low < high:
    middle: int = low + (high - low) // 2
    if sorted_values[middle] >= target:
      high = middle  # middle may itself be the boundary.
    else:
      low = middle + 1  # middle is known infeasible; exclude it.

  return low

def upper_bound(sorted_values: Sequence[Key], target: Key) -> int:
  """
    The first index `i` with `sorted_values[i] > target`.\n
    Differs from lower_bound only in the strictness of the comparison;\n
    `upper_bound - lower_bound` counts the elements equal to target.\n
  """
  low: int = 0
  high: int = len(sorted_values)

  # narrow to the first index whose value strictly exceeds target.
  while low < high:
    middle: int = low + (high - low) // 2
    if sorted_values[middle] > target:
      high = middle
    else:
      low = middle + 1

  return low

def count_equal(sorted_values: Sequence[Key], target: Key) -> int:
  """
    How many entries equal `target`, as the width of the equal run.\n
  """
  return upper_bound(sorted_values, target) - lower_bound(sorted_values, target)

def contains(sorted_values: Sequence[Key], target: Key) -> bool:
  """
    Whether `target` occurs in the sorted array.\n
  """
  index: int = lower_bound(sorted_values, target)
  return index < len(sorted_values) and sorted_values[index] == target
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

Two details make this correct, and getting either wrong is the classic off-by-one bug:

  • The interval is half-open, as a search space but inclusive as an answer. We initialize , not , because the answer can be no element is , i.e. index . The loop's exit then names a valid boundary in .
  • The mid uses and the two branches are asymmetric. When holds we set (not ), because is itself a candidate boundary. When fails we set , because is now known-infeasible and must be excluded. With a floored mid, always, so strictly shrinks the interval and the loop cannot spin forever. For , change the test to ; nothing else moves.

One caution on the mid expression. Written as in a 32-bit integer type, the sum overflows as soon as exceeds : with (legal indices into a large byte array) the sum wraps negative and the midpoint lands outside the interval entirely. The form computes the same value, but the intermediate never exceeds the interval width, so it cannot overflow while holds. This bug sat in production binary searches for decades because it only fires on arrays longer than a billion elements.

The template, traced

Run on

The first index with is , and the loop finds it in three probes:

iter.?update
1yes
2no
3no
exitreturn

Iteration 3 is the critical case: the interval holds one untested candidate plus the feasible frontier, floors to , and the false branch moves past it. In this two-element configuration a wrong update rule spins forever (the bug taxonomy below returns to it).

on . Each row is one iteration's interval with the probed ; the dashed cell is the past-the-end sentinel . Three probes pin the boundary at index

The same array answers the counting question. tests instead: it probes (no, ), then (no, ), then (yes, ), and returns . The number of s is , computed without ever scanning the run of equal keys: counting duplicates stays even when the run has length .

The generalization: searching a monotone predicate

Nothing in the loop above inspected the array except through . Abstract it away. Let be monotone:

The pattern in the definition is forced, not assumed. Monotonicity says the true-set is upward closed: if it contains it contains everything above . An upward-closed subset of is a suffix, so it is either empty, or exactly for . There is one transition or none, and the none case is why the template carries an always-feasible sentinel at : it guarantees the true-set is nonempty, and no real answer exists comes back encoded as the sentinel itself.

If is monotone and computable, we can find by binary search over the numeric range , with no array at all. This is binary search on the answer: we are searching the space of candidate answers, and the only thing that makes it work is that feasibility is monotone in the answer. Monotonicity is what makes the search sound and complete for the threshold: the single transition means the boundary we return is the true and no other transition can be mistaken for it.

A monotone predicate flips false→true exactly once; binary search finds that boundary, the smallest feasible answer

The cost is uniform across every application:

We pay probes, because each iteration replaces the interval width by at most , so after probes the width is at most , and the loop stops when it reaches . The logarithm is what makes the technique scale: a range of costs probes, and a range of costs . Sixty evaluations of a feasibility check settle a question over an answer space no machine could enumerate. Replacing the array access by an arbitrary monotone test is the entire idea.

binary_search_answer.pypython
from typing import Callable

def first_true(low: int, high: int, predicate: Callable[[int], bool]) -> int:
  """
    The smallest integer `x` in [low, high] with `predicate(x)` true,\n
    for a predicate whose truth pattern is F..F T..T (monotone increasing).\n
    `high` is treated as an always-feasible sentinel: it is returned when no\n
    smaller value satisfies the predicate. Requires low <= high.\n
  """
  # shrink toward the first feasible value; floored mid keeps it moving.
  while low < high:
    middle: int = low + (high - low) // 2
    if predicate(middle):
      high = middle  # feasible; middle may be the boundary.
    else:
      low = middle + 1  # infeasible; exclude middle.

  return low

def last_true(low: int, high: int, predicate: Callable[[int], bool]) -> int:
  """
    The largest integer `x` in [low, high] with `predicate(x)` true,\n
    for a predicate whose truth pattern is T..T F..F (monotone decreasing).\n
    `low` is treated as an always-feasible sentinel. Requires low <= high.\n
  """
  # shrink toward the last feasible value; ceiled mid never sticks at `low`.
  while low < high:
    middle: int = low + (high - low + 1) // 2
    if predicate(middle):
      low = middle  # feasible; middle may be the boundary.
    else:
      high = middle - 1  # infeasible; exclude middle.

  return low
Each probe tests and discards the infeasible half — probes

Worked examples

In each case the work is the same three steps: name the answer parameter and its range , name the monotone predicate , and write the feasibility check. The binary search loop never changes.

Koko eating bananas

Koko has piles and hours; at speed she clears hours on pile . Minimize such that she finishes within hours.

  • Answer parameter: the speed , an integer in .
  • Predicate: , can finish in hours.
  • Monotonicity: a larger never increases any term , so is monotone increasing in (false for tiny speeds, true once fast enough). Binary search the smallest feasible .

Each check is , the range is , so the cost is .

koko_eating_bananas.pypython
from math import ceil
from typing import Sequence

from binary_search_answer import first_true

def hours_needed(piles: Sequence[int], speed: int) -> int:
  """
    Total hours to clear every pile at the given eating `speed`.\n
  """
  return sum(ceil(pile / speed) for pile in piles)

def minimum_eating_speed(piles: Sequence[int], hours: int) -> int:
  """
    The least integer speed at which Koko finishes all `piles` within\n
    `hours`. Assumes hours >= len(piles) (otherwise no speed suffices).\n
    Returns 0 when there are no piles.\n
  """
  if not piles:
    return 0

  def can_finish(speed: int) -> bool:
    return hours_needed(piles, speed) <= hours

  fastest: int = max(piles)  # speed >= max pile always finishes in n hours.
  return first_true(1, fastest, can_finish)
Koko feasibility over speeds for , . The predicate flips once; binary search returns the boundary

The table above is what the predicate looks like; the search never builds it. Run the loop on this instance. The range is , and is a legitimate always-feasible sentinel: at speed every pile takes exactly one hour, so the total is . Four probes suffice (), and each one evaluates the actual sum of ceilings:

probe?update
1yes
2no
3yes
4yes
exitreturn

Probes 3 and 4 happen to compute the same total, hours at both and ; the check uses the total only through the comparison, and the search still needs probe 4 to learn that is feasible while is not. Out of eleven candidate speeds, only four were ever examined.

The search over speeds for , : four probes, numbered in order and labelled with the hours each check computed, narrow to the boundary . The shaded band is the feasible suffix

Capacity to ship / split array largest sum

These two problems are the same problem. Given an array and a count (days / parts), partition it into contiguous groups to minimize the maximum group sum. (Capacity to ship within days reads the array as package weights; Split array largest sum reads it as integers, with identical structure.)

  • Answer parameter: the cap on a group's sum, in . (You cannot go below the largest single element; you never need to exceed the whole sum.)
  • Predicate: the array can be split into contiguous groups, each with sum .
  • Feasibility check (greedy, ): sweep left to right, accumulating into the current group; whenever adding would exceed , close the group and start a new one at . The number of groups this greedy uses is the minimum possible for cap , so holds iff that count is .
Feasibility check for cap on with : the greedy sweep cuts whenever the next element would push a group past , using groups (sums , ). Since , holds

A larger only ever merges groups, so the group count is non-increasing in : is monotone, and we binary search the smallest feasible . Cost: .

split_array_largest_sum.pypython
from typing import Sequence

from binary_search_answer import first_true

def groups_needed(values: Sequence[int], cap: int) -> int:
  """
    The minimum number of contiguous groups whose sums each stay <= `cap`,\n
    found by greedily extending the current group until the next element\n
    would overflow it. Assumes every element is <= cap.\n
  """
  groups: int = 1
  current_sum: int = 0
  for value in values:
    if current_sum + value > cap:  # would overflow; close and start anew.
      groups += 1
      current_sum = value
    else:
      current_sum += value
  return groups

def split_array_largest_sum(values: Sequence[int], parts: int) -> int:
  """
    The smallest possible value of the largest group sum when `values` is\n
    split into at most `parts` contiguous groups. Returns 0 for an empty\n
    array. Assumes values are non-negative and 1 <= parts.\n
  """
  if not values:
    return 0

  def fits(cap: int) -> bool:
    return groups_needed(values, cap) <= parts

  smallest_cap: int = max(values)  # cannot go below the largest element.
  largest_cap: int = sum(values)  # one group never needs more than this.
  return first_true(smallest_cap, largest_cap, fits)

On with the range is , and the search runs the greedy sweep four times:

probegreedy groupscount?update
1yes
2no
3yes
4no
exitreturn

Probes 2 and 4 fail for the same structural reason: once , the packages and can no longer share a group, and the greedy is forced to three. The returned optimum is itself a sum of a contiguous run, necessarily: is a step function of whose value can only change at caps equal to some contiguous-run sum, so the smallest feasible cap always lands on one. The binary search does not exploit this; it simply cannot return anything else.

Integer square root

A pure-numeric instance with no array in sight: given , compute , the largest with .

  • Answer parameter: , in (or tighten to ).
  • Predicate: here the natural test is monotone the other way, namely true, …, true, false, …, so we want the last true. Search the first with via the standard template and subtract one; or flip the comparison and keep the largest feasible form.

The check is , so the integer square root costs , and the same shape computes any .1

integer_root.pypython
from binary_search_answer import last_true

def integer_sqrt(number: int) -> int:
  """
    The floor of the square root of `number` >= 0: the largest x with\n
    x*x <= number.\n
  """
  if number < 0:
    raise ValueError("integer_sqrt is undefined for negative input")
  if number < 2:
    return number  # 0 and 1 are their own floor-roots.

  # any x > number/2 + 1 already squares past number, so cap the search there.
  highest: int = number // 2 + 1
  return last_true(0, highest, lambda candidate: candidate * candidate <= number)

def integer_root(number: int, degree: int) -> int:
  """
    The floor of the `degree`-th root of `number` >= 0: the largest x with\n
    x ** degree <= number. Requires degree >= 1.\n
  """
  if number < 0:
    raise ValueError("integer_root is undefined for negative input")
  if degree < 1:
    raise ValueError("degree must be at least 1")
  if number < 2 or degree == 1:
    return number

  highest: int = number  # x ** degree grows fast, but N is always a safe cap.
  return last_true(0, highest, lambda candidate: candidate**degree <= number)
Reversed monotonicity: runs , so the answer is the LAST true, not the first. For the boundary sits at ()

Traced for with the flip: search the first with over (any with works as the sentinel; ). Probe : fails, . Probe : holds, . Probe : holds, . The loop exits at , and .

When monotonicity fails

The precondition is easy to violate with an innocent-looking change of predicate. Ask Koko's question with equality instead of inequality: , she finishes in exactly hours. On the hour totals for are , so reads

two transitions. Feed this to the smallest-feasible template: the first probe is , is false, and the template concludes the boundary lies to the right, setting . Both true cells are gone. Every later probe is false too, so the loop drifts up to the sentinel and returns , a speed that does not even satisfy . Nothing inside the loop misbehaved; the precondition was false, so the invariant everything below is infeasible broke at the very first update.

An equality predicate is not monotone: on Koko's instance is true only at , so it has two transitions. The first probe makes the template discard , losing both true cells; the search drifts to the sentinel , which is not a solution at all

The repair is standard: search a monotone relaxation and test afterwards. Here, find the smallest with (the original monotone predicate, giving ), then check whether happens to hold. More generally, before trusting any answer-space search, write the one-line monotonicity argument (increasing the parameter only relaxes the constraint, so a feasible answer stays feasible) and confirm the sentinel is feasible by construction. If either fails, the loop still terminates and still returns something; it just returns garbage.

Correctness and termination

Every variant rests on one invariant, stated here for the smallest feasible half-open template ( inclusive as an answer):

To see the loop bug fire, suppose we want the last true (the integer-square-root shape) and, improvising, keep the floored with the updates on true and on false. On the two-element interval , with true: , the true branch assigns , and the state is exactly what it was. The loop runs forever, and it does so only when the search has already narrowed to two candidates, which is why the bug survives casual testing: small hand-checked examples that happen to exit earlier look fine.

The correct last true mirror uses the ceiling midpoint:

Algorithm:largest xx with p(x)p(x) — the mirrored template needs a ceiling mid
  1. 1
    lo; hirlo \gets \ell;\ hi \gets r
    invariant: p(lo)p(lo) true, everything above hihi false
  2. 2
    while lo<hilo < hi do
  3. 3
    midlo+(hilo)/2mid \gets lo + \lceil (hi - lo)/2 \rceil
    ceiling: mid>lomid > lo always
  4. 4
    if p(mid)p(mid) then
  5. 5
    lomidlo \gets mid
    mid feasible, may be the answer
  6. 6
    else
  7. 7
    himid1hi \gets mid - 1
    mid known-infeasible
  8. 8
    return lolo

Now whenever , so both branches strictly shrink the interval; the roles of the floor and ceiling are symmetric to the roles of and . The rule of thumb: whichever side the update keeps ( or ), round away from that side. Floor pairs with ; ceiling pairs with . This form returns the largest feasible value directly, which is what the integer square root wanted before we flipped it into a first-false search.

Binary search on a real interval

When the answer is a real number rather than an integer (minimize a continuous radius, a rate, a time), the boundary need not be representable exactly, so we run parametric search: the same loop on a real interval, stopping after a fixed number of iterations or once .

Algorithm:real-valued binary search — first xx with p(x)p(x) within ε\varepsilon
  1. 1
    lo; hirlo \gets \ell;\ hi \gets r
  2. 2
    repeat KK times:
    K=100K=100 \Rightarrow error (r)2100\le(r-\ell)2^{-100}
  3. 3
    mid(lo+hi)/2mid \gets (lo + hi)/2
  4. 4
    if p(mid)p(mid) then himidhi \gets mid else lomidlo \gets mid
  5. 5
    return hihi

Each iteration halves the interval, so iterations reach absolute error ; solving for the iteration count that reaches a tolerance gives

The numbers stay small even for extravagant demands: an interval of width pushed down to needs iterations. There is no bookkeeping because we never need the exact integer boundary, only an -close one.

Preferring a fixed over the loop condition while (hi - lo > eps) matters for correctness. A double carries significand bits, so once the interval is a few units in the last place wide, rounds to or and the interval stops shrinking; if is below that granularity, the eps-condition never becomes false and the loop hangs. A fixed (around for doubles, comfortably past machine precision) is immune by construction. As always, the predicate's monotonicity is the real precondition: if is monotone over the loop converges to its boundary; if is not monotone, binary search is simply the wrong tool, since there may be several transitions and no guarantee which one we land on.3

Parametric search, bisection, and decision vs. optimization

Binary search on the answer is the discrete, hand-rolled special case of a broad optimization paradigm. When the feasibility check is itself a shortest-path or flow computation, the technique is parametric search (Megiddo, Applying Parallel Computation Algorithms in the Design of Serial Algorithms, JACM 1983), which replaces the numeric probe with a simulation of the check run on the unknown optimum, and is the classical route to problems like the minimum-ratio cycle and the -th smallest distance. The everyday version — pick a value, run a Boolean feasibility test, halve the range — is what this lesson does, and it is worth recognizing that the two are the same idea at different levels of sophistication.

The continuous analogue, bisection on a monotone real predicate, is a root-finding method: to solve for monotone , binary search the sign of . Bisection converges linearly (one bit per step), which is why numerical libraries pair it with faster-but-fragile methods — Brent's method (Brent, Algorithms for Minimization Without Derivatives, 1973) falls back to bisection whenever the superlinear step would leave the bracketing interval, so it keeps bisection's guaranteed convergence while usually running faster. The binary search on a real parameter until the interval is small enough pattern in this lesson is bisection with an explicit tolerance, and the same caution applies: it needs a genuine sign change (a genuine monotone predicate) bracketed at the endpoints, or it converges to nothing meaningful.

Finally, the monotone-predicate framing connects binary search to decision vs. optimization. Many optimization problems are solved by reducing them to a sequence of decision (is a solution of quality feasible?) problems and binary searching ; the reduction is efficient precisely when the decision version is polynomial and feasibility is monotone in . That is the same move that turns an NP optimization problem into its NP decision counterpart, and in the tractable case it is this lesson's technique verbatim.

Takeaways

  • Binary search locates the boundary of a monotone predicate in probes; the sorted array is just the special case .
  • Memorise one template. The half-open while (lo < hi) form with a floored mid, on feasible and on infeasible, returning , computes and without off-by-one errors.
  • Binary search on the answer: when feasibility is monotone in a numeric parameter, binary search the parameter and call a feasibility check at each step, for total cost .
  • Recipe for each problem: name the answer range , the monotone predicate , and an efficient check. Koko (speed; sum of ceilings), ship/split (max-group cap; greedy partition in ), integer square root () all fit this mold.
  • The correctness invariant keeps infeasible and feasible; floored mid plus asymmetric updates guarantee termination. Mixing the closed and half-open templates is where the classic bugs live, and the infinite-loop variants all fire on the two-element interval. Rounding rule: floor pairs with , ceiling with .
  • Use binary search on the answer when checking is easy but solving is hard: evaluating is cap enough? is a linear greedy sweep, while computing the optimal directly is not. The search converts a verifier into an optimizer at a factor.
  • Verify monotonicity before trusting the output. Equality-style predicates have two transitions and send the search to garbage without any visible failure; search the monotone relaxation ( instead of ) and test the boundary afterwards. Confirm the sentinel is feasible by construction.
  • For a real-valued answer, run parametric search: a fixed iteration count rather than an eps loop condition, which can hang at floating-point granularity. Monotonicity of is the only precondition that matters.

Footnotes

  1. CLRS, Ch. 2 — Binary search (Exercise 2.3-5): the sorted-array search and its boundary (insertion-point) variants.
  2. Skiena, §4.9 — Binary Search and Related Algorithms: searching a monotone predicate over a numeric range (binary search on the answer) and one-sided/parametric variants.
Practice

╌╌ END ╌╌