Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Monotonic Structures & Search on the Answer  ›  Lesson

Binary Search on the Answer

Binary Search on the Answer 30 minWhen a yes/no test flips only once, search the answers instead of the data
You're building a piece ofQuarry — the query planner
This piece — min_capacity(): The smallest shard capacity that fits every table into the shard budget.
Scenario The cluster has room for six shards and four tables to hold. Quarry's capacity tuner finds the smallest shard size that makes everything fit, in a handful of probes instead of trying every size.
Your task
Build min_capacity(tables, budget). Each table has a row count, and at shard capacity c a table with r rows needs ceil(r / c) shards. Return the smallest whole-number c >= 1 whose total shard count fits in budget, or -1 if the budget is smaller than the number of tables. With no tables, return 1. Binary search the capacity. Example: min_capacity([30, 11, 23, 4], 6) → 15.

Binary Search on the Answer

In short: when "is this value good enough?" flips from no to yes exactly once as the value grows, you can binary search the answer itself, not just a sorted list. Pick a range you know contains the answer, write a yes/no feasibility check, prove in one sentence that it's monotone, then halve the range with the usual template. A range of a billion costs about 30 checks — the search is never the bottleneck, the check is.

Four probes on the range 1..30: 15 fits (keep 1..15), 8 fails (keep 9..15), 12 fails (keep 13..15), 14 fails, and lo meets hi at 15

Quarry splits every table into shards, and each shard holds at most c rows. A table with r rows therefore needs ⌈r/c⌉\lceil r / c \rceil shards, and the cluster has room for a fixed budget of shards across all tables. Big shards are slow to move and rebalance, so the capacity tuner wants the smallest c that still fits every table into the budget.

python

There's no sorted list here to search, and there's no formula that gives c directly, because the ceilings get in the way. But look at the output. As c grows, the shard count never goes up. So the question "does capacity c fit in 6 shards?" reads no, no, no, …, yes, yes, yes as c increases. It flips exactly once. Anything that flips exactly once can be binary searched.

You've done this once already

In the binary search lesson, grid_side found the biggest square grid of album covers, and first_true found the first day a track crossed a million plays. Neither searched a list. Both searched a range of possible answers with a yes/no test. This lesson turns that trick into a method you can apply on purpose. It has four questions, and you should answer them in a comment before writing the loop:

  1. What is the answer space? Pick lo and hi so that the answer is certainly inside. hi must be a value you know works. Here c = max(tables) puts every table in one shard, so if any capacity fits, that one does.
  2. What is the test? A function fits(c) that says yes or no. This is often called the feasibility check, and it's usually a simple greedy or counting loop.
  3. Is it monotone, and which way? No-then-yes means you want the first yes (a smallest capacity). Yes-then-no means you want the last yes (a largest gap, a biggest grid).
  4. What does one test cost? The search makes about log⁡2R\log_2 R tests over a range of size RR, so the total is O(log⁡R×test)O(\log R \times \text{test}).

Watching the answer space shrink

Here are the capacities from 1 to 30 as cells. Each probe tests one capacity, and because the test is monotone, a single answer rules out a whole side:

watch it move · array

The cells are candidate answers, not data. One probe settles a whole side of the range, because a capacity that fits means every bigger one fits too.

The answer is somewhere in 1..30, and 30 is sure to work because every table then fits in one shard. 30 cells. lo at index 0. hi at index 29.

1 / 8

Now run it on your own test. Change budget, or rewrite fits completely, and watch where the probes land:

your code, drawn · array

Your capacity tuner, probe by probe. The cells are candidate answers. Try budget = 4 or budget = 20 and see how the probes move.

Two templates, and the one bug that hangs

Almost every search on the answer is one of these two loops. Learn them as a pair:

python

The rounding is not a style choice. In last_true, rounding down with lo = 3, hi = 4 gives mid = 3, the test passes, lo = mid leaves lo at 3, and the loop never ends. Whichever side keeps mid, round mid away from it.

How many probes does a big range take? Fewer than you might think:

What does this print?
lo, hi, probes = 1, 10**9, 0
target = 123_456_789
while lo < hi:
    mid = (lo + hi) // 2
    probes += 1
    if mid >= target:
        hi = mid
    else:
        lo = mid + 1
print(lo == target, probes)

When the test is not monotone

Binary search trusts the test completely. After one "no" it never looks left of mid again. If the test can say yes, then no, then yes, the search can step straight over the real answer:

What does this print?
def first_true(lo, hi, ok):
    while lo < hi:
        mid = (lo + hi) // 2
        if ok(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo

works = {1: False, 2: True, 3: False, 4: False, 5: False, 6: True, 7: True, 8: True}
print(first_true(1, 8, lambda c: works[c]))

So before you binary search an answer, prove the test is monotone in one sentence. For shards: a bigger capacity never needs more shards for any table, so if c fits, c + 1 fits. For "place replicas at least d apart": if you can do it with gap d, the same placement works for any smaller gap. If you can't write that sentence, don't binary search.

Feasibility checks are usually greedy

Quarry places k replicas of a hot index on racks at the positions in racks, and it wants them as far apart as possible, to survive a local failure. Maximising the smallest gap directly is hard. Checking a proposed gap is easy: walk the racks in order and place a replica whenever you're at least d from the last one. Placing as early as possible never hurts, so if this greedy walk can't place k replicas, nothing can.

step through it
1racks = [1, 4, 9, 12, 15]
2k = 3
3d = 6
4placed = 1
5last = racks[0]
6for x in racks[1:]:
7 if x - last >= d:
8 placed += 1
9 last = x
10print(placed >= k)

The largest gap that passes is a last-true search over d from 0 up to racks[-1] - racks[0]. That's the drill exercise.

Recognising the pattern

Search on the answer rarely announces itself. These phrasings are the usual signals:

  • "the smallest X such that…" or "the largest X such that…", where X is a number: a capacity, a speed, a size, a time limit, a distance;
  • "minimise the maximum…" or "maximise the minimum…". The replica problem above is one, and so is the smallest daily listening cap from the binary search lesson, where the goal was to make the busiest day as light as possible;
  • a problem that is hard to optimise but easy to check. If you can't see how to compute the best c, but given a c you could quickly say whether it works, that's the signal.

The last one is the real test. Binary search on the answer turns an optimisation problem ("find the best") into a sequence of decision problems ("is this good enough?"), and decision problems are usually much easier to write correctly. You pay a factor of log⁡R\log R for the conversion, which is almost always a bargain.

❓ Cross-question — "Couldn't I just try every capacity from 1 upwards?" You could, and on the four small tables above it would take 15 checks instead of 4. But the answer space for a real cluster runs to millions of rows per shard. A linear scan pays one check per candidate, while binary search pays one check per halving. Over a range of a billion that's the difference between a billion checks and thirty.

The hi bound is where most bugs hide. It has to be a value that you know passes:

broken — fix it

With 4 tables and a budget of 4 shards, every table needs its own single shard, so the answer is 30. This prints 29, which needs 5 shards.

How much can one check cost?

The search makes about 30 probes over a range of a billion, so the budget for the whole search is really a budget for the check. Quarry's tuner has to answer within 200 ms, and 2³⁰ is just over a billion, so each of the 30 checks gets about 6 ms. A 20 ms check would take 600 ms in all. That's why feasibility checks are written as tight O(n) loops. An O(n log n) check or an O(n²) one multiplies straight into the total.

Answers that aren't whole numbers

The same halving works on real numbers, with one change: there's no "lo == hi" moment, so run a fixed number of rounds. Sixty halvings shrink any sensible range below floating-point precision. Say Quarry models its cache hit rate as size / (size + 40) and wants the smallest size giving 95% hits:

python

Interview problem: the k-th smallest latency gap

Sometimes the check is a count. "Is the answer at most d?" becomes "are there at least k things of size at most d?", and that flips once as d grows.

The k-th smallest gap. Quarry compares every pair of requests in a trace and looks at the difference in their latencies. Given latencies and k, return the k-th smallest of those differences, counting from 1. There are n·(n−1)/2 pairs, and listing them all is too slow.

Constraints: 2 ≤ n ≤ 10⁵, 0 ≤ latencies[i] ≤ 10⁶, 1 ≤ k ≤ n·(n−1)/2.

Examples:

  • kth_gap([12, 40, 19, 33], 2) → 7. The gaps sorted are 7, 7, 14, 21, 21, 28.
  • kth_gap([5, 5, 9], 1) → 0

Sort the latencies. Then "how many pairs have a gap of at most d?" is a single two-pointer sweep, and the smallest d for which that count reaches k is the answer, found with first_true. It costs O(n log n) for the sort plus O(n log R) for the search, where R is the largest gap. That's the boss exercise.

What it costs

step cost
probes over a range of size R about log⁡2R\log_2 R
total O(log⁡R×check)O(\log R \times \text{check})
real-valued answers a fixed number of rounds, about 60
a check that isn't monotone a wrong answer, without any error

Idioms & real-world patterns

  • Write the monotonicity sentence first, in a comment, before any code.
  • Pick hi so it is certainly feasible, and lo so it is either the smallest possible answer or certainly infeasible.
  • Round mid away from the side that keeps it.
  • Keep the check separate: fits(c) as its own function, tested alone.
  • Where it shows up: capacity planning, choosing timeouts and batch sizes, finding the first bad build in a history, rate limits, and many "minimise the maximum" or "maximise the minimum" problems.

⚡ Advanced — bisect can search an answer space too

bisect_left accepts any sequence, and since Python 3.10 a key. A range is a lazy sequence, so you can hand it the whole answer space without building a list:

python

It is tidy, but it hides the four questions. Write the loop by hand until those questions are automatic.


Quick practice

What does this print?
def last_true(lo, hi, ok):
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if ok(mid):
            lo = mid
        else:
            hi = mid - 1
    return lo

print(last_true(0, 100, lambda x: x ** 3 <= 1000))
broken — fix it

The smallest x whose square reaches 49 is 7 (7² = 49). This prints 8, because the test only accepts squares that are strictly bigger than 49.


🎯 Your turn

Build min_capacity(tables, budget) for Quarry's capacity tuner. tables lists each table's row count (each at least 1). A table with r rows needs ⌈r/c⌉\lceil r / c \rceil shards at capacity c. Return the smallest whole-number c >= 1 whose total shard count fits in budget, or -1 if no capacity can (every table needs at least one shard). With no tables, return 1.

  • min_capacity([30, 11, 23, 4], 6) → 15
  • min_capacity([30, 11, 23, 4], 3) → -1

Hint: search c from 1 to max(tables) with the first-true template. The test is shards_needed(tables, c) <= budget.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of the app is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upThe biggest cube

Quarry packs blocks into a cube-shaped layout. Write cube_side(n) returning the largest whole number x >= 0 with x * x * x <= n, using binary search on x (no ** (1/3): floating point can land just below a whole cube root). n >= 0. cube_side(26) → 2, and cube_side(27) → 3.

binary-searchsearch-space
DrillSpread the replicas

Quarry places k replicas of a hot index on racks, at most one per rack, and wants the smallest distance between any two replicas to be as large as possible. racks lists distinct rack positions in any order, and 2 <= k <= len(racks). Write spread_replicas(racks, k) returning that largest possible smallest distance. spread_replicas([1, 9, 4, 15, 12], 3) → 6, using racks 1, 9 and 15.

binary-searchfeasibility-checkgreedy
BuildA fair throttle

Tenants ask Quarry for demands[i] units of throughput per second, but only capacity units exist. Quarry picks a whole-number cap t and grants each tenant min(demand, t). Write throttle_cap(demands, capacity) returning the largest t whose grants add up to at most capacity. If every demand fits in full, return the largest demand (or 0 with no tenants). throttle_cap([50, 20, 90, 40], 150) → 45.

parametric-searchsearching
BossThe k-th smallest latency gap

Quarry compares every pair of requests in a trace by the difference in their latencies. Write kth_gap(latencies, k) returning the k-th smallest of those differences, counting from 1, without listing all the pairs. 2 <= len(latencies) and 1 <= k <= n·(n−1)/2. kth_gap([12, 40, 19, 33], 2) → 7, since the sorted gaps are 7, 7, 14, 21, 21, 28.

predicate-monotonesearchingtwo-pointers
CapstoneThe widest steady window

Quarry calls a window size w steady if every window of w consecutive requests has its slowest and fastest latencies within limit of each other. Write widest_steady(latencies, limit) returning the largest steady w (0 for an empty trace). Aim for O(n log n): binary search w, and check each candidate in O(n) with two monotonic deques. widest_steady([10, 12, 11, 15, 14, 13], 4) → 3.

binary-searchmonotonic-dequesearching
ExplainProving the test flips once

Before Quarry's capacity tuner ships, a reviewer asks you to justify using binary search on the shard capacity. Explain what property the test shards_needed(tables, c) <= budget must have, prove that it has it, and describe what binary search would do if it didn't.

min_capacity.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
min_capacity(tables, budget) → intThe smallest shard capacity that fits every table into the shard budget, or -1.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.