Programming I, 2018-9
Lecture 5 – Notes

type declarations

A type declaration introduces a new name for a type:

type
  int = integer;
  array3 = array[1..3] of integer;
  matrix = array of array of integer;

var
  i: int;
  a: array3;
  m: matrix;

A type declaration conveniently lets us avoid have to type the name of a long type such as array of array of integer over and over again.

Also, type declarations are sometimes necessary if we wish to pass certain parameter types to functions, as we will see in the next section.

passing arrays to functions (continued)

We will often want to pass an array to a function. We discussed this topic briefly last week, but will now take a closer look at it.

Suppose that we want to pass a static array or multidimensional array to a function. Surprisingly, the following declarations will fail to compile:

function sum(a: array[1..5] of integer): integer;     // DOES NOT COMPILE

function sum(a: array of array of integer): integer;  // DOES NOT COMPILE

An array parameter to a function or procedure in Pascal must be either of a named type, or must be an open array.

To use a named type, simply declare a type name as described in the previous section:

type
array5 = array[1..5] of integer;
matrix = array of array of integer;

And now you can declare functions taking arrays of these types:

function sum(a: array5): integer;

function sum(a: matrix): integer;

Alternately, a function or procedure parameter can have an array type with no bounds:

function sum(a: array of integer): integer;

This looks like a dynamic array type, but in this context this is an open array parameter. You can pass either a static or dynamic array to a function that expects an open array.

Just like dynamic arrays, open arrays are always indexed starting from 0, even if their source array has a different indexing base! For example:

procedure first(a: array of integer);
begin
  writeln('low = ', low(a), ', high = ', high(a), ', first = ', a[0]);
end;

var
  a: array[1..5] of integer = (2, 4, 6, 8, 10);

begin
  first(a);
  ...

This program will print

low = 0, high = 4, first = 2

This behavior might seem odd, but arguably it is useful, because you can write a function that operates on an open array without worrying about the indexing base of the array that is passed. For example:

function sum(a: array of integer): integer;
var
  s: integer = 0;
  i: integer;
begin
  for i := 0 to high(a) do
    s := s + a[i];
  exit(s);
end;

var
  b: array[7..10] of integer = (1, 2, 3, 4);

begin
  writeln(sum(b));
end.

This program prints 10. The function sum works fine on the array b, even though b is indexed from 7.

const parameters

As we discussed in the previous lecture, Pascal passes arguments either by value, which is the default, or by reference, when a parameter is preceded with the var keyword. When an array is passed by value, the entire array is copied.

Consider again the sum function from the previous section. Its declaration looks like this:

function sum(a: array of integer): integer;

In this declaration, should we write var before the parameter name a? This is a bit of a dilemma. If we don't write var, the array is copied on every call. This is wasteful and is potentially expensive if the input array is large. But writing var seems odd, since the function does not need to modify the input array.

We can resolve the dilemma by using the const keyword:

function sum(const a: array of integer): integer;

When we pass an array with const, it is not copied, and the function cannot modify the array. That is exactly what we want in this situation.

As a rule of thumb, when we pass an array to a function in Pascal, we will almost always use either the var keyword (if we want the function to modify the array) or the const keyword (if the function will only read the array). Without these keywords, the array will be copied on every call, which is a behavior we will want only rarely.

binary search

Last week we learned how to sort an array using the bubble sort algorithm. After sorting an array a of length n, all elements are in order:

a[0] ≤ a[1] ≤ a[2] ≤ … ≤ a[n - 1]

We may wish to search for an integer (sometimes called the “key”) in a sorted array. Here is a naive sequential search:

function contains(const a: array of integer; key: integer): boolean;
  var
    v: integer;
  begin
    for v in a do
      if v = key then
        exit(true);

    exit(false);
  end;

Unfortunately this can be inefficient if the input array is large. We can use an algorithm called binary search to find the key much more efficiently.

Binary search is related to the number-guessing game that we saw in an earlier lecture. Recall that in that game, one player thinks of a number N, say between 1 and 1,000,000. If we are trying to guess it, we can first ask "Is N greater than, less than, or equal to 500,000?" If we learn that N is greater, then we now know that N is between 500,001 and 1,000,000. In our next guess we can ask to compare N to 750,000. By always guessing at the midpoint of the unknown interval, we can divide the interval in half at each step and find N in a small number of guesses.

Similarly, suppose that we are searching for the key in a sorted array of 1,000,000 elements from a[0] through a[999,999]. We can first compare the key to a[500,000]. If it is not equal, then we know which half of the array contains the key. We can repeat this process until the key is found.

To implement this in Pascal code, we will use two integer variables lo and hi that keep track of the current unknown range. Initially lo = -1 and hi = length(a). At all times, we know that all array elements with indices <= lo are less than the key, and all elements with indices >= hi are greater than the key. That means that the key, if it exists in the array, must be in the range a[lo + 1] … a[hi – 1]. As the binary search progresses, lo and hi move toward each other. If eventually hi = lo + 1, then the unknown range is empty. This means that the key must not exist in the array, and we can return false.

Here is a binary search in Pascal:

function contains(const a: array of integer; key: integer): boolean;
  var
    lo, hi, mid: integer;
  begin
    lo := -1;
    hi := length(a);
    while hi – lo > 1 do
      begin
        mid := (lo + hi) div 2;
        if a[mid] = key then
          exit(true);
        if a[mid] < key then
          lo := mid;
        else  // a[mid] > key
          hi := mid;
      end;
    exit(false);  // not found
  end;

Suppose that the input array has 1,000,000,000 elements. How many times might this loop iterate?

After the first iteration, the unknown interval (hi – lo) has approximately 500,000,000 elements. After two iterations, it has 250,000,000 elements, and so on. After k iterations it has 1,000,000,000 / 2k elements. We reach the end of the loop when 1,000,000,000 / 2k = 1, so k = log2(1,000,000,000).

We learned in the first lecture that 230 = 1 G ≈ 1,000,000,000. So log2(1,000,000,000) ≈ 30. This means that the loop will iterate only about 30 times, which will take only a few microseconds at most.

big-O notation and running time analysis

We would now like to consider how long our programs or functions take to run as a function of their input size.

As a first example, consider this function, which adds the integers from 1 to n:

function sum(n: integer): integer;
var
  s: integer = 0;
  i: integer;
begin
  for i := 1 to n do
    s += i;
  exit(s);
end;

Here the input size is n. The function will run quickly when n is small, but as n increases the function's running time will certainly increase. How can we quantify this?

Here is the same function again, with comments indicating how many times each line in the function will run:

function sum(n: integer): integer;
var
  s: integer = 0;     // 1
  i: integer;
begin
  for i := 1 to n do
    s += i;           // n
  exit(s);            // 1
end;

Suppose that the first commented statement above runs in time a, the second runs in time b and the third runs in time c, where a, b, and c are constants representing numbers of nanoseconds. Then the total running time is b n + (a + c).

In theoretical computer science we don't care at all about the constant factors in an expression such as this. They are dependent on the particular machine on which a program runs, and may not even be so easy to determine. We also don't care about the constant term (a + c). That's because as n grows, the constant term is dominated by the linear term b n, which becomes larger by an arbitrary factor. To be more precise, limn→∞ [b n / (a + c)] = ∞.

So we write:

running time = b n + (a + c) = O(n)

Here, the O is sometimes pronounced "big O". We could give a formal mathematical definition of big O, but informally it looks like the form of an expression after discarding all constant factors and lower-order terms which are dominated by the highest-order term. For example:

3 n + 4 = O(n)

5 n2 + 6 n + 4 = O(n2)

2 n + log(n) = O(n)

2n + 5 n4 + n = O(2n)

To know which lower-order terms we can discard we must know which functions grow faster than others. Here is a table of common growth rates in order from smallest to largest:


notation

name

O(1)

constant

O(log n)

logarithmic

O(n)

linear

O(n log n)

log linear

O(n2)

quadratic

O(n3)

cubic

O(nk)

polynomial

O(kn)

exponential

O(n!)

factorial

running time of various algorithms

Let's consider the running time of various algorithms that we have already studied in this course.

primality testing

First let's look at primality testing using trial division, which we saw a couple of lectures ago:

function isPrime(n: integer): boolean;
var
  i: integer;
begin
  for i := 2 to trunc(sqrt(n)) do
    if n mod i = 0 then
      exit(false);
  exit(true);
end;

Notice that for this algorithm some values of n are harder than others. For example, if n is even then the function will exit immediately, even if n is very large. On the other hand, if n is prime then the function will need to iterate all the way up to sqrt(n) in order to determine its primality.

In general we are interested in determining an algorithm's best-case and worst-case running times as the input size n increases. For some algorithms these will be the same, and for others they will be different. The best-case running time is a lower bound on how long it might take the algorithm to run, and the worst-case running time is an upper bound.

Let's annotate the function above with the number of times each statement might run:

function isPrime(n: integer): boolean;
var
  i: integer;
begin
  for i := 2 to trunc(sqrt(n)) do
    if n mod i = 0 then  // min = 1, max = sqrt(n) - 1
      exit(false);  // 1
  exit(true);  // 1
end;

The statement 'if n mod i = 0' might run only once, or as many as sqrt(n) – 1 times. This function runs in O(1) in the best case, and in O(sqrt(n)) in the worst case.

bubble sort

Now let's consider a bubble sort:

procedure bubbleSort(var a: array of integer);
var
  i, j: integer;
begin
  for i := high(a) - 1 downto 0 do
    for j := 0 to i do
      if a[j] > a[j + 1] then
        swap(a[j], a[j + 1]);
end;

Analyzing this procedure's running time takes a bit more thought. Let N be the length of the input array a; this is the input size. On the first iteration of the outer for loop, i = high(a) – 1 = N – 2. So j iterates from 0 to N – 2, a total of (N – 1) different values. That means that the if statement runs (N – 1) times.

On the second iteration of the outer loop, i = N – 3. So j iterates from 0 to N – 3, a total of (N – 2) different values, and the if statement runs (N – 2) times.

And so on. On the last outer loop iteration, the if statement runs only once. The total number of times that the if statement runs is

(N – 1) + (N – 2) + … + 2 + 1 = N (N – 1) / 2 = N2 / 2 – N / 2 = O(N2).

In the worst case, the if condition always evaluates to true and we call swap every time, so we perform O(N2) swaps. Note that the time for any individual swap is constant. So the total running time is

O(N2) (ifs) + O(N2) (swaps) = O(N2).

In the best case, the if condition never evaluates to true and we perform no swaps. Then the running time is

O(N2) (ifs) + 0 (swaps) = O(N2).

So bubble sort runs in O(N2) in both the best and worst cases. This doesn't mean that the function's running time is constant for any given N – in the worst case it will take longer. But asymptotically the best and worst cases are the same. In other words, they differ only by a constant factor.

By the way, an experienced programmer would not need to perform algebra to determine the running time of the procedure above. They could glance at it and immediately know that it is O(N2). That's because it has a double loop that fits this important pattern:

1 + 2 + 3 + … + N = O(N2)

In general, any double loop in which the inner loop first iterates to 1, then to 2, and so on up to N will be quadratic. Also, adding or subtracting a constant from the loop bounds will not generally affect the running time. For example, if the procedure above if we replace the line

    for j := 0 to i do

with

    for j := 1 to i do

or

    for j := 0 to (i - 1) do

then the running time will still certainly be O(N2).

binary search

Finally let's determine the running time of a binary search for a value in a sorted array. (We just learned this algorithm; see the section "binary search", above). In the best case, the binary search immediately finds the value that we are seeking. So the best-case running time is O(1). As we observed above, in the worst case the binary search must perform log2(N) iterations, where N is the size of the input array. So the worst-case time is O(log2 N).

Actually we don't need to write the base 2 in O(log2 N). That's because O(log2 N) = O(logkN) for any k > 0. To see why, recall that

logab · logbc = logac for any a, b, c > 0

Thus, for example,

log210 · log10N = log2N

So we see that log10N and log2N differ by only a constant factor. The same is true for any two logarithmic bases. Because big-O ignores constant factors, we can omit the base and just write

O(log2N) = O(log N)

summary

Let's summarize the best-case and worst-case running time for these algorithms:

algorithm

best case

worst case

primality testing

O(1)

O(sqrt(N))

bubble sort

O(N2)

O(N2)

binary search for element

O(1)

O(log N)

div and mod with negative numbers

We leaned about the div and mod operators in our first lecture. Up to now we have only been using them with positive values. Let's now investigate the value of (a div b) when a is negative.

Here's a program to print out (i div 3) and (i mod 3) for various values of i:

var
  i: integer;

begin
  writeln(' i  (i div 3) (i mod 3)');
  writeln('----------------------------');
  for i := 6 downto -6 do
    writeln(i:2, (i div 3):8, (i mod 3):8);
end.

The program prints this output:

 i  (i div 3) (i mod 3)
----------------------------
 6       2       0
 5       1       2
 4       1       1
 3       1       0
 2       0       2
 1       0       1
 0       0       0
-1       0      -1
-2       0      -2
-3      -1       0
-4      -1      -1
-5      -1      -2
-6      -2       0

Notice that if i is negative, then (i mod 3) is either negative or zero. Also notice the symmetry about i = 0. Specifically, (-i div 3) = -(i div 3) and (-i mod 3) = -(i mod 3).

In some programs it is inconvenient that mod can return a negative value. Consider this example. We can represent an hour of the day using an integer between 0 and 23. For example, the integer 14 represents 14:00. We'd like to write a function addHours that adds a positive or negative number of hours to an hour of the day. For example,

This implementation is wrong:

function addHours(h: integer; d: integer): integer;
begin
  exit((h + d) mod 24);
end;

It will return the correct answer in the first two examples above. But it will return addHours(1, -2) = -1, since -1 mod 24 = -1.

We want to ensure that the value we return is in the range 0..23. The easiest way to do that is to simply add 24 if the mod operator returns a negative value:

function addHours(h: integer; d: integer): integer;
var
  i: integer;
begin
  i := (h + d) mod 24;
  if i < 0 then
    i := i + 24;
  exit(i);
end;

This is a useful trick to know, and we will use it from time to time.

splitting into digits from left to right

In an earlier lecture we learned how to split a number into decimal digits from right to left. (See the section "breaking a number into digits" in the notes for lecture 3.)

Sometime we would instead like to iterate over digits from left to right. This is not much more difficult. Suppose that N = 247,638 and we want to split it into digits. We first need to calculate the highest power of 10 that is less than or equal to N. Call this value d. Now, in a loop, we can repeatedly call (N div d) to retrieve a digit, then (N mod d) to remove that digit from N. Then we divide d by 10 and proceed with the next digit.

Here is a Pascal function that implements this idea to add all the digits in a number, working from left to right:

function digitSum(n: integer): integer;
var
  d: integer;
  sum: integer = 0;
begin
  // Compute the highest power of 10 that is <= n.
  d := 1;
  while d * 10 <= n do
    d := d * 10;
  
  // Fetch digits from n.
  while d > 0 do
    begin
      sum := sum + (n div d);
      n := n mod d;
      d := d div 10;
    end;
  
  exit(sum);
end;