Big O is the metric the industry uses to describe the efficiency of algorithms.

When calculating Big O:

  • Time and space matter. An algorithm that runs faster may consume more memory, and an algorithm that is snail-like may use a small amount of memory.
  • Drop the constants when evaluating Big O; is not more precise than . No one cares about the number of instructions, only about how the runtime scales.
  • Drop the non-dominant terms because the overall runtime scale is more important. (e.g. will be )
  • Add runtimes when the algorithm is in the form “do this, then when you’re all done, do that”
  • Multiply runtimes when the algorithm is in the form “do this for each time you do that”

Example Runtimes

Amortized Time

Allows us to describe the typical runtime scale for an algorithm even when there is a worst case runtime. For example, inserting into an array of no fixed size. To accommodate the “infinite” size, an array of size needs to be copied and inserted into a new array of size . There are two cases for the Big O:

  • Inserting an element into the array that won’t make it exceed size where the Big O is .
  • Inserting an element into the array that will make it double in size where the Big O is since the entire array must be copied and inserted.

Therefore the amortized time in this case is .

Log N Runtimes

When there is a problem where the number of elements in the problem space are halved each time, the runtime will likely be .

A binary search is a prominent example.

Recursive Runtimes

When there is a recursive function with multiple calls, the runtime will often look like , where is the number of times each recursive call branches.

Examples

The following examples illustrate different runtimes. I’m using Javascript for the examples. I’d use Python since it’s basically pseudocode but there are a lot of for loops that are easier to write in JS.

function foo(array) {
  let sum = 0;
  let product = 1;

  for (let i = 0; i < array.length; i++) {
    sum += array[i];
  }

  for (let i = 0; i < array.length; i++) {
    product *= array[i];
  }

  console.log(`${sum}, ${product}`);
}

This example relies on the length of the array argument passed into the function. The length does not change.

There are two for loops, but since constants do not matter, the runtime is .

function printUnorderedPairs(array) {
  let i = 1;

  for (let i = 0; i < array.length; i++) {
    for (let j = i + 1; j < array.length; j++) {
      console.log(`${array[i]}, ${array[j]}`);
    }
  }
}

We need to understand how often the inner loop runs to get the runtime. runs times on the first iteration of , times on the second, and so on until we reach . Adding that together makes

which is the sum of 1 through . This equates to:

since we drop constants and non-dominant terms.

function printUnorderedPairs(arrayA, arrayB) {
  for (let i = 0; i < arrayA.length; i++) {
    for (let j = 0; j < arrayB.length; j++) {
      if (i < j) {
        console.log(`${arrayA[i]}, ${arrayB[j]}`);
      }
    }
  }
}

For every element in the first array, the inner loop goes through the length of the second array. If b = arrayB.length and a = arrayA.length, then the runtime is .

function isPrime(n) {
  for (let i = 2; i * i <= n; i++) {
    if (n % i === 0) {
      return false;
    }
  }
  return true;
}

The loop ends at which is the same as saying . Therefore, the runtime is .

function allFib(n) {
  for (let i = 0; i < n; i++) {
    console.log(`${i}: ${fib(i)}`);
  }
}

function fib(n) {
  if (n <= 0) return 0;
  else if (n == 1) return 1;
  return fib(n - 1) + fib(n - 2);
}

While allFib prints out the fibonnaci numbers from 0 to n, the amount of work that needs to happen on each iteration changes because n changes.

So the total amount of work is:

Therefore the runtime is .