When we want to convince ourselves that our code is correct, we need to be able to argue that certain things are true at various points in the program. Loops pose a special challenge for such arguments, because the same code can be executed multiple times. How do we know what is true about the program as loops execute?
A loop invariant is a condition that is true at the beginning and end of every iteration of a loop. The concept is similar to a class invariant, which must be true at the beginning and end of every public method. When you write a loop that works correctly, you are at least implicitly relying on a loop invariant. Knowing what a loop invariant is and thinking explicitly about loop invariants will help you write correct, efficient code, especially when implementing tricky algorithms.
Suppose we want to find an element in a sorted array. We can do much better than scanning from left to right: we can use binary search. Here is the binary search algorithm, written as a loop.
binary_search.java
Conceptually, this algorithm is simple. But it is deceptively tricky to get
exactly right. How do we know we got the computation of m
right?
Why is it k <= a[m] and not k < a[m]? Why
m
and m+1
in the two updates to r
and
l
respectively? If we change any of these decisions, the algorithm is
incorrect and will sometimes fail to find the correct element.
The key question is whether the loop code is correct. To say what it
means to be correct, we must identify the precondition and the postcondition
of the loop itself. The precondition describes the possible states of the
program just as the loop begins executing; the postcondition describes the
desired states when the loop stops. In this case, the precondition consists
of the precondition of search()
itself, plus the facts that
l = 0
and r = a.length - 1
. The desired
postcondition is that k = a[l]
. To show that the loop
is correct, we use a loop invariant to transform the state of
the program from one satisfying the precondition to one satisfying
the postcondition.
To convince ourselves that we wrote the correct code, we need a loop invariant that describes the conditions that we want the loop body to preserve. For this example, we can write a useful loop invariant using three clauses:
a
is sorted in ascending order.
0 ≤ l ≤ r ≤ a.length-1
k ∈ a[l..r]
We use the notation i..j
to denote the set or sequence {x |
i ≤ x ≤ j}
or (i,i+1,...,j-1,j)
. We use the notation
a[i..j]
to denote the subsequence of the array a
starting from a[i]
and continuing up to and including
a[j]
.
If we squint at this loop invariant, we can see that it looks a bit like
the precondition and a bit like the postcondition, but generalizes both
of them. The invariant looks like the precondition except that it
ranges over just the indices from l
to r
.
And the third part of the invariant looks like the postcondition,
except that it talks about a range of the array rather than a single
element. The loop invariant captures the progress that the algorithm
makes as it runs.
If we have a loop and we know what the loop invariant is for that loop, it is
often a good idea to document it. In fact, we can document it in a checkable
way by using an assert
statement that is executed on every loop
iteration.
We observed earlier that tail-recursive functions can be converted into loops. Conversely, loops can be converted into tail-recursive functions. What is the relationship of this tail-recursive function to the loop invariant?
For example, binary search can be converted into a tail-recursive function as follows:
binary_search.java
Notice that the precondition of the tail-recursive function
search_rec
is exactly the loop invariant, and the postcondition of
the loop is just the postcondition of the function. This is no accident: a loop
invariant is simply the precondition of the corresponding tail-recursive
function. The loop preserves the loop invariant exactly when the precondition
is satisfied for all recursive calls made in the recursive version. In this
example there are two recursive calls.
If we think of a loop as a function whose precondition must be satisfied and whose postcondition must ensure the loop's postcondition is satisfied, that can help us identify the loop invariant.
Loop invariants can help us convince ourselves that our code, especially tricky code, is correct. They also help us develop code that is correct in the first place, and they help us write efficient code.
To use a loop invariant to argue that code does what we want, we use the following steps:
while
(guard) { body }
, the invariant must be true before
beginning the loop. For for-loops for (init; guard;
incr) { body }
, the invariant must be true just after executing
the code init
, which is not properly part of the loop because
it is executed only once.guard
are true just before executing the loop body, then the loop
invariant is true just after executing the loop body. Note that the
loop invariant may fail to be true at intermediate steps during the execution
of the loop body, as long as it is reestablished by the end. (For
for
loops, the loop body also includes the increment
incr
.) guard
is false (so the loop exits)
and the loop invariant holds, then the desired result of the loop
has been achieved: the loop postocondition holds.Other than coming up with the loop invariant in the first place, the Preservation step is typically the most challenging. The Postcondition step is a crucial step too. If the chosen loop invariant is too weak, this step will not be possible. Defining a too-weak invariant is a common mistake, so it is good to try the Postcondition step early.
These three steps allow us to conclude that the loop satisfies partial correctness, which means that if the loop terminates, it will succeed. To show total correctness, we must show in addition that the loop eventually terminates. To show this, there is a fourth step:
Let's try these four steps on the binary search algorithm.
The loop invariant has three clauses; we can argue that each one holds:
a
is sorted is in the precondition of search
.
a.length
is at least 1 by the precondition that k
is in the array, and since initially l = 0
and r = a.length-1
, we have 0 ≤ l ≤ r ≤ a.length-1
.k ∈ a[l..r]
because that is the whole array and the precondition
guarantees that k
is there.Since the loop doesn't change the array, clearly the first clause of the invariant is preserved. Arguing the remaining clauses is a bit trickier.
We use l', r' to represent the values of l, r at the end of the loop.
We want to show that if the invariant is true at the beginning of the loop body,
that is, if 0 ≤ l ≤ r ≤ a.length-1
and k ∈ a[l..r]
,
then clauses 2 and 3 are true at the end of the loop body, that is,
0 ≤ l' ≤ r' ≤ a.length-1
and k ∈ a[l'..r']
.
Note that m is the average of l and r, rounded down. So we
know that l ≤ m ≤ r
. In fact, because the loop guard l < r
is true
and the value of m is rounded down, we know something stronger: l ≤ m < r
.
How the loop body executes depends on the outcome of the test k <= a[m]
. There
are two cases to consider: either k <= a[m]
or k > a[m]
.
We analyze the two cases separately.
Case k <= a[m]
:
In this case we have l' = l
and r' = m
.
Since we have 0 ≤ l ≤ m < r ≤ a.length-1
as just argued,
clause 2 is preserved: 0 ≤ l' ≤ r' ≤ a.length-1
.
How about clause 3? By the loop invariant, we know that k occurs at
least once in a[l..r]
. Since k <= a[m]
,
it must occur at or before index m
. Therefore we have
k∈a[l..m] = a[l'..r']
, as required.
Case k > a[m]
:
Clause 2. In this case, the loop body sets r' = r
and l' =
m+1
. We know m < r
, so l' = m + 1 ≤ r = r'
.
Therefore, we have 0 ≤ l' ≤ r' ≤ a.length-1
Clause 3. Since the array is sorted, k
cannot be
located in a[l..m]
. But according to the invariant, it
is in a[l..r]
. Therefore, k
is in a[m+1..r]
= a[l'..r']
,
as required.
For the algorithm to be correct, we need
a[l] = k
. If the loop guard is false, we know l ≥ r
. But the invariant (2) guarantees l ≤ r
,
so we must have l = r
. We know from the invariant (3) that k ∈ a[l..r]
,
which has been reduced to a single element, so that must be where k is.
The value DF = r − l
is guaranteed by the invariant
(1) to be nonnegative: we can choose DF0 = 0.
In the case where k ∈ a[l..m]
, we know m < r
, so
r' − l' < r − l
. In the other case, we know l < m+1
, so again,
r' − l' < r − l
. Thus the quantity r − l
gets strictly
smaller on every loop iteration as long as l < r
. Therefore,
the loop eventually terminates.
In this case, the loop invariant has three clauses, but it is easy to leave things out of the loop invariant. If clauses are omitted, the invariant may be too weak: Initialization is easier to argue, but it becomes impossible to show Preservation or Postcondition. On the other hand, if the loop invariant is too strong because it contains clauses that shouldn't be there, then Initialization or Preservation become impossible to show.
Let's consider what would have happened had we omitted any of the three clauses from the binary search loop invariant:
l ≤ r
Without this clause, we don't know that we are going to the correct side when we split
on m
. The Termination argument also fails because the decrementing
function is no longer guaranteed to be nonnegative.
k ∈ a[l..r]
Without this clause, we don't know that the loop has found anything when it terminates,
so Postcondition fails.
Here is an implementation of exponentiation that is efficient but whose correctness is not immediately apparent.
Pow.java
Intuitively, this algorithm converts the exponent e into a binary representation, which we can think of as a sum of powers of 2. So e = 2k1 + 2k2 + ··· and xe = x2k1+2k2+··· = x2k1·x2k2···. (Note that x2k always means x(2k), never (x2)k.)
For example, if e = 11010 in binary, then xe = x11010 = x10000+1000+10 = x10000·x1000·x10.
By repeatedly halving y and inspecting the resulting parity, the algorithm finds each of the “1 digits” in the binary representation of e, corresponding to the terms 2ki, and for such a digit at position k, multiplies into r the appropriate factor x2k. That is the intuition, but the loop invariant will help convince us that it really does work. The loop invariant captures that part of the final result has been transferred into r and what remains to be multiplied into the result is by.
Let's consider the four steps outlined above.
Initially, r=1, b=x and y=e, so trivially we have r·by = xe. Also y ≥ 0 since initially y = e and e ≥ 0 as a precondition.
Let us use y', b', and r' to refer to the values of these variables at the end of the loop. We need to show that if y > 0 and r·by = xe at the beginning of the loop body, then y' ≥ 0 and r'·b'y' = xe at the end of the loop body. It suffices to show that if y > 0, then r'·b'y' = r·by and y' ≥ 0. There are two cases to consider:
Case: y is even. In this case, r' = r, y' = y/2, and b'= b2. Therefore, r'·b'y' = r·(b2)y/2 = r·by, as desired.
Case: y is odd. Here we have r'=r·b, y' = (y-1)/2, and b' = b2. Therefore, r'·b'y' = r·b·(b2)(y-1)/2 = r·b·by-1 = r·by, again.
In either case, y' ≥ 0, since halving a positive number cannot make it negative.
If the loop guard is false, then y = 0, since y ≥ 0 and not y > 0. But if y = 0, then r = r·by = xe, and that is the return value.
Dividing by two makes the quantity y strictly smaller on every loop iteration, because it is always nonnegative (this is a part of the loop invariant). It can never become negative, so eventually it will become zero and the loop will terminate.
Therefore, the loop terminates and returns the correct value of xe.
insertion_sort.java
There are two loops, hence two loop invariants. These loop invariants can be visualized with the following diagram:
Notice that the loop invariant holds in for
loops at the point
when the loop guard (i.e., i < a.length) is evaluated, and
not necessarily at the point when the for
statement starts
executing. That is, the initialization expression in the for
statement can help establish the loop invariant.
There are two things that typically go wrong when choosing a loop invariant. The most common error is to define a loop invariant that is too weak. This error usually shows up when trying to take the Postcondition step. If the loop invariant is too weak, we can't argue that the loop achieves whatever it is supposed to. Alternatively, sometimes a too-weak invariant will show up in the Preservation step, because the weak loop invariant does not restrict the state enough to show that even it is preserved by the loop.
If a loop invariant is too strong, that error typically shows up when one tries to prove Preservation: the strong loop invariant simply isn't preserved by executing the loop body. It is also possible but less common for this error to show up in the Establishment step, if the putative loop invariant doesn't even hold at the start of the loop.
Choosing loop invariants is a bit of an art, but becomes easier with practice. The key is that a loop invariant really does capture the core reason why the loop works. So imagine trying to explain to someone why the loop works, and write down conditions that capture that explanation.
Recall that the loop invariant must be implied by the loop precondition and it
must imply the loop postcondition; another way to think about the loop
invariant is that it interpolates between the precondition and postcondition.
It can be helpful to write down both the precondition and the postcondition
together, and then to try to come up with a logical statement that captures
both of them. It should generalize both the precondition and the postcondition,
but be close to the precondition as the loop starts and close to the
postcondition when it ends. For example, in the binary search algorithm, that
we want k = a[l]
at the end and we have k ∈
a[0..a.length-1]
at the beginning. The statement k ∈ a[l..r]
generalizes both of these assertions.
Loop invariants capture key facts that explain why code works. If you write code in which the loop invariant is not obvious, you should add a comment that gives the loop invariant. This helps other programmers understand the code, and helps keep them (and you!) from accidentally breaking the invariant with future changes.
It also makes sense to add assert
statements that for every iteration check
the parts of the loop invariant that are easy to check. Such assertions will catch errors
early and expose problems with your understanding of why the code works.