Deep Dive into

Big O

with: Tom Kelly

What is Big O?

  • Big O is the notation we use to express the runtime or space complexity of an algorithm relative to its input, as the input gets arbitrarily large.
  • Big O is about growth, not actual time or space
  • Because Big O is about measuring the shape of a growth curve, we express Big O in terms of its largest factor, and drop all others.
  • Calculating Big O is actually a lot of fun, and you're about to learn to love it!

Why You Should Care

  • Being able to evaluate the runtime/space complexity of an algorithm is critical to writing performant code.
    • When asked to optimize an algorithm, one of the first things you might do is determine its Big(O) complexity.

Basic StrategIES

/* Let's start out easy */

function foo(arr) {
	var sum = 0;				
	var product = 1;			

	for (var i = 0; i < arr.length; i++)	
		sum += arr[i];			    
	for (var j = 0; j < arr.length; j++)	
		product *= arr[i];		    

	console.log(sum * product);		
}                  
/* Piece of cake */

function foo(arr) {
	var sum = 0;				// O(1)
	var product = 1;			// O(1)

	for (var i = 0; i < arr.length; i++)	// O(arr)
		sum += arr[i];			    // O(1)
	for (var j = 0; j < arr.length; j++)	// O(arr)
		product *= arr[i];		    // O(1)

	console.log(sum * product);		// O(1)
}                  

// O(1) + O(1) + (O(arr) * O(1)) + (O(arr) * O(1)) + O(1)
// O(3 + 2arr) => O(arr) => O(n)

/* 

- Measure the complexity of the algorithm at each and every step.
  Ask yourself: would this change if the input got larger?

- Be concrete: you don't need to use 'n', 'm' etc if you don't want to. 
  Use the name of the inputs of the algorithm, or whatever makes the most sense to you!

- Add the complexity for each line at the same level of indentation.
  Multiply inner levels by their outer levels.

- When you've reduced as far as you can, drop everything but the largest term!

*/
/* Another softball */

function bar(arr) {
	for (var i = 0; i < arr.length; i++)		
		for (var j = 0; j < arr.length; j++)		
			console.log(arr[i] + arr[j]);		    
}


/* All good! */

function bar(arr) {
	for (var i = 0; i < arr.length; i++)		// O(arr)
		for (var j = 0; j < arr.length; j++)		// O(arr)
			console.log(arr[i] + arr[j]);		    // O(1)
}

// O(arr) * O(arr) * O(1) => O(arr^2) => O(n^2)
/* Don't worry, you got this */

function baz(arrA, arrB) {
	for (var i = 0; i < arrA.length; i++)	        
		for (var j = 0; j < arrB.length; j++)	    
			console.log(arrA[i] + arrB[j]);		
}
/* See that wasn't so bad */

function baz(arrA, arrB) {
	for (var i = 0; i < arrA.length; i++)	        // O(arrA)
		for (var j = 0; j < arrB.length; j++)	    // O(arrB)
			console.log(arrA[i] + arrB[j]);		// O(1)
}

// O(arrA) * O(arrB) * O(1) => O(nm)

Beyond the basics

  • Recursion
  • Space Complexity
  • Algorithms on algorithms

How to LOOK AT recursion

  • It's helpful to think of recursion as a tree.
  • When there is only one recursive branch, this is usually like a standard "for" loop, where the number of times we recurse is a function of the input size.
  • When there are multiple recursive branches, the runtime will often be similar to O(branches^depth)
    • Each level of 'depth' has 'branch' number more calls than the level before - an exponential relationship!
  • When in doubt, write it out!

Branches^depth

  • Depth is relative to the input size
  • For example, a balanced binary search tree with seven nodes is three levels deep. If n = 7, the depth is roughly log(n) (log base 2)
/*
        7
       / \
    4       9
   / \     / \
  1   6   8   12


Seven elements altogether
Three levels deep
For an algorithm that visits each node:
O(2^log(n)) ==> MATH ==> O(n)

*/
/* Let's take it to the limit! */

function fib (n) {
    if (n === 1 || n === 0) return n;
    else return fib(n - 1) + fib(n - 2);
}
/* This is why fib is so slow! */

/*
                     fib(4)
                    /      \
            fib(3)            fib(2)
           /      \          /      \
      fib(2)     fib(1)   fib(1)    fib(0)
      /    \
 fib(1)     fib(0)


our input is equal to 4: n = 4
we go four levels deep, so depth = n
we branch twice with each recursive call

therefore, runtime is O(2^n)!

*/
/* Let's get the memo! */

function fib (n, memo) {
    if (!memo) var memo = {};

    if (n === 1 || n === 0) return n;
    else if (memo[n]) return memo[n];
    else memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
    return memo[n];
}

/* Such quicker, much dynamic, wow! */

/*
                     fib(4)
                    /      \
            fib(3)            fib(2)
           /      \          /      \
      fib(2)     fib(1)   fib(1)    fib(0)
      /    \
 fib(1)     fib(0)



1. fib(4) = fib(3) + fib(2)
           /
2. fib(3) = fib(2) + fib(1)
             /
3. fib(2) = fib(1) + fib(0) = memo[2]   at this point, we've had to do O(n) work

4. fib(3) = memo[2] + fib(1) = memo[3]  but now, every calculation is constant time!

5. fib(4) = memo[3] + fib(2) => memo[3] + memo[2]


*/

/* 

That entire second branch got taken out of the picture!
Every step after we reach the bottom of the tree is O(1), thanks to the memo!
Using a memo cuts runtime down to O(n)!

*/

Space Complexity

  • Big O can also express space complexity
  • Measures how much storage space we use relative to the input (ex. by storing values in arrays and hashes, and simultaneous calls on the call stack). Remember what matters is the growth curve - not the sheer number of bytes we store!
  • Space can be taken and then freed up again (the same can't be said of time!)
  • Note: space complexity doesn't get quite the attention that time complexity does because it's far less often an issue - usually we have plenty of space, but not enough time!
/* Memoized fibonacci revisited */

function fib (n, memo) {
    if (!memo) var memo = {};

    if (n === 1 || n === 0) return n;
    else if (memo[n]) return memo[n];
    else memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
    return memo[n];
}

/* 

Call Stack:
fib(4)      fib(4)      fib(4)      fib(4)      fib(4)     fib(4)
fib(3)      fib(3)      fib(3)      fib(3)      fib(3)     etc...
fib(2)      fib(2)      fib(2)                  fib(1)           
fib(1)  >>          >>  fib(0)  >>          >>          >>

This is a lot quicker than the non-memoized version, but remember that it's still recursive, 
so we will eventually have n calls on the call stack. We also have the memo, 
but it ends up not mattering much. It will always contain a little less than n items.

Therefore, space complexity is O(n + n-ish) => O(n)
...which is the same as the non-memoized version!

*/

Multi-Level Algorithms

  • What if you have an algorithm that uses another algorithm? For example, what if you loop over an array of strings and sort each string?
  • Be careful not to confuse the input & runtime of the outer algorithm with the input & runtime of inner algorithms
/* Sorting an array of strings */

function sortedStrings (arr) {
    for (var i = 0; i < arr.length; i++)
        arr[i].sort();                        // Let's say that .sort is O(n log n)
}

// Fun fact: different browsers have different implementations for Array.prototype.sort!
/* Sorting an array of strings */


/* The key to understanding this is that we have two algorithms with two different inputs
    sortedStrings takes an array with an array input, with a length of say 'n'
    Array.prototype.sort takes a string input, with a length of say 's'
*/

function sortedStrings (arr) {
    for (var i = 0; i < arr.length; i++)      // O(n)
        arr[i].sort();                            // O(s log s)
}

// O(n) * O(s log s) => O(n * s log s)

Resources and Questions

Made with Slides.com