Lecture Notes On Binary Search: 15-122: Principles of Imperative Computation Frank Pfenning August 31, 2010
Lecture Notes On Binary Search: 15-122: Principles of Imperative Computation Frank Pfenning August 31, 2010
Lecture Notes On Binary Search: 15-122: Principles of Imperative Computation Frank Pfenning August 31, 2010
Binary Search
Lecture 3
August 31, 2010
1 Introduction
One of the fundamental and recurring problems in computer science is to
find elements in collections, such as elements in sets. An important algo-
rithm for this problem is binary search. We use binary search for an integer
in a sorted array to exemplify it.
Binary search is the first time we see the fundamental principle of divide-
and-conquer. We will see many other examples of this important principle
in future lectures. It refers to the idea of subdividing a given problem into
smaller components, each of which can be solved separately. We then com-
bine the results to obtain a solution to the original problem.
We will also once again see the importance of loop invariants in writing
correct code. Here is a note by Jon Bentley about binary search:
I’ve assigned [binary search] in courses at Bell Labs and IBM. Profes-
sional programmers had a couple of hours to convert [its] description
into a program in the language of their choice; a high-level pseudocode
was fine. At the end of the specified time, almost all the programmers
reported that they had correct code for the task. We would then take
thirty minutes to examine their code, which the programmers did with
test cases. In several classes and with over a hundred programmers,
the results varied little: ninety percent of the programmers found bugs
in their programs (and I wasn’t always convinced of the correctness of
the code in which no bugs were found).
I was amazed: given ample time, only about ten percent of profes-
sional programmers were able to get this small program right. But
they aren’t the only ones to find this task difficult: in the history in
Section 6.2.1 of his Sorting and Searching, Knuth points out that
while the first binary search was published in 1946, the first published
binary search without bugs did not appear until 1962.
—Jon Bentley, Programming Pearls (1st edition), pp.35–36
I contend that what these programmers are missing is the understanding
of how to use loop invariants in composing their programs. They help
us to make assumptions explicit and clarify the reasons why a particular
program is correct. Part of the magic of pre- and post-conditions as well as
loop invariants and assertions is that they localize reasoning. Rather than
having to look at the whole program, or the whole function, we can focus
on individual statements tracking properties via the loop invariants and
assertions.
Before we introduce binary search, we discuss linear search.
3 Sorted Arrays
A number of algorithms on arrays would like to assume that they are sorted.
We begin with a specification of this property. The function is_sorted(A,n)
traverses the array A from left to right, checking that each element is smaller
or equal to its right neighbor. We need to be careful about the loop invari-
ant to guarantee that there will be no attempt to access a memory element
out of bounds.
Loop Traversal: Assume the loop invariant holds before the test, so either
n = 0 or 0 ≤ i ≤ n − 1. Because we do not exit the loop, we also have
i < n − 1. The step statement in the loop increments i so we have
i0 = i + 1.
Since i0 = i + 1 and 0 ≤ i we have 0 ≤ i0 . Also, since i < n − 1 and
i0 = i + 1 we have i0 − 1 < n − 1 and so i0 < n. Therefore i ≤ n − 1.
So 0 ≤ i0 ≤ n − 1 and the loop invariant is still satisfied because the
right disjunct is true for the new value i0 of i.
This does not exploit that the array is sorted. We would like to exit the
loop and return −1 as soon as we find that A[i] > x. If we haven’t found x
already, we will not find it subsequently since all elements to the right of i
will be greater or equal to A[i] and therefore strictly greater than x. But we
have to be careful: the following program has a bug.
Can you spot the problem? If you cannot spot it immediately, reason
through the loop invariant. Read on if you are confident in your answer.
Now A[i] <= x will only be evaluated if i < n and the access will be in
bounds since we also know 0 ≤ i from the loop invariant.
This program is not yet satisfactory, because the loop invariant does not
have enough information to prove the postcondition. We do know that if we
return directly from inside the loop, that A[i] = x and so A[\result] == x
holds. But we cannot deduce that !is_in(x, A, n) if we return −1.
Before you read on, consider which loop invariant you might add to
guarantee that. Try to reason why the fact that the exit condition must
be false and the loop invariant true is enough information to know that
!is_in(x, A, n) holds.
Did you try to exploit that the array is sorted? If not, then your invariant
is most likely too weak, because the function is incorrect if the array is not
sorted!
What we want to say is that all elements in A to the left of index i are smaller
than x. Just saying A[i-1] < x isn’t quite right, because when the loop is
entered the first time we have i = 0 and we would try to access A[−1]. We
again exploit shirt-circuiting evaluation, this time for disjunction
is almost exactly 3 ∗ n in the worst case. We can express this by saying that
the running time is linear in the size of the input (n). This allows us to pre-
dict the running time pretty accurately. We run it for some reasonably large
n and measure its time. Doubling the size of the input n0 = 2 ∗ n mean that
now we perform 3 ∗ n0 = 3 ∗ 2 ∗ n = 2 ∗ (3 ∗ n) operations, twice as many as
for n inputs.
We will introduce more abstract measurements for the running times in
the next lecture.
6 Binary Search
Can we do better than searching through the array linearly? If you don’t
know the answer already it might be surprising that, yes, we can do signif-
icantly better! Perhaps almost equally surprising is that the code is almost
as short!
Before we write the code, let us describe the algorithm. We start by
examining the middle element of the array. If it smaller than x than x must
be in the upper half of the array (if it is there at all); if is greater than x then
it must be in the lower half. Now we continue by restricting our attention
to either the upper or lower half, again finding the middle element and
proceeding as before.
We stop if we either find x, or if the size of the subarray shrinks to zero,
in which case x cannot be in the array.
Before we write a program to implement this algorithm, let us analyze
the running time. Assume for the moment that the size of the array is a
power of 2, say 2k . Each time around the loop, when we examine the mid-
dle element, we cut the size of the subarrays we look at in half. So before the
first iteration the size of the subarray of interest is 2k . After the second iter-
ation it is of size 2k−1 , then 2k−2 , etc. After k iterations it will be 2k−k = 1,
so we stop after the next iteration. Altogether we can have at most k + 1
iterations. Within each iteration, we perform a constant amount of work:
computing the midpoint, and a few comparisons. So, overall, when given
a size of array n we perform c ∗ log2 (n) operations.1
If the size n is not a power of 2, then we can round n up to the next
power of 2, and the reasoning above still applies. The actual number of
1
In general in computer science, we are mostly interested in logarithm to the base 2 so
we will just write log(n) for log to the base 2 from now on unless we are considering a
different base.
steps can only be smaller than this bound, because some of the actual subin-
tervals may be smaller than the bound we obtained when rounding up n.
The logarithm grows much slower than the linear function that we ob-
tained when analyzing linear search. As before, consider that we are dou-
bling the size of the input, n0 = 2 ∗ n. Then the number of operations will be
c ∗ log(2 ∗ n) = c ∗ (log(2) + log(n)) = c ∗ (1 + log(n)) = c + c ∗ log(n). So the
number of operations increases only by a constant amount c when we dou-
ble the size of the input. Considering that the largest representable positive
number in two’s complement representation is 231 − 1 (about 2 billion) bi-
nary search even for unreasonably large arrays will only traverse the loop
31 times! So the maximal number of operations is effectively bounded by a
constant if it is logarithmic.
We have two variables, lower and upper, which hold the lower and upper
end of the subinterval in the array that we are considering. We start with
lower as 0 and upper as n, so the interval includes lower and excludes
upper. This often turns out to be a convenient choice when computing
with arrays.
The for loop from linear search becomes a while loop, exiting when
the interval has size zero, that is, lower == upper. We can easily write the
first loop invariant, relating lower and upper to each other and the overall
bound of the array.
In the body of the loop, we first compute the midpoint mid. Then we
check if A[mid ] = x. If so, we have found the element and return mid .
{ int lower = 0;
int upper = n;
while (lower < upper)
//@loop_invariant 0 <= lower && lower <= upper && upper <= n;
//@loop_invariant ...??...
{ int mid = (lower + upper)/2;
if (A[mid] == x) return mid;
// ...??...
}
return -1;
}
Now comes the hard part. What is the missing part of the invariant?
The first instinct might be to say that x should be in the interval from
A[lower ] to A[upper ]. But that may not even be true when the loop is en-
tered the first time. Looking back at linear search we notice that the invari-
ant was somewhat different: we expressed that x could not be outside of
the chosen interval. We say that here by saying that A[lower − 1] < x and
A[upper ] > x. The asymmetry arises because the interval under considera-
tion includes A[lower ] but excludes A[upper ].
As in linear search, we have to worry about the boundary condition
when lower = 0 or upper = n, in which case we have not yet excluded any
part of the array. And, again, we use disjunction and exploit short-circuit
evaluation to put these together.
int binsearch(int x, int[] A, int n)
//@requires 0 <= n && n <= \length(A);
//@requires is_sorted(A,n);
//@ensures (\result == -1 && !is_in(x, A, n)) || A[\result] == x;
{ int lower = 0;
int upper = n;
while (lower < upper)
//@loop_invariant 0 <= lower && lower <= upper && upper <= n;
//@loop_invariant (lower == 0 || A[lower-1] < x);
//@loop_invariant (upper == n || A[upper] > x);
{ int mid = (lower+upper)/2;
if (A[mid] == x) return mid;
// ...??...
}
return -1;
}
At this point, let’s check if the loop invariant is strong enough to imply
the postcondition of the function. If we return from inside the loop because
A[mid ] = x we return mid , so A[\result] == x as required.
If we exit the loop because lower < upper is false, we know lower =
upper , by the first loop invariant. Now we have to distinguish some cases.
1. If A[lower −1] < x and A[upper ] > x, then A[lower ] > x (since lower =
upper ). Because the array is sorted, x cannot be in it.
Notice that we could verify all this without even knowing the complete
program! As long as we can finish the loop to preserve the invariant and
terminate, we will have a correct implementation! This would again be a
good point for you to interrupt your reading and to try to complete the
loop, reasoning from the invariant.
Does this function terminate? If proceed to the loop body, that is, lower <
upper , then the interval from lower to upper is non-empty. Moreover, the
intervals from lower to mid and from mid + 1 to upper are both strictly
smaller than the original interval. Unless we find the element, the differ-
ence between upper and lower must eventually become 0 and we exit the
loop.
Where you able to see it? It’s subtle, but somewhat similar to the prob-
lem we had in our very first example, the integer square root. Where we
compute
9 Some Measurements
Algorithm design is an interesting mix between mathematics and an ex-
perimental science. Our analysis above, albeit somewhat preliminary in
2
see Joshua Bloch’s Extra, Extra blog entry
When running linear search 2000 times (1000 elements in the array and 1000
random elements) on 218 elements (256 K elements) we get the following
answer
The running times are fairly close to doubling consistently. Due to mem-
ory locality effects and other overheads, for larger arrays we would expect
larger numbers.
Running the same experiments with binary search we get