Case Studies: Analyzing Standalone Functions

Steven J. Zeil

Last modified: Apr 29, 2024
Contents:

We’ll illustrate the techniques we’ve learned with some common functions, including some of the array manipulation functions that we converted to iterator style.

1 Some Simple Examples

1.1 Diagonal Matrix

This block of code might be found in an early step in a linear algebra application. It sets up an NxN matrix with 1.0 on the diagonals and 0.0 on all of the off-diagonal elements:

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
       if (i == j)
         matrix[i][j] = 1.0;   ➀
       else
         matrix[i][j] = 0.0;   ➁
    }
}

The most deeply nested statements in this code are and . Looking at them, we can see that they involve address calculations for the array indexing, and assignment of a single double. All of those calculations are $O(1)$.

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    matrix[i] = new double[N];
    for (int j = 0; j < N; ++j)
    {
       if (i == j)
         matrix[i][j] = 1.0;   // O(1)
       else
         matrix[i][j] = 0.0;   // O(1)
    }
}

The if statement condition is $O(1)$,

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
       if (i == j)         // cond: O(1)
         matrix[i][j] = 1.0;   // O(1)
       else
         matrix[i][j] = 0.0;   // O(1)
    }
}

which means that

\[ \begin{align} t_{\mbox{if}} &= t_{\mbox{condition}} + \max(t_{\mbox{then}}, t_{\mbox{else}}) \\ &= O(1) + \max(O(1), O(1)) \\ &= O(1) \end{align} \]

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
       if (i == j)         // cond: O(1)  total: O(1)
         matrix[i][j] = 1.0;   // O(1)
       else
         matrix[i][j] = 0.0;   // O(1)
    }
}

Collapsing,

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
       // O(1)
    }
}

The inner for loop has initialization, condition, and increment all O(1). It executes N times.

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    // init: O(1)
    for (int j = 0; j < N; ++j) // cond: O(1)  incr: O(1)  #: N
    {
       // O(1)
    }
}

So by our rule for for loops,

\[\begin{align} t_{\mbox{for}} &= t_{\mbox{init}} + \sum_{\mbox{iterations}}(t_{\mbox{condition}} + t_{\mbox{increment}} + t_{\mbox{body}}) + t_{\mbox{condition}}^{\mbox{final}} \\ &= O(1) + \sum_{j=0}^{N-1}(O(1) + O(1) + O(1)) + O(1) \\ &= O(1) + N * (O(1) + O(1) + O(1)) + O(1) \\ &= O(1) + N * (O(1)) + O(1) \\ &= O(1) + O(N) + O(1) \\ &= O(N) \end{align}\]

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    // init: O(1)
    for (int j = 0; j < N; ++j) // cond: O(1)  incr: O(1)  #: N  total: O(N)
    {
       // O(1)
    }
}

Collapsing:

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    // init: O(1)
    // O(N)
}

The statement sequence making up this function body adds up to $O(N)$

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i)
{
    // O(N)
}

The remaining for loop has initialization, condition, and increment all O(1). It executes N times.

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i) // cond: O(1)  incr: O(1)  #: N
{
    // O(N)
}

And so the time for this loop is

\[\begin{align} t_{\mbox{for}} &= t_{\mbox{init}} + \sum_{\mbox{iterations}}(t_{\mbox{condition}} + t_{\mbox{increment}} + t_{\mbox{body}}) + t_{\mbox{condition}}^{\mbox{final}} \\ &= O(1) + \sum_{i=0}^{N-1}(O(1) + O(1) + O(N)) + O(1) \\ &= O(1) + N * (O(1) + O(1) + O(N)) + O(1) \\ &= O(1) + N * (O(N)) + O(1) \\ &= O(1) + O(N^2) + O(1) \\ &= O(N^2) \end{align}\]

double[][] matrix = new double[N][N];
for (int i = 0; i < N; ++i) // cond: O(1)  incr: O(1)  #: N  total: O(N^2)
{
    // O(N)
}

Collapsing:

double[][] matrix = new double[N][N];
// O(N^2)

The first statement allocates a 2D array of double, containing a total of $N^2$ elements. Java initializes each of those $N^2$ elements to zero. Because it must visit each of those $N^2$ elements to do so, this is $O(N^2)$.

double[][] matrix = new double[N][N]; // O(N^2)
// O(N^2)

and the entire statement sequence adds up to $O(N^2)$.

There are a few things that I hope you take notice of here:

1.2 Working with a Matrix

Continuing with our NxN matrix, suppose that later in the code we saw:

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];
        matrix[j][i] += matrix[i][i];
        if (i == j)
        {
            for (int k = 0; k < N; ++k)
                matrix[i][i] *= 2.0;
        }
    }
}

Here we have three nested loops, each apparently repeating N times, so we might guess that this will be $O(N^3)$. let’s see if this guess holds up.

Again, we can start by marking simple statements that involve only arithmetic and assignment of primitive types.

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)
        {
            for (int k = 0; k < N; ++k)
                matrix[i][i] *= 2.0;      // O(1)
        }
    }
}

Looking at the innermost loop,

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)
        {
            for (int k = 0; k < N; ++k)   //cond: O(1)  iter:    O(1) #: N
                matrix[i][i] *= 2.0;      // O(1)
        }
    }
}

and the loop time is

\[\begin{align} t_{\mbox{for}} &= t_{\mbox{init}} + \sum_{\mbox{iterations}}(t_{\mbox{condition}} + t_{\mbox{increment}} + t_{\mbox{body}}) + t_{\mbox{condition}}^{\mbox{final}} \\ &= O(1) + \sum_{k=0}^{N-1}(O(1) + O(1) + O(1)) + O(1) \\ &= O(1) + N * (O(1) + O(1) + O(1)) + O(1) \\ &= O(1) + N * (O(1)) + O(1) \\ &= O(1) + O(N) + O(1) \\ &= O(N) \end{align}\]

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)
        {
            for (int k = 0; k < N; ++k)   //cond: O(1)  iter: O(1) #: N  total: O(N)
                matrix[i][i] *= 2.0;      // O(1)
        }
    }
}

Collapsing:

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)
        {
           // O(N)
        }
    }
}

The if condition is $O(1)$.

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)  // cond: O(1)
        {
           // O(N)
        }
    }
}

And we can then evaluate the if as

\[ \begin{align} t_{\mbox{if}} &= t_{\mbox{condition}} + \max(t_{\mbox{then}}, t_{\mbox{else}}) \\ &= O(1) + \max(O(N), O(1)) \\ &= O(N) \end{align} \]

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)
    {
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)  // cond: O(1)  total: O(N)
        {
           // O(N)
        }
    }
}

Now, commonly we would, at this point, collapse the if code and just retain the total complexity. But there are three things that hint to me that this might not be the best approach:

  1. The if is inside a loop.
  2. The then and else part complexities are different, and
  3. The if condition seems highly selective (will take one path much more often than the other).

Whenever I see those three characteristics together, I think it’s worth asking whether the more expensive of the two if parts gets executed often enough, in the worst case, to actually dominate the overall loop.

Ignore the outermost loop for the moment.

    for (int j = 0; j < N; ++j)
    {// O(N)
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)  // cond: O(1)  total: O(N)
        {
           // O(N)
        }
    }

Question: How many times, total, is the if statement going to be executed by this loop?

Click to reveal

Question: How many times, total, will the if statement take the “then” (true) branch?

Click to reveal

Question: How many times, total, will the if statement take the “else” (false) branch?

Click to reveal

OK, now the rest of that loop is easy enough to handle:

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)  // cond: O(1)  iter: O(1)  #: N
    {// O(N)
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)  // cond: O(1)  total: O(N)
        {
           // O(N)
        }
    }
}

so that

\[\begin{align} t_{\mbox{for}} &= t_{\mbox{init}} + \sum_{\mbox{iterations}}(t_{\mbox{condition}} + t_{\mbox{increment}} + t_{\mbox{body}}) + t_{\mbox{condition}}^{\mbox{final}} \\ &= O(1) + \sum_{j=0}^{N-1}(O(1) + O(1) + t_{\mbox{body}}) + O(1) \\ &= O(1) + \sum_{j=0}^{N-1}(O(1) + O(1)) + \sum_{j=0}^{N-1} t_{\mbox{body}} + O(1) \\ &= O(1) + N * O(1) + \sum_{j=0}^{N-1} t_{\mbox{body}} \\ &= O(1) + O(N) + \sum_{j=0}^{N-1} t_{\mbox{body}} \\ &= O(N) + \sum_{j=0}^{N-1} t_{\mbox{body}} \\ \end{align}\]

OK, now let’s think about what our earlier questions told us about the sum of the loop body times over all of the iterations. That sum will add together a total of N executions of the body. Exactly one of those will take $O(N)$ time and $N-1$ of those will take $O(1)$ time. So

\[\begin{align} t_{\mbox{for}} &= O(N) + \sum_{j=0}^{N-1} t_{\mbox{body}} \\ &= O(N) + O(N) + (N-1)O(1) \\ &= O(N) + O(N) + O(N) - O(1) \\ &= O(N) \\ \end{align}\]

So what we can see is that the expensive option of the if is done so rarely that it does not dominate the rest of the sum – it kind of disappears into the overall sum.

for (int i = 0; i < N; ++i)
{
    for (int j = 0; j < N; ++j)  // cond: O(1)  iter: O(1)  #: N  total: O(N)
    { // O(N)
        matrix[i][j] += matrix[i][i];     // O(1)
        matrix[j][i] += matrix[i][i];     // O(1)
        if (i == j)  // cond: O(1)  total: O(N)
        {
           // O(N)
        }
    }
}

Now we can collapse that “j” loop:

for (int i = 0; i < N; ++i)
{
    // O(N)
}

The final loop follows the familiar form:

for (int i = 0; i < N; ++i)  // cond: O(1)  iter: O(1)  #: N
{
    // O(N)
}

\[\begin{align} t_{\mbox{for}} &= t_{\mbox{init}} + \sum_{\mbox{iterations}}(t_{\mbox{condition}} + t_{\mbox{increment}} + t_{\mbox{body}}) + t_{\mbox{condition}}^{\mbox{final}} \\ &= O(1) + \sum_{i=0}^{N-1}(O(1) + O(1) + O(N)) + O(1) \\ &= O(1) + N * (O(1) + O(1) + O(N)) + O(1) \\ &= O(1) + N * (O(1)) + O(1) \\ &= O(1) + O(N^2) + O(1) \\ &= O(N^2) \end{align}\]

And the entire block of code is $O(N^2)$.

That is not what we predicted by just looking at the depth of nesting of the loops. Surprises demand explanation. In this case we have the explanation: the innermost loop is executed only (1/N)th of the time, so we wind up being a factor of $N$ faster than we had originally predicted.

2 Ordered Insertion


We have worked with this function in earlier lessons:

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          ➀
        while(i > 0 && value.compareTo(intoArray[i-1]) < 0) {  ➁
            intoArray[i] = intoArray[i-1];                     ➂
            --i;
        }
        intoArray[i] = value;                                  ➃
        return i;
    }  

2.1 Analysis of insertInOrder

We start our analysis with the easy stuff – mark all of the non-compound statements.

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          // O(1)
        while(i > 0 && value.compareTo(intoArray[i-1]) < 0) {  
            intoArray[i] = intoArray[i-1];                     // O(1)
            --i;                                               // O(1)
        }
        intoArray[i] = value;                                  // O(1)
        return i;                                              // O(1)
    }  

Next, looking at the while loop, what is the complexity of the loop condition?

When we have an expression that involves a function call, we need to know the complexity of that function.

But the compareTo function here could be anything. Literally, anything. That’s because this insertInOrder function is a generic and could be used with any data type that supports the Comparable interface, including future data types that no one has yet written.

So we can’t say how long this function takes to execute. We’ll just have to introduce a symbol, say, $t_c$ to denote the time taken by the compareTo function.

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          // O(1)
        while(i > 0 && value.compareTo(intoArray[i-1]) < 0) {  // cond: O(t_c)
            intoArray[i] = intoArray[i-1];                     // O(1)
            --i;                                               // O(1)
        }
        intoArray[i] = value;                                  // O(1)
        return i;                                              // O(1)
    }  

This is a pretty common occurrence when analyzing generics.

Moving on, we see that the loop repeats at most size times.

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          // O(1)
        while(i > 0 && value.compareTo(intoArray[i-1]) < 0) {  // cond: O(t_c) # size
            intoArray[i] = intoArray[i-1];                     // O(1)
            --i;                                               // O(1)
        }
        intoArray[i] = value;                                  // O(1)
        return i;                                              // O(1)
    }  

The loop body is $O(1)$, so

\[ \begin{align} t_{\mbox{while}} &= \sum_{\mbox{iterations}}(t_{\mbox{condition}} + t_{\mbox{body}}) + t_{\mbox{condition}}^{\mbox{final}} \\ &= \sum_{i=1}^{\mbox{size}} (O(t_c) + O(1)) + O(1) \\ &= O\left(\sum_{i=1}^{\mbox{size}} t_c\right) + O(1) \\ &= O(\mbox{size * t_c}) + O(1) \\ &= O(\mbox{size * t_c}) \end{align} \]

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          // O(1)
        while(i > 0 && value.compareTo(intoArray[i-1]) < 0) {  // cond: O(t_c) # size total: O(size* t_c)
            intoArray[i] = intoArray[i-1];                     // O(1)
            --i;                                               // O(1)
        }
        intoArray[i] = value;                                  // O(1)
        return i;                                              // O(1)
    }  

Replacing the loop by this quantity … ,

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          // O(1)
        // O(size * t_c)
            intoArray[i] = intoArray[i-1];                     // O(1)
            --i;                                               // O(1)
        }
        intoArray[i] = value;                                  // O(1)
        return i;                                              // O(1)
    }  

… we get down to a statement sequence composed of $O(1)$ and $O(\mbox{size})$ terms. The total run time is therefore

\[ t_{\mbox{addInOrder}} = O(1) + O(\mbox{size * t_c}) + O(1) + O(1) + O(1) + O(1) = O(\mbox{size * t_c}) \]

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        // O(size * t_c)
    }  

So, we conclude that this function is in $O()\mbox{size} * t_c)$, where $t_c$ is the worst case time of the compareTo function.

Note that, because none of the inputs to this function are actually named “n”, it is not proper to say the function is “O(n)” unless we explicitly define “n” to be equal to size * $t_c$.

2.2 Special Case Behavior

    public static <T extends Comparable<T>> int insertInOrder(T value, T[] intoArray, int size) {
        int i = size;                                          
        while(i > 0 && value.compareTo(intoArray[i-1]) < 0) {  
            intoArray[i] = intoArray[i-1];                     
            --i;                                               
        }
        intoArray[i] = value;                                  
        return i;                                              
    }  

A special point worth noting:

3 Sequential Search

Another common utility function is to search through a range of data looking for a “search key”, returning the position where it is found and some “impossible” position if it cannot be found.

This code is from chapter 7 of the text:

// Return the position of an element in array A with value K.
// If K is not in A, return A.length.
static int sequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) // For each element
    if (A[i] == K)               // if we found it
       return i;                 //   return this position
  return A.length;               // Otherwise, return the array length
}

3.1 Analysis

Again, we can start the analysis by marking the “easy” stuff – the non-compound statements.

// Return the position of an element in array A with value K.
// If K is not in A, return A.length.
static int sequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) // 
    if (A[i] == K)               //
       return i;                 // O(1)
  return A.length;               // O(1)
}

Looking at the if statement, the condition is $O(1)$.

// Return the position of an element in array A with value K.
// If K is not in A, return A.length.
static int sequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) // 
    if (A[i] == K)               // cond: O(1)
       return i;                 // O(1)
  return A.length;               // O(1)
}

The “then” part of the if is also $O(1)$. So is the (missing) “else” part. That means tht the entire if statement is $O(1)$ in total.

// Return the position of an element in array A with value K.
// If K is not in A, return A.length.
static int sequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) // 
    if (A[i] == K)               // cond: O(1) total: O(1)
       return i;                 // O(1)
  return A.length;               // O(1)
}

Now, looking at the for loop, the initialization, condition, and increment are all $O(1)$.

The loop repeats a maximum of A.length times.

// Return the position of an element in array A with value K.
// If K is not in A, return A.length.
static int sequential(int[] A, int K) {
  // O(1)
  for (int i=0; i<A.length; i++) // cond: O(1) #: A.length
    if (A[i] == K)               // cond: O(1) total: O(1)
       return i;                 // O(1)
  return A.length;               // O(1)
}

We have established that the body of the loop is also $O(1)$, so the total time for the loop is $O(1) + /mbox{A.length} * O(1)$, which simplifies to $O(/mbox{A.length})$.

// Return the position of an element in array A with value K.
// If K is not in A, return A.length.
static int sequential(int[] A, int K) {
  // O(1)
  for (int i=0; i<A.length; i++) // cond: O(1) #: A.length total: O(A.length)
    if (A[i] == K)               // cond: O(1) total: O(1)
       return i;                 // O(1)
  return A.length;               // O(1)
}

And the entire function is therefore $O(/mbox{A.length})$.

Take note that this is not $O(N)$ unless we explicitly define $N$ to be A.length.

How did we know that this complexity would involve A.length as the size measure instead of “N”? That information came to us, naturally, when we examined the loop and saw how many times it repeats.

This is exactly what should happen in most analyses. We don’t start out knowing what the relevant measure of data size will serve as our “N”. We _discover_what the size meausure will be as we are doing the analysis.

4 Ordered Sequential Search

A common variation on the sequential search is the ordered sequential search. If we know that the data in our array is sorted into order, then we can sometimes stop a search early by noting when we have started reaching arrays elements that are larger than the value we are looking for.

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) {
    if (A[i] == K)              
       return i;
	else if (A[i] > K)   
	  return A.length;       
  }   
  return A.length;              
}

The highlighted code adds the check to see if we can exit the loop early even though we have not found the value that we were looking for.

Clearly, this should run faster than the basic sequential function in many cases. But the worst case analysis is pretty nearly identical to that of sequential. That might not be entirely a surprise, since the worst case is similar in both cases: we sometimes need to run through the entire array. In sequential, that happens whenever we look for a value that is not actually in the array. In orderedSequential, it happens when we look for a value that is larger than anything in the array.

We can start by marking the “obvious” $O(1)$ statements:

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) {
    if (A[i] == K)              
       return i;                     // O(1)
	else if (A[i] > K)   
	  return A.length;               // O(1)
  }   
  return A.length;                   // O(1)
}

Now look at the innermost “if”. Its condition is $O(1)$, so the entire statement is $O(1)$.

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) {
    if (A[i] == K)              
       return i;                     // O(1)
	else if (A[i] > K)               // cond: O(1) total: O(1)
	  return A.length;               // O(1)
  }   
  return A.length;                   // O(1)
}

Then look at the outer “if”. Its condition is $O(1)$, and we have just established that its “then” part is $O(1)$, so the entire statement is $O(1)$.

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) {
    if (A[i] == K)                   // cond: O(1) total: O(1)
       return i;                     // O(1)
	else if (A[i] > K)               // cond: O(1) total: O(1)
	  return A.length;               // O(1)
  }   
  return A.length;                   // O(1)
}

Collapsing…

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) {
    // O(1)
  }   
  return A.length;                   // O(1)
}

We can see that the loop has $O(1)$ initialization, condition, and increment, and repeats A.length times, making the loop $O(A.length)$

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  for (int i=0; i<A.length; i++) {  // cond: O(1) # A.length total: O(A.length)
    // O(1)
  }   
  return A.length;                   // O(1)
}

Collapsing…

// Return the position of an element in a sorted array A with value K.
// If K is not in A, return A.length.
static int orderedSequential(int[] A, int K) {
  // O(A.length)
  }   
  return A.length;                   // O(1)
}

… we see that the entire function is $O(/mbox{A.length})$.

This is the same complexity as sequential.

Does that mean that this function is no faster than sequential?

No, it means that both functions have a time that is bounded above by $c * /mbox{A.length}$ for some constant $c$, but that constant is a bit lower for orderedSequential than for sequential. orderedSequential may be faster, and that’s nothing to sneeze at, but the speedup is insignificant when compared to a speedup that we might get from an algorithm with a different big-O.

5 Binary Search

Finally, let’s look at binary search. Again, from chapter 7 of your text:

public static int binarySearch(int[] A, int K) {
  int low = 0;
  int high = A.length - 1;
  while (low <= high) {
    int mid = (low + high) / 2;
    if (A[mid] < K) 
      low = mid + 1;
    else if (A[mid] > K)
      high = mid - 1;
    else
      return mid;
  }
}

5.1 Starting the Analysis

Let’s mark the simple $O(1)$ statements to start:

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {
    int mid = (low + high) / 2;      // O(1)
    if (A[mid] < K) 
      low = mid + 1;                 // O(1)
    else if (A[mid] > K)
      high = mid - 1;                // O(1)
    else
      return mid;                    // O(1)
  }
}

Now look at the innermost if (the “else if”). The then parts and else parts are already marked as $O(1)$. The condition is also $O(1)$.

So that if takes time $O(1) + \max{O(1), O(1)} = O(1)$.

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {
    int mid = (low + high) / 2;      // O(1)
    if (A[mid] < K) 
      low = mid + 1;                 // O(1)
    else if (A[mid] > K)             // cond: O(1) total: O(1)
      high = mid - 1;                // O(1)
    else
      return mid;                    // O(1)
  }
}

Collapsing:

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {
    int mid = (low + high) / 2;      // O(1)
    if (A[mid] < K)                  
      low = mid + 1;                 // O(1)
    else 
      // O(1)
  }
}

The remaining if also has a condition that takes time $O(1)$. Its then and else parts are $O(1)$, so the total if statement is $O(1).

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {
    int mid = (low + high) / 2;      // O(1)
    if (A[mid] < K)                  // cond: O(1) total: O(1)
      low = mid + 1;                 // O(1)
    else 
      // O(1)
  }
}

Collapsing,

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {
    int mid = (low + high) / 2;      // O(1)
    // O(1)
  }
}

The loop condition is $O(1)$:

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {              // cond: O(1)  #: ?
    int mid = (low + high) / 2;      // O(1)
    // O(1)
  }
}

Now, how many times does this loop repeat?

To answer this question let’s go back to the original listing.

public static int binarySearch(int[] A, int K) {
  int low = 0;
  int high = A.length - 1;
  while (low <= high) {
    int mid = (low + high) / 2;
    if (A[mid] < K) 
      low = mid + 1;
    else if (A[mid] > K)
      high = mid - 1;
    else
      return mid;
  }
}

Remember how this function actually works.

  • lowhigh define our current search area. Initially, there are A.length items in this area.

  • The two values $\mbox{low}$ and $\mbox{high}$ define our current search range. If the value we are looking for is somewhere in the array, it is going to be at a position $\geq \mbox{low}$ and $\leq \mbox{high}$. So the difference, $\mbox{high} - \mbox{low} + 1$, defines how many values we have left in the search range.

On any given iteration of the loop:

  • there are $\mbox{high} - \mbox{low} + 1$ items in the search area

    • Each time around the loop, we cut this area in half.

    • We stop when the search area has been reduced to a single item.

  • Let $N$ denote high - low + 1, the number of positions being searched.

    How many times can we divide $N$ things into $2$ equal parts before getting down to only $1$?

That’s the interesting question!

5.2 Logarithmic Behavior

If I start with $N$ things in this search area, how many times can I keep cutting that search area of $N$ things in half till I get to only a single item? (An item that must be the value we are looking for, if that value is anywhere in the array at all.)

Let’s assume for the sake of our argument that $N$ is some power of 2 to start with. So we cut $N$ in half – we get $N/2$. Next time we cut that half which makes $N/4$, then $N/8$ and then $N/16$ and so on. And the question is, how often can we keep doing that until we reduce the number down to just $1$?

The answer may be a bit clearer if we turn the problem around. Start at $1$ and keep doubling until we get $N$. So we proceed

$$1, 2, 4, \ldots $$

and we keep going until we actually get up to $N$. The number of steps we did in doubling is same as number of steps when we start at $N$ and kept dividing by $2$.

How many steps is that? Well, what power of $2$ do we reach when we finally get to $N$? Suppose we took $k$ steps. Then we are saying that $N = 2^k$. Solving for $k$, we get $k = \log_2 N$.

The use of $\log_2\mbox{}$ is so common in Computer Science that we generally assume that any logarithm written without its base is to the base 2. In other words, computer scientists would usually write this as $k = \log N$. (That’s in contrast to other mathematical fields, where a logarithm with no base is generally assumed to be base 10.)

What if $N$ is not an exact power of 2? In that case, $\log_2 N$ is not an integer, and we would need to divide or double

$$\left\lceil \log_2 N \right\rceil$$

times, where $\lceil \; \rceil$ denotes the ceiling or “next integer higher than”.

In practice, we won’t need to worry about that fractional part, because we are going to use this inside a big-O expression, where the fractional part will be dominated in any sum by the logarithm itself.

5.3 Back to the Analysis

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {              // cond: O(1)  #: log(A.length)
    int mid = (low + high) / 2;      // O(1)
    // O(1)
  }
}

Again, the condition and body times are independent of which iteration we are in, so

\[ t_{\mbox{while}} = (\log N)*(O(1) + O(1)) + O(1) = O(\log N) \]

where $N =$A.length.

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  while (low <= high) {              // cond: O(1)  #: log(A.length) total: O(log(A.length))
    int mid = (low + high) / 2;      // O(1)
    // O(1)
  }
}

Collapsing,

public static int binarySearch(int[] A, int K) {
  int low = 0;                       // O(1)
  int high = A.length - 1;           // O(1)
  //O(log(A.length))
}

and the total complexity of the function is $O(\log(N))$ where $N$ is the the length of the array A.