CMPS 144 Computer Science 2
Searching an Array: Development of the Linear and Binary Search Algorithms

Linear (or Sequential) Search

Here we explore the problem of devising a computer program meeting the following specifications.

Given are two inputs:

  1. a fixed value m of type, say, int, and
  2. a fixed int array a[] in which, it is assumed, m occurs at least once.

Note: By the term fixed we mean simply that the item's value is not to be modified. End of note.

Note: Our choice of int as the data type of m and of the elements in a[] is somewhat arbitrary. Any data type for which the notion of equality exists will do. End of note.

The goal is for the program to assign to k (which can be viewed as the output variable) a value such that a[k] = m and such that no element in a[0..k) is equal to m. In other words, the final value of k should indicate the location of the first occurrence of m in a[].

As an example, suppose we are given as input m having value 5 and a[] as follows:

    0   1   2   3   4   5   6   7
  +---+---+---+---+---+---+---+---+
a | 2 | 9 | 0 | 5 | 0 | 2 | 5 | 7 |
  +---+---+---+---+---+---+---+---+

Then the program should terminate with k having value 3, as the first occurrence of m (i.e., 5) in a[] is at location 3.

In order to express the desired behavior of a program (or subprogram, or code segment) somewhat more formally, we use pre- and post-conditions. In this case, we could write them as follows:

pre: a = A  ∧  m = M  ∧  m occurs in a[0..a.length)
post: a = A  ∧  m = M  ∧  a[k] = m  ∧  ¬(m occurs in a[0..k))

Note: A and M here are not variables appearing in the program; rather, they are specification constants (also referred to as rigid variables) whose purpose is to refer to the initial values of a and m, respectively. That is, you may think of a = A in the pre-condition as attaching to A the initial value of a, which then makes a = A in the postcondition mean, in effect, that the final value of a is required to be the same as its initial value. The first two conjuncts of the pre- and post-condition, then, serve to specify that both a and m are "fixed". End of note.

Often it is helpful to draw pictures. Using N as an abbreviation for a.length, the postcondition can be depicted as

   0               k             N
  +---------------+-+-----------+
a |      != m     |m|    ?      |
  +---------------+-+-----------+

The notation "!= m" labeling the array segment a[0..k) is intended to mean that, for each element in that segment, it is not equal to m. The question mark in segment a(k..N) means that we are asserting nothing about the values there.

Now let us attempt to develop a program to solve this problem. It should be clear that, in order to find an occurrence of m in a[], it will be necessary to examine elements in a[] at least until we hit upon one whose value is m. This strongly suggests that a loop is called for.

For some guidance as to what the loop's invariant might be, we turn to the post-condition. (After all, a loop's invariant is typically just a weakened version of its post-condition.) Omitting the conjuncts that convey the fact that a[] and m are fixed, the post-condition is

a[k] = m   ∧   ¬(m occurs in a[0..k))

Let's choose one of the conjuncts as the loop invariant and make the negation of the other be the loop guard. (This well-known "heuristic" is called deleting a conjunct, referring to the fact that the invariant is obtained from the post-condition by removing one of the latter's conjuncts.) Letting I, B, and Q refer to the loop invariant, loop guard, and post-condition, respectively, such a choice guarantees that I∧¬B = Q, which ensures that Q will hold when the loop terminates. (Recall that all we really need is the weaker I ∧ ¬B ⇒ Q.)

Which of the two conjuncts —a[k] = m   or   ¬(m occurs in a[0..k))— ought we to choose for the invariant? If we choose the former, the initialization code preceding the loop will have the responsibility to assign to k a value such that a[k] = m. As we have no idea at which location(s) m occurs in a[], this would seem to be a rather tall order! Indeed, finding a value for k to satisfy a[k] = m would seem to be the crux of the entire problem, so it only makes sense that the loop, rather than the initialization code preceding the loop, should have the responsibility to solve it.

If, on the other hand, we choose the second conjunct — ¬(m occurs in a[0..k)) — as the loop invariant, initializing k to truthify the invariant is easy: set k to zero. (Obviously, neither m nor any other value occurs in a[0..0), which is an empty segment.) Taking the negation of the first conjunct as our loop guard, we get

k = 0;
// loop invariant I: ¬(m occurs in a[0..k)) 
while (a[k] != m) { 
   ?
}
// post-condition Q: a[k] = m  ∧ ¬(m occurs in a[0..k-1])

It remains to fill in the body of the loop. A loop body has two responsibilities: its execution must preserve the truth of the loop invariant and make progress towards termination.

In our case, termination occurs when k points to an element in a[] having value m, of course. One could reasonably argue that a loop iteration has made progress towards termination if, during its execution, the value of k moved closer to K, where K points to the lowest-numbered location in a[] containing m.

According to the loop invariant, m fails to occur in the segment a[0..k), so reducing the value of k would move its value farther away from K, the opposite of making progress. It follows that the loop body should have the effect of increasing the value of k, which would mean assigning to it a value in the range [k+1..N). (Values N and above are no good, as they are outside a's index range.) But among the values in this range, the only one we could assign to k, while still being sure that the loop invariant holds, is k+1. After all, the loop invariant tells us that m fails to occur in a[0..k), and the loop guard tells us that a[k] != m (or else we would not be executing the loop body!).

From these two facts, it follows that m fails to occur in a[0..k], which means that, if we then increment k, it will still be the case that m fails to occur in a[0..k).

On the other hand, if we were to increase k by more than one, it's possible that m occurs among the element(s) that we "skipped over", making the loop invariant false.

Our completed program is

k = 0;
// loop invariant I: ¬(m occurs in a[0..k-1]) 
while (a[k] != m) { 
   k = k+1; 
}
// post-condition Q: a[k] = m  ∧ ¬(m occurs in a[0..k))

Suppose that we were to generalize the problem to allow for the possibility that m does not occur in a[]. A reasonable post-condition would be that, as before, there are no occurrences of m in a[0..k) and that, in addition, either k = N or a[k] = m.

More formally, we could write this as follows:

pre: a = A ∧ m = M
post: a = A ∧ m = M ∧ ¬(m occurs in a[0..k)) ∧ (k=N ∨ a[k]=m)

As before, we choose ¬(m occurs in a[0..k)) as the loop invariant. The negation of k=N ∨ a[k]=m is chosen as the loop guard. The resulting program is

k = 0;
// loop invariant I: ¬(m occurs in a[0..k-1]) 
while !(k == N  ||  a[k] == m)  {
   k = k+1;
}

The reader adept at propositional logic will be able to show that I ∧ ¬B ⇒ Q, i.e., if the loop invariant holds but the loop guard doesn't, then the post-condition must hold.

Note: This code assumes that, as in Java, if the first disjunct of a disjunction is found to be true, the second disjunct will not even be evaluated. (Here, it means that a[k] == m will not be evaluated unless k==N was found to be false, so as to avoid an array-index-out-of-range error from occurring.) End of note.


Binary Search

Let us change the problem again, but this time in a more substantive way. This time, we strengthen the pre-condition to say that the elements in a[0..N) must be in ascending order (meaning that, for any two consecutive elements, the second must be at least as large as the first). (As before, N is an abbreviation for a.length.) As immediately above, we omit the requirement that m occurs in a[]. To state the problem:

Given are two inputs:

  1. a fixed int value m.
  2. a fixed int array a[0..N) the elements of which are in ascending order.

The goal is to assign to k a value such that all elements in a[0..k) are less than m and none of those in a[k..N) are less than m. (The fact that the elements in a[] are in ascending order makes this an achievable goal.) One way to express this somewhat more formally as a pre- and post-condition is the following:

pre: a = A ∧ m = M ∧ for all i satisfying 0≤i<N-1, a[i] ≤ a[i+1]
post: a = A ∧ m = M ∧ a[0..k) < m ∧ a[k..N) ≥ m

Note: The third conjunct in the precondition says simply that the elements in a[] are in ascending order. The expression "a[0..k) < m" is to be understood as an abbreviation for the statement "for all j satisfying 0≤j<k, a[j] < m". Of course, "a[k..N) ≥ m" should be interpreted similarly. End of note.

Often it is helpful to draw pictures. The postcondition can be depicted as

   0               k                  N
  +---------------+------------------+
a |      < m      |      >= m        |
  +---------------+------------------+

It's pretty obvious that we are going to need a loop to solve this problem. To obtain a potential loop invariant, we examine the (two interesting) conjuncts of the postcondition, a[0..k) < m   and   a[k..N) ≥ m. Suppose that we were to replace the first occurrence of k by a "fresh" variable j but also add the new conjunct j = k. Clearly, the resulting predicate is stronger than that with which we started (meaning that, in any state in which the latter holds, so will the former). "Deleting" the new conjunct (in accord with the heuristic described earlier), we get as a possible loop invariant

a[0..j) < m   ∧   a[k..N) ≥ m
which, as a picture, looks like

   0         j             k            N
  +---------+-------------+------------+
a |   < m   |      ?      |    >= m    |
  +---------+-------------+------------+

Note: The picture suggests that j ≤ k, but can we really be sure of that? Yes, because otherwise a[k..j) would be a non-empty segment each of whose elements would have to be both less than m (to satisfy the first conjunct of the invariant) and greater than or equal to m (to satisfy the second conjunct). Clearly, this is impossible. End of note.

For the loop guard, we choose the negation of the deleted conjunct, which is j != k. We have

// pre: a = A  ∧  m = M  ∧  
//      for all i satisfying 0≤i<N-1, a[i] ≤ a[i+1]
< initialization code >
// loop invariant: a[0..j-1] < m  ∧  a[k..N-1] ≥ m 
while (j != k) {
   < loop body >
}
// post: a = A  ∧  m = M  ∧  a[0..j) < m  ∧  
//       a[k..N) ≥ m  ∧  j = k 

The initialization code calls for values to be assigned to j and k so as to truthify the loop invariant. Without examining any array elements, the only way to be sure that every element in segment a[0..j) is less than m (as prescribed by the first conjunct of the loop invariant) is to make it an empty segment by setting j to zero. For similar reasons, to ensure that the second conjunct of the loop invariant holds, we should set k to N.

Now consider what ought to be done during execution of the body of the loop. Because the loop terminates when j = k, a reasonable measure of how "far" it is from termination is the distance between j and k. Thus, to make progress towards termination, either j ought to be increased or k ought to be decreased, or both. (Recall from the note above that we can be sure that j ≤ k.)

This observation leads us to ask the following question: If i is in the range [j..k), under what conditions would we be justified in changing k's value to i? (Of course, we could have asked the analogous question about j.) The answer is: whenever a[i] ≥ m! That is, if we were to ascertain that a[i] ≥ m, we could be sure that every element in a[i..N) is greater than or equal to m, by a's being in ascending order. Hence, setting k to i would preserve the truth of the loop invariant. On the other hand, if we found that a[i] < m, we could safely set j to i+1, knowing that every element in a[0..i] must be less than m.

Notice that the reasoning we just used relies upon including the third conjunct of the precondition (which says that the elements of a[] are in ascending order) as one of the conjuncts of the loop invariant. Because a[] remains fixed, we are justified in doing this.

We now have

// pre: a = A  ∧  m = M  ∧  
//      for all i satisfying 0≤i<N-1, a[i] ≤ a[i+1] 
j = 0;  k = N;
// loop invariant: a[0..j) < m  ∧  a[k..N) ≥ m 
while (j != k) {
   i = < some value in range [j..k) >
   if (a[i] >= m) { 
      k = i;
   }
   else {  // a[i] < m
      j = i+1;
   }
}
// post: a = A  ∧  m = M  ∧  
//       a[0..j) < m  ∧  a[k..N) ≥ m  ∧  j = k

All that remains is to determine to what value to set i at the beginning of each loop interation. If we use j, we end up with the linear search algorithm derived earlier. If we use k-1, we get the right-to-left linear search algorithm. Either way, our program will have to do N loop iterations in the worst case (that being when j and k end up meeting at the opposite end of the array from where the search began).

If, on the other hand, we set i to the value half-way between j and k, we guarantee that, regardless of which branch of the if statement is executed, the distance between j and k will be cut in half (or slightly more). It follows that j and k will meet (and hence the loop will terminate) after at most ⌊lg N + 1⌋ iterations (By lg N we mean the logarithm to the base 2 of N), which is a small number, even for large N.

We get our final program by refining the assignment to i to read

i = (j + k) / 2;

Also, it is traditional to use the more descriptive names low, high, and mid, respectively, in place of j, k, and i. The resulting program is

// pre: a = A  ∧  m = M  ∧  
//      for all i satisfying 0≤i<N-1, a[i] ≤ a[i+1] 
low = 0;  high = N;
// loop invariant: a[0..low) < m  ∧  a[high..N) ≥ m 
while (low != high) {
   mid = (low + high) / 2;
   if (a[mid] >= m) { 
      high = mid;
   }
   else {  // a[mid] < m
      low = mid+1;
   }
}
// post: a = A  ∧  m = M  ∧  
//       a[0..low) < m  ∧  a[high..N) ≥ m  ∧  low = high