Welcome back. You now have the core ingredients of a robust boundary search: a monotone predicate, a half-open interval contract, invariant-preserving updates, and an overflow-safe lower midpoint. This lesson puts them together into the proof that makes the template trustworthy.
We will prove two claims about a first-true search:
- Partial correctness: if the loop finishes, its returned value satisfies the boundary contract.
- Termination: the loop must finish on every finite, representable search interval.
Together, these give total correctness.
1. Start with a precise contract
Consider this C++20 first-true template:
#include <numeric>
template <class Pred>
int first_true(int lo, int hi, Pred P) {
while (lo < hi) {
int mid = std::midpoint(lo, hi);
if (P(mid)) {
hi = mid;
} else {
lo = mid + 1;
}
}
return lo;
}
Let the original input interval be , where initially:
lo = a;
hi = b;
Assume:
- The interval is finite and its endpoints are representable as
int. Pis deterministic: evaluating it twice at the same input gives the same result.Pis monotone nondecreasing over the domain:
In other words, once P becomes true, it stays true as the input increases.
The desired postcondition for the returned value is:
This contract deliberately handles every edge case:
| Predicate pattern on | Returned |
|---|---|
| All values are false | |
| All values are true | |
| False values followed by true values | First true position |
Notice an important detail: when , we do not evaluate P(b). It is a past-the-end boundary, not necessarily a valid predicate input.
The resource below presents the same idea using explicit false and true sentinels around the searchable indices. Its proof sketch is compact, but its sentinel-style loop is not identical to the half-open loop we will prove here.
{"type":"reading","par_intro":"Read the “Search on arbitrary predicate” section from Algorithms for Competitive Programming. It gives a concise invariant-based proof of locating a false-to-true transition.","par_directions":"In “Search on arbitrary predicate,” first read the paragraph defining a monotonically increasing Boolean function. Then read the <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"1236d6d0\" data-range-start=\"Proof of correctness supposing a transition point exists, that is\" data-range-end=\"giving us our desired transition point.\">proof sketch</span>. Focus on the two proof ingredients: preserving known false and true positions, and showing that the remaining gap shrinks. Their code uses external sentinels; keep that invariant separate from the half-open invariant developed below.","learning_duration":"8 minutes","url":"https://cp-algorithms.com/num_methods/binary_search.html","title":"Binary Search - Algorithms for Competitive Programming","isV2":true,"blockId":"5430a919-ce8d-44e3-9e1c-ddf16d5a5c94","lessonId":"bc600820-9e19-4ce8-a195-ea5c9f28086c"}
{
"type": "exercise",
"id": "1412e30e-11e2-4068-9cc4-3fc010a73150"
}
2. The loop invariant: turn the final contract into a live claim
A loop invariant is a statement that is true before the first iteration and remains true after every completed iteration.
For first-true search, use this invariant:
Read it operationally:
- Everything strictly left of
lohas been proved false. - Everything at or right of
hihas been proved true. - Only remains unresolved.
This is stronger and more useful than merely saying “the answer is still somewhere in the interval.” It records why discarded positions cannot contain the first true value.
Initialization
Before the loop:
lo = a;
hi = b;
The bounds condition holds immediately:
The two logical regions are empty:
A universal statement about an empty set is true. There are no positions left of a that need to be false, and no positions at or beyond b that need to be true. Therefore, the invariant holds before the first iteration.
This is one reason half-open intervals are so clean: initialization needs no special case for an empty domain.
3. Maintenance: why each update preserves the invariant
Assume the invariant holds at the start of an iteration and the guard is true:
Because std::midpoint(lo, hi) returns the lower midpoint for ordered integer arguments:
Thus, mid is always a valid, unresolved position in the active interval.
There are two cases.
Case 1: P(mid) is true
The code executes:
hi = mid;
The new known-true region is .
Why is every value in that region true?
P(mid)is true by the branch condition.- By monotonicity, every position greater than
midis also true. - The previously known-true region remains true as well.
So after setting hi = mid:
The known-false region has not changed because lo did not change.
The bounds remain valid because:
So the invariant is preserved.
Case 2: P(mid) is false
The code executes:
lo = mid + 1;
The new known-false region is .
Why is every value there false?
P(mid)is false by the branch condition.- If some were true, monotonicity would force
P(mid)to be true too. That contradicts the branch condition. - Therefore, every value at or left of
midis false.
After assigning lo = mid + 1:
The known-true region does not change because hi does not change.
The bounds remain valid because:
so:
Thus the invariant is preserved in this branch too.
A concrete trace
Take:
over . The first true value is .
lo | hi | mid | P(mid) | Resulting unresolved interval |
|---|---|---|---|---|
| 0 | 10 | 5 | false | |
| 6 | 10 | 8 | true | |
| 6 | 8 | 7 | true | |
| 6 | 7 | 6 | true |
At every stage, the values left of lo are known false, values at or right of hi are known true, and only the displayed interval is undecided.
4. Termination: prove a quantity strictly decreases
Preserving the invariant is not enough. A loop could preserve an invariant forever.
For termination, define the variant:
The bounds part of the invariant gives:
So is always a nonnegative integer.
Whenever the loop runs, lo < hi, hence . We now show that each branch strictly decreases .
True branch
When P(mid) is true:
hi = mid;
The new variant is:
Since:
we get:
False branch
When P(mid) is false:
lo = mid + 1;
The new variant is:
Since:
we have:
Therefore:
In both branches, remains a nonnegative integer and strictly decreases. A nonnegative integer cannot decrease forever. By the well-ordering principle, eventually , meaning:
At that point the loop condition is false, so the loop terminates.
The short video segment below emphasizes the key implementation fact behind this argument: the midpoint is strictly below high when the active interval is nonempty.
{"type":"video","title":"Binary Search - A Different Perspective | Python Algorithms","learning_duration":52,"video_id":"tgVSkMA8joQ","par_intro":"Watch “Binary Search - A Different Perspective” by mCoding for a compact explanation of why the distance between the bounds shrinks on every iteration.","par_directions":"In the segment beginning just after the implementation, watch <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"09207df8\" data-range-start=\"361\" data-range-end=\"413\">the termination argument</span>. Focus on the justification that the midpoint is strictly less than the upper bound, which makes the `hi = mid` branch progress rather than stall.","video_duration":536,"isV2":true,"blockId":"848bf77b-2a34-471a-a4bf-341a9023c359","lessonId":"bc600820-9e19-4ce8-a195-ea5c9f28086c"}
{
"type": "exercise",
"id": "40b83f33-f110-4131-979f-ebccbc56cc52"
}
5. Partial correctness at loop exit
Now suppose the loop has ended.
The loop guard is false, so:
The bounds invariant also says:
The only possibility is:
The function returns lo, so it returns .
Substitute for both bounds in the invariant:
Those are exactly the desired postconditions.
There are two interpretations:
- If , then
P(q)is true and every earlier position is false. Thus is the first true value. - If , every position in is false. Thus no true value exists in the original domain, and returning the past-the-end boundary is correct.
So the function is partially correct. Since the preceding variant proof established termination, the function is totally correct.
{
"type": "exercise",
"id": "eb76e651-fb0d-4edd-bb51-bbb7ea6c21f8"
}
6. A proof-writing template for interviews and contests
For a boundary-search explanation, avoid saying only “binary search cuts the range in half.” That is intuition, not a proof. A compact rigorous explanation has four parts:
-
Contract
State exactly what the result means, including all-false and all-true cases. -
Invariant
State the bounds and what has been established outside the active interval. -
Maintenance
For each branch, explain:- what new region has become known;
- where monotonicity is used;
- why the new bounds remain valid.
-
Termination and exit
Use an integer variant such ashi - lo, prove strict decrease, then apply the invariant oncelo == hi.
For a short proof, this is usually sufficient:
Maintain that every value before
lois false and every value fromhionward is true. At a midpoint, a true result makes the suffix beginning atmidtrue by monotonicity, so sethi = mid; a false result makes the prefix throughmidfalse, so setlo = mid + 1. The widthhi - lois a nonnegative integer and decreases strictly in either branch. When the bounds meet, the invariant states exactly that the meeting point is the first true position, or the end if no true position exists.
Focused practice protocol
Spend about 10 minutes writing this proof without looking at the template:
- Write the three invariant clauses first.
- For each branch, name the exact newly eliminated interval.
- State precisely where monotonicity is used.
- Finish with the variant , not an informal claim that the range “roughly halves.”
- At exit, substitute
lo == hiinto the invariant and derive the return contract.
If any sentence says only “we discard this half,” strengthen it by stating what predicate value has been proved for that discarded region.
Conclusion
A correct boundary search rests on two complementary arguments:
- The loop invariant proves that discarded positions are classified correctly: false on the left and true on the right.
- The variant proves that the active interval cannot remain nonempty forever.
At termination, lo and hi meet at exactly the boundary promised by the contract. This is why the familiar updates hi = mid and lo = mid + 1 are not arbitrary off-by-one conventions: each is forced by the invariant and the requirement of strict progress.
Next, you will turn this proof-backed idea into reusable C++ boundary-search templates, beginning with a callable half-open first_true implementation.
Can't find a good explanation? Sign up and we'll make it for you