Create your own
Lesson illustration

Proving Termination and Partial Correctness of a Boundary-Search Loop

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:

  1. Partial correctness: if the loop finishes, its returned value satisfies the boundary contract.
  2. 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.
  • P is deterministic: evaluating it twice at the same input gives the same result.
  • P is 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 valuesFirst 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.

Binary Search - Algorithms for Competitive Programming

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.

In “Search on arbitrary predicate,” first read the paragraph defining a monotonically increasing Boolean function. Then read the proof sketch. 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.


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 lo has been proved false.
  • Everything at or right of hi has 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 mid is 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 mid is 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 .

lohimidP(mid)Resulting unresolved interval
0105false
6108true
687true
676true

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.

Binary Search - A Different Perspective | Python Algorithms

Watch “Binary Search - A Different Perspective” by mCoding for a compact explanation of why the distance between the bounds shrinks on every iteration.

In the segment beginning just after the implementation, watch the termination argument. Focus on the justification that the midpoint is strictly less than the upper bound, which makes the hi = mid branch progress rather than stall.


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.


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:

  1. Contract
    State exactly what the result means, including all-false and all-true cases.

  2. Invariant
    State the bounds and what has been established outside the active interval.

  3. Maintenance
    For each branch, explain:

    • what new region has become known;
    • where monotonicity is used;
    • why the new bounds remain valid.
  4. Termination and exit
    Use an integer variant such as hi - lo, prove strict decrease, then apply the invariant once lo == hi.

For a short proof, this is usually sufficient:

Maintain that every value before lo is false and every value from hi onward is true. At a midpoint, a true result makes the suffix beginning at mid true by monotonicity, so set hi = mid; a false result makes the prefix through mid false, so set lo = mid + 1. The width hi - lo is 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 == hi into 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

Sign up