Binary Search on the Answer, Two Pointers and Sweeping
Binary search written from one invariant, searching over the answer instead of an array, sliding windows, meet in the middle and sweep lines, each stress tested against a brute force.
Easy to describe, easy to get wrong
Binary search is the first algorithm most people learn after sorting, and one of the most often written incorrectly. The idea fits in a sentence: compare with the middle, throw away the half that cannot contain the answer. The code has three places to slip: whether the bounds are inclusive or exclusive, whether the middle rounds up or down, and how the middle is computed. A slip in any of them gives an infinite loop, an answer that is off by one, or an out-of-range index, and usually only on some inputs.
There is a well-documented example. In 2006 Joshua Bloch reported that the binary search in Java's java.util.Arrays, and the one in Jon Bentley's book "Programming Pearls", computed the middle as (low + high) / 2, which overflows once the array has about a billion (2^30) elements or more. The Java code had been in use for about nine years.
This lesson builds binary search from one invariant, so that the three choices follow from it instead of from memory. Then it moves the idea from arrays to answers: "what is the smallest battery range that gets the car home?" is a binary search over ranges whenever a bigger range never hurts. The second half covers three other techniques for the n up to about 2*10^5 row of the constraints table from Complexity in Practice: two pointers, meet in the middle and sweep lines. Every program is stress tested with the harness habit from Fast I/O, Contest Templates and Stress Testing. The next lesson, Prefix Sums, Fenwick Trees and Segment Trees, answers many range questions about one array without rescanning it.
One invariant: false below `lo`, true from `hi`
Almost every binary search answers one question: given a predicate pred that is false for a while and then true forever (we say it is monotone), where does it switch? "Is a[i] >= x?" on a sorted array is such a predicate. So is "can the car finish with range r?".
Keep a half-open window [lo, hi) and the invariant:
- every index below
lohaspredfalse, - every index from
hion haspredtrue.
At the start lo is the first index and hi is one past the last, so the invariant holds trivially (nothing is below lo or from hi on). While lo < hi, look at mid = lo + (hi - lo) / 2, which satisfies lo <= mid < hi. If pred(mid) is true, everything from mid on is true, so hi = mid keeps the invariant. If it is false, everything up to mid is false, so lo = mid + 1. Each step shrinks the window by at least one, because mid < hi and mid + 1 > lo, so the loop ends; when it does, lo == hi is the first true index (or one past the end if there is none).
By hand, on a = {2, 3, 3, 3, 7, 9} with pred(i) = a[i] >= 3: [0, 6), mid = 3, true, so [0, 3); mid = 1, true, so [0, 1); mid = 0, a[0] = 2, false, so [1, 1). Answer: index 1, the first 3.
That is all the theory. The rounding (down) and the update (hi = mid, lo = mid + 1) are not choices any more; they are what the invariant requires.
`lower_bound`, `upper_bound` and `partition_point`
The standard library implements this search three times:
std::lower_bound(first, last, x)returns the first element that is not less thanx, i.e. the firstewithe >= x.std::upper_bound(first, last, x)returns the first element greater thanx.std::partition_point(first, last, p)is the general form: the range must be partitioned so that every element satisfyingpcomes before every element that does not, and it returns the first element that does not satisfyp. Note the direction:pis true first, then false, the opposite of ourpred.
All three need only that the range is partitioned with respect to the question, not fully sorted. lower_bound and upper_bound make at most log2(N) + O(1) comparisons; on iterators that are not random access (for example std::set's) the iterator steps are still linear, which is why std::set has its own member lower_bound. The C++20 std::ranges:: versions take a whole range. The count of a value is upper_bound - lower_bound.
The program prints a few results on a small array, stress tests the hand-written firstTrue against a linear scan and std::ranges::lower_bound on 5,000 random sorted arrays (including empty ones, all-equal ones and targets below and above every value), and counts probes for growing n.
#include <algorithm>
#include <cstdint>
#include <print>
#include <ranges>
#include <vector>
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
};
long long probes = 0;
// Smallest i in [lo, hi) with pred(i) true, or hi if there is none.
// Requires pred to be false...false true...true on [lo, hi).
// Invariant: pred is false below lo and true from hi on.
template <class Pred>
int firstTrue(int lo, int hi, Pred pred) {
while (lo < hi) {
int mid = lo + (hi - lo) / 2; // lo <= mid < hi, never overflows
++probes;
if (pred(mid)) hi = mid;
else lo = mid + 1;
}
return lo;
}
int main() {
std::vector<int> a{2, 3, 3, 3, 7, 9};
auto lb = std::ranges::lower_bound(a, 3) - a.begin(); // first a[i] >= 3
auto ub = std::ranges::upper_bound(a, 3) - a.begin(); // first a[i] > 3
auto pp = std::ranges::partition_point(a, [](int x) { return x < 5; }) - a.begin();
std::println("lower_bound(3) = {}, upper_bound(3) = {}, count = {}", lb, ub, ub - lb);
std::println("lower_bound(8) = {}, lower_bound(10) = {}",
std::ranges::lower_bound(a, 8) - a.begin(),
std::ranges::lower_bound(a, 10) - a.begin());
std::println("first x >= 5: index {}", pp);
Lcg rng{8};
int checked = 0;
for (; checked < 5000; ++checked) {
std::vector<int> v(rng.range(0, 8)); // empty arrays included
for (int& x : v) x = rng.range(0, 4); // small values: many duplicates
std::ranges::sort(v);
int x = rng.range(-1, 5); // below, inside and above the values
int fast = firstTrue(0, int(v.size()), [&](int i) { return v[i] >= x; });
int slow = 0;
while (slow < int(v.size()) && v[slow] < x) ++slow;
int library = int(std::ranges::lower_bound(v, x) - v.begin());
if (fast != slow || library != slow) {
std::println("mismatch at case {}", checked);
return 1;
}
}
std::println("{} random cases, all agree", checked);
for (int n : {1'000, 1'000'000, 1'000'000'000}) {
probes = 0;
firstTrue(0, n, [&](int i) { return i >= n / 3; });
std::println("n = {:>13}: {} probes", n, probes);
}
}Press Run to compile and run this example.
lower_bound(3) = 1, upper_bound(3) = 4, count = 3
lower_bound(8) = 5, lower_bound(10) = 6
first x >= 5: index 4
5000 random cases, all agree
n = 1000: 10 probes
n = 1000000: 20 probes
n = 1000000000: 30 probeslower_bound(10) returns 6, one past the end: "no element is at least 10". Always check for that before dereferencing. The last three lines are the reason binary search matters: each factor of 1,000 in n adds 10 probes, because 2^10 = 1024. The predicate in the last loop is a lambda over indices, not an array, which is the step to the next sections: a binary search does not need an array at all.
Two bugs that pass the sample
The first bug appears when you search for the last true instead of the first, and write lo = mid to keep a candidate. With mid = lo + (hi - lo) / 2 rounding down and hi = lo + 1, mid equals lo; if pred(mid) is true, lo = mid changes nothing, and the loop runs forever. The fix is to round up whenever the update is lo = mid, so that mid > lo. The second bug is the overflow Bloch described. The program shows both; the loop gives up after 100 rounds so you can see the stuck state, and the overflow is shown with 32-bit unsigned values, where wrapping around is defined. With signed int the overflow is undefined behavior, which is worse: the compiler may assume it never happens.
#include <cstdint>
#include <numeric>
#include <print>
// Largest x in [lo, hi] with ok(x) true; ok must be true...true false...false
// and ok(lo) must be true. Gives up after 100 rounds so the bug is visible.
template <class Pred>
void lastTrue(const char* name, long long lo, long long hi, Pred ok, bool roundUp) {
for (int round = 1; round <= 100; ++round) {
if (lo == hi) {
std::println("{}: answer {} after {} rounds", name, lo, round - 1);
return;
}
long long mid = roundUp ? lo + (hi - lo + 1) / 2 : lo + (hi - lo) / 2;
if (ok(mid)) lo = mid; // mid is a candidate, keep it
else hi = mid - 1;
}
std::println("{}: still at lo = {}, hi = {} after 100 rounds", name, lo, hi);
}
int main() {
auto squareFits = [](long long x) { return x * x <= 30; }; // answer: 5
lastTrue("mid rounds down", 0, 30, squareFits, false);
lastTrue("mid rounds up ", 0, 30, squareFits, true);
// Midpoint overflow, shown with 32-bit unsigned values where wrapping is defined.
// With signed int the same sum is undefined behavior, which is worse.
std::uint32_t lo = 3'000'000'000, hi = 4'000'000'000;
std::uint32_t naive = (lo + hi) / 2;
std::uint32_t safe = lo + (hi - lo) / 2;
std::println("(lo + hi) / 2 = {}", naive);
std::println("lo + (hi - lo) / 2 = {}", safe);
std::println("std::midpoint = {}", std::midpoint(lo, hi));
}Press Run to compile and run this example.
mid rounds down: still at lo = 5, hi = 6 after 100 rounds
mid rounds up : answer 5 after 5 rounds
(lo + hi) / 2 = 1352516352
lo + (hi - lo) / 2 = 3500000000
std::midpoint = 3500000000The rounding-down version reaches [5, 6], computes mid = 5, finds 25 <= 30, sets lo = 5 and repeats forever. On a judge that is "time limit exceeded" on some tests and correct output on others, depending on where the answer lies. 3,000,000,000 + 4,000,000,000 wraps modulo 2^32 to 2,705,032,704, whose half is the nonsense value shown. std::midpoint (C++20, header <numeric>) computes the middle of two integers without overflow, rounding towards its first argument.
The simplest defense against the first bug is not to need it: write every search as firstTrue with a half-open window. "Last x with ok(x)" is "first x with !ok(x), minus one".
Binary search on the answer
A car drives along a straight road from position 0 to position len. There are n charging stations at known positions strictly between the two ends. The battery holds enough for r kilometers, starts full, and is recharged to full at every stop. The driver accepts at most k stops. What is the smallest integer range r that makes the trip possible? Constraints: n <= 2*10^5, len <= 10^9.
Two observations turn this into binary search:
- Checking a given
ris easy. Drive greedily: pass each station if the battery still reaches the next one, and stop at the last station you can reach otherwise. Stopping later never hurts, so greedy uses the fewest stops. That isO(n). - Feasibility is monotone in
r. If rangerworks, any larger range works with the same stops. SocanFinish(r)is false, false, ..., true, true, and the answer is the first true.
The answer lies in [1, len] (range len needs no stops at all), so about log2(10^9) = 30 checks suffice: O(n log len) in total. Input for the sample: n = 6, k = 2, len = 100, then the stations.
6 2 100
15 30 42 60 71 88The brute force tries r = 1, 2, 3, ... and checks each with a dynamic program over stations (fewest stops to reach each station), which shares no logic with the greedy.
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <print>
#include <sstream>
#include <vector>
using ll = long long;
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
};
int predicateCalls = 0;
// Can a car with range r get from 0 to len with at most k recharging stops?
// stops: sorted positions strictly between 0 and len. Greedy: drive as far as you can.
bool canFinish(const std::vector<ll>& stops, ll len, int k, ll r) {
++predicateCalls;
ll pos = 0; // where the battery was last full
ll prev = 0; // last station passed
int used = 0;
for (ll s : stops) {
if (s - prev > r) return false; // a gap no charge can bridge
if (s - pos > r) pos = prev, ++used; // must have charged at prev
prev = s;
}
if (len - prev > r) return false;
if (len - pos > r) ++used;
return used <= k;
}
ll minRange(const std::vector<ll>& stops, ll len, int k) {
ll lo = 1, hi = len; // range len always works; the answer lies in [lo, hi]
while (lo < hi) {
ll mid = lo + (hi - lo) / 2;
if (canFinish(stops, len, k, mid)) hi = mid;
else lo = mid + 1;
}
return lo;
}
// Brute force: try every r upwards, each checked by a DP over stations.
ll slowRange(const std::vector<ll>& stops, ll len, int k) {
std::vector<ll> p{0};
p.insert(p.end(), stops.begin(), stops.end());
p.push_back(len);
int m = int(p.size());
for (ll r = 1;; ++r) {
std::vector<int> fewest(m, 1 << 20); // fewest stops to arrive at p[i]
fewest[0] = 0;
for (int i = 1; i < m; ++i)
for (int j = 0; j < i; ++j)
if (p[i] - p[j] <= r) // a stop at p[j] counts unless j is the start
fewest[i] = std::min(fewest[i], fewest[j] + (j > 0));
if (fewest[m - 1] <= k) return r;
}
}
void solve(std::istream& in, std::ostream& out) {
int n, k;
ll len;
in >> n >> k >> len;
std::vector<ll> stops(n);
for (ll& s : stops) in >> s;
out << minRange(stops, len, k) << '\n';
}
int main() {
std::istringstream in("6 2 100\n15 30 42 60 71 88\n");
solve(in, std::cout); // on a judge: solve(std::cin, std::cout)
Lcg rng{3};
int checked = 0;
for (; checked < 3000; ++checked) {
ll len = rng.range(1, 40);
std::vector<ll> stops;
for (ll x = 1; x < len; ++x)
if (rng.range(0, 3) == 0) stops.push_back(x); // sometimes none at all
int k = rng.range(0, 4);
if (minRange(stops, len, k) != slowRange(stops, len, k)) {
std::println("mismatch at case {}", checked);
return 1;
}
}
std::println("{} random cases, all agree", checked);
predicateCalls = 0;
std::vector<ll> far{400'000'000, 900'000'000};
std::println("len 10^9: range {} after {} predicate calls",
minRange(far, 1'000'000'000, 1), predicateCalls);
}Press Run to compile and run this example.
40
3000 random cases, all agree
len 10^9: range 600000000 after 30 predicate callsWith r = 40: charge at 30 (the last station within 40 of the start), then at 60, and 60 to 100 is exactly 40. With r = 39 the same greedy needs a third stop. The pattern is general. When a problem says "minimize the maximum" or "maximize the minimum" (the largest gap, the slowest machine, the longest wait), ask whether a yes/no version with a fixed threshold is easy and monotone. If it is, binary search over the threshold and the hard optimization turns into log easy checks.
Real numbers and unimodal functions
When the answer is a real number, there is no "first true index". Search on double and stop after a fixed number of iterations rather than when hi - lo < eps. Each iteration halves the interval, so 100 iterations shrink any starting interval by 2^100, far below what a double can resolve; the loop always terminates and you never choose an epsilon that is too small for large values (where lo and hi can stop changing before they get that close).
The example: columns of width 1 with given heights, and volume units of water poured over them that settle at one level h. The water stored at level h is the sum of max(0, h - height[i]), which grows with h, so we binary search for the level where it reaches volume.
The other half of the program is ternary search, for functions that are not monotone but unimodal: strictly decreasing, then strictly increasing (or convex, as here). Compare f at two interior points m1 < m2. If f(m1) < f(m2), the minimum cannot be right of m2; otherwise it cannot be left of m1. On integers, stop when three or fewer candidates remain and check them directly. Each round keeps about two thirds, so it needs O(log n) rounds, two evaluations each.
#include <algorithm>
#include <cstdlib>
#include <functional>
#include <print>
#include <vector>
// Water poured over columns of width 1 settles at one level h with
// sum of max(0, h - height[i]) equal to the volume. Find h.
double waterLevel(const std::vector<double>& height, double volume) {
auto stored = [&](double h) {
double sum = 0;
for (double x : height) sum += std::max(0.0, h - x);
return sum;
};
double lo = std::ranges::min(height), hi = std::ranges::max(height) + volume;
for (int it = 0; it < 100; ++it) { // a fixed count: no epsilon to choose
double mid = (lo + hi) / 2;
if (stored(mid) >= volume) hi = mid;
else lo = mid;
}
return hi;
}
int evaluations = 0;
// Smallest value of f on [lo, hi] for f that falls, then rises (no flat steps).
using Fn = std::function<long long(long long)>;
long long ternaryMin(long long lo, long long hi, const Fn& f) {
while (hi - lo > 2) {
long long m1 = lo + (hi - lo) / 3, m2 = hi - (hi - lo) / 3;
evaluations += 2;
if (f(m1) < f(m2)) hi = m2 - 1; // the minimum is not right of m2
else lo = m1 + 1; // the minimum is not left of m1
}
long long best = f(lo);
for (long long x = lo + 1; x <= hi; ++x) best = std::min(best, f(x));
return best;
}
int main() {
std::println("level for 10 units: {:.6f}", waterLevel({3, 1, 4, 1, 5, 9, 2, 6}, 10));
std::println("level for 1 unit: {:.6f}", waterLevel({3, 1, 4, 1, 5, 9, 2, 6}, 1));
// Total walking distance to a meeting point x: convex in x.
std::vector<long long> homes{12, 40, 41, 77, 300, 999'983};
auto walk = [&](long long x) {
long long sum = 0;
for (long long p : homes) sum += std::llabs(x - p) * (1 + p % 3);
return sum;
};
long long brute = walk(0);
for (long long x = 1; x <= 1'000'000; ++x) brute = std::min(brute, walk(x));
std::println("ternary {} vs brute {}, {} evaluations instead of 1000001",
ternaryMin(0, 1'000'000, walk), brute, evaluations);
// A flat step breaks it: f(m1) == f(m2) cannot tell which side to drop.
auto flat = [](long long x) { return std::min(std::llabs(x - 100), 100LL); };
std::println("flat part: ternary {}, true minimum 0", ternaryMin(0, 1000, flat));
}Press Run to compile and run this example.
level for 10 units: 4.200000
level for 1 unit: 1.500000
ternary 3000188 vs brute 3000188, 62 evaluations instead of 1000001
flat part: ternary 100, true minimum 0The water level checks out by hand: between heights 4 and 5 the water covers the two columns of height 1 and those of height 2, 3 and 4, so it holds 2(h - 1) + (h - 2) + (h - 3) + (h - 4) = 5h - 11, which is 10 at h = 4.2.
The last line is the trap. min(|x - 100|, 100) falls to 0 at x = 100, rises, then stays flat at 100. Both probes land on the flat part, f(m1) == f(m2), and the search discards the left side, where the minimum is. Ternary search needs strict unimodality, or convexity (where a tie can only happen around the minimum). A function with a flat stretch away from the minimum breaks it. For a convex function on integers there is also a cleaner alternative: binary search for the first x with f(x + 1) - f(x) >= 0, which is monotone.
Two pointers: a window that only moves forward
A sensor records a[0..n-1] events per minute, all >= 0. For a budget S, find the longest stretch of consecutive minutes with at most S events, and the number of such stretches. Checking every pair (l, r) is O(n^2); with n = 2*10^5 that is 2*10^10 sums.
The observation: if a[l..r] is within budget, so is every shorter window inside it, because the values are non-negative. So when the right end moves forward, the best left end for it never moves back. Keep l and r, extend r by one each step, and advance l only while the window is over budget. Each pointer moves at most n times, so the whole scan is O(n) (the amortized argument from lesson 1). For each r, the windows ending at r that fit are exactly those starting in [l, r], which is r - l + 1 of them, so counting is free.
The program stress tests this against the O(n^2) brute force twice: once with values 0..9 as the problem promises, once with values -5..9, where the "shorter windows are cheaper" argument is false. It shrinks the failing input as in lesson 2, and finally counts pointer moves.
#include <algorithm>
#include <cstdint>
#include <print>
#include <vector>
using ll = long long;
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
};
struct Windows { int longest; ll count; }; // windows a[l..r] with sum <= budget
long long pointerMoves = 0;
Windows twoPointers(const std::vector<ll>& a, ll budget) {
Windows w{0, 0};
ll sum = 0;
int l = 0;
for (int r = 0; r < int(a.size()); ++r) {
sum += a[r];
++pointerMoves;
while (l <= r && sum > budget) sum -= a[l++], ++pointerMoves; // shrink
w.longest = std::max(w.longest, r - l + 1);
w.count += r - l + 1; // every l' in [l, r] gives a valid window ending at r
}
return w;
}
Windows bruteForce(const std::vector<ll>& a, ll budget) {
Windows w{0, 0};
for (int l = 0; l < int(a.size()); ++l) {
ll sum = 0;
for (int r = l; r < int(a.size()); ++r) {
sum += a[r];
if (sum <= budget) w.longest = std::max(w.longest, r - l + 1), ++w.count;
}
}
return w;
}
void stress(const char* name, int minValue) {
Lcg rng{21};
for (int test = 0; test < 3000; ++test) {
std::vector<ll> a(rng.range(0, 10));
for (ll& x : a) x = rng.range(minValue, 9);
ll budget = rng.range(0, 20);
auto differ = [&](const std::vector<ll>& v) {
auto [l1, c1] = twoPointers(v, budget);
auto [l2, c2] = bruteForce(v, budget);
return l1 != l2 || c1 != c2;
};
if (differ(a)) {
for (std::size_t i = 0; i < a.size();) { // shrink, as in lesson 2
std::vector<ll> smaller = a;
smaller.erase(smaller.begin() + i);
if (differ(smaller)) a = smaller; else ++i;
}
auto [l1, c1] = twoPointers(a, budget);
auto [l2, c2] = bruteForce(a, budget);
std::print("{}: test {} fails on", name, test);
for (ll x : a) std::print(" {}", x);
std::println(" with budget {}: got ({}, {}), want ({}, {})",
budget, l1, c1, l2, c2);
return;
}
}
std::println("{}: 3000 tests, all agree", name);
}
int main() {
auto [longest, count] = twoPointers({4, 2, 0, 7, 1, 1, 3, 8}, 7);
std::println("budget 7: longest {}, windows {}", longest, count);
stress("values 0..9 ", 0);
stress("values -5..9", -5);
for (int n : {1'000, 10'000, 100'000}) {
pointerMoves = 0;
twoPointers(std::vector<ll>(n, 1), 50);
std::println("n = {:>6}: {} pointer moves", n, pointerMoves);
}
}Press Run to compile and run this example.
budget 7: longest 3, windows 14
values 0..9 : 3000 tests, all agree
values -5..9: test 1 fails on 7 -2 with budget 5: got (1, 1), want (2, 2)
n = 1000: 1950 pointer moves
n = 10000: 19950 pointer moves
n = 100000: 199950 pointer movesThe shrunk counterexample is two numbers. The window {7} is over budget, so the left pointer moves past it; but {7, -2} sums to 5 and fits. With negative values a longer window can be cheaper than a shorter one, monotonicity fails, and so does the method. The fix for negatives is a different technique: with prefix sums P, the window l..r fits when P[r+1] - P[l] <= S, and for each r you search an ordered structure of earlier prefix sums, O(n log n). The pointer moves are under 2n, as promised.