16. Introduction to Algorithms

An algorithm is, essentially, a step-by-step recipe for solving a problem. In computer science, the study of algorithms is one of the biggest and most important topics of study. There are many questions we can ask about algorithms, such as:

  • Is it correct? Does it work in all cases, or just in some?
  • Is it efficient? Does it run quickly in all cases? Does it use a lot of memory?
  • Can it be made to run efficiently on multiple CPUs?
  • Is it easy to extend and modify?

In this course we are going to consider mainly the first and second questions.

In the notes that follow we will study the performance of some basic (but important!) algorithms from both a theoretical and empirical point of view.

16.3. Pre and Post Conditions

Some computer scientists advocate proving a program is correct instead of testing it. The idea is that a proof can give you a 100% guarantee of correctness, at least in theory. However, this idea has yet to catch on in practice since proving even small programs correct is extremely difficult and time-consuming. Plus proofs don’t catch things like typing errors, or bad inputs. Furthermore, program correctness proofs can be so complex that there is real doubt as to the correctness of the proofs themselves!

Nonetheless, for some functions trying to construct a proof of its correctness can be quite useful. It forces you to analyze the code carefully and to be explicit about what every step does. That level of formal checking is often a good way to find bugs and create nasty test cases, even if you don’t end up finishing a formal proof.

It is often help to write pre-conditions and post-conditions for a function. Pre-conditions list what must be true before the function is called, and post- conditions list what will be true after it finishes executing. Together, they form a contract between the function and the rest of the program. They also help document the behaviour of a function.

Here’s how we might write pre and post conditions for linear_search:

// Pre:
// Post: returns i such that 0 <= i < v.size() and x == v[i];
//       if there is no such i, then -1 is returned
int linear_search(int x, const vector<int>& v)

Here there is no explicit pre-condition: nothing in particular needs to be true before you call linear_search. That’s good, because it means linear_search is widely applicable.

The post-condition carefully describes what possible values might be returned. It is written at a level of detail similar to a mathematical proof. For instance, the post-condition says nothing about what specific index will be returned when there happen to be multiple copies of x in the vector. All it says is that an index for one of them will be returned. The value of this sort of vagueness is that it lets us implement algorithms in different ways, e.g. linear search could search left-to-right, or right-to-left, or from the middle outwards.

Pre and post conditions form a contract between the function and the code that calls it. The function is saying that as long as the pre-condition is true when it is called, it promises that the post-condition will be true after it terminates. If the pre-condition is not true, then that means that some code before the function must be doing something wrong. But if the post- condition is violated, then that means something in the function itself is wrong. Thus pre and post conditions can help narrow down your search for bugs.

For many of the tricky functions we write from now on, we will include pre and post conditions.

16.6. Testing Our Theory Empirically

The above analysis of linear search was purely theoretical. It could turn out that it is wrong in practice: maybe we made a mistake in the analysis, such as a math error or a wrong assumption, or maybe we just overlooked some important detail.

So lets check our theory by writing some code that actually counts the number of comparisons linear_search does under the two assumptions above. If the results don’t match pretty closely with the theory then we know that something — probably the theory, because it seems more complicated than the algorithm — is wrong and needs to be revised.

We’ll do this using a modified version of linear search:

// Returns the number of comparisons done by linear search.
int linear_search_comp_count(int x, const vector<int>& v) {
  int count = 0;
  for(int i = 0; i < v.size(); ++i) {
    ++count;
    if (x == v[i]) {
      return count;
    }
  }
  return count;
}

Instead of returning the index location of x in v, this returns the number of comparisons done by linear search to determine if x is v or not.

Next, we need a way to create random vectors of varying length. One way to do this is as follows:

// returns a new vector with {0, 1, 2, ..., n - 1} scrambled
vector<int> random_vec(int n) {
  vector<int> result(n);
  for(int i = 0; i < n; ++i) {
    result[i] = i;
  }
  random_shuffle(result.begin(), result.end());
  return result;
}

The returned vector is a random permutation of the values 0 to n - 1.

Now we need a function to run linear search many times:

int do_one_trial(int size = 1000) {
  vector<int> v(random_vec(size));
  int target;
  if (randint(2) == 0) {
    target = 5;   // 5 is guaranteed to be in v, although we don't know where
  } else {
    target = -1;  // -1 is guaranteed to never be in v
  }

  int num_comps = linear_search_comp_count(target, v);
  return num_comps;
}


void test_linear_search(int trials = 1000, int size = 1000) {
  int total = 0;
  for(int i = 0; i < trials; ++i) {
    total += do_one_trial(size);
  }
  cout << "           # of trials: " << trials << endl
       << "Total # of comparisons: " << total << endl
       << "  Avg. comps per trial: " << double(total) / trials << endl;
}

Calling test_linear_search(10000) runs linear search ten thousand times on randomly ordered arrays of 1000 elements:

$ ./linear_search
           # of trials: 10000
Total # of comparisons: 7515656
  Avg. comps per trial: 751.566

In this run we see that about 752 comparisons are done, on average, for each call to linear_search on an array with 1000 elements. Compare this to our theoretical calculations say that \frac{3n}{4} comparisons are done, on average, for an array of n elements: if n = 1000, then the theory says \frac{3\cdot 1000}{4} = 750, which is extremely close to our experimental results.