February 07, 2020

Is there a computer science topic more terrifying than Big O notation? Don’t let the name scare you, Big O notation is not a big deal. It’s very easy to understand and you don’t need to be a math whiz to do so. In this tutorial, you’ll learn the fundamentals of Big O notation quadratic time complexity with examples in JavaScript.

This is the third in a series on Big O notation. If you want to stay in the loop, sign up for my weekly newsletter, The Solution.

- Big O notation helps us answer the question, “Will it scale?”
- Big O notation equips us with a shared language for discussing performance with other developers (and mathematicians!).

In the first twp parts of this series, we looked at constant and linear time complexity, or O(1) and O(n). If you’re just joining us, you will want to start with that article, What is Big O Notation?

Big O notation is a system for measuring the rate of growth of an algorithm. Big O notation mathematically describes the complexity of an algorithm in terms of time and space. We don’t measure the speed of an algorithm in seconds (or minutes!). Instead, we measure the number of operations it takes to complete.

The O is short for “Order of”. So, if we’re discussing an algorithm with O(n^2), we say its order of, or rate of growth, is n^2, or quadratic complexity.

Big O notation measures the *worst-case scenario*.

Why?

Because we don’t know what we don’t know.

We need to know just how poorly our algorithm will perform so we can evaluate other solutions.

The worst-case scenario is also known as the “upper bound”. When we say “upper bound”, we mean the maximum number of operations performed by an algorithm.

Remember this table?

O | Complexity | Rate of growth |
---|---|---|

O(1) | constant | fast |

O(log n) | logarithmic | |

O(n) | linear time | |

O(n * log n) | log linear | |

O(n^2) | quadratic | |

O(n^3) | cubic | |

O(2^n) | exponential | |

O(n!) | factorial | slow |

It lists common orders by rate of growth, from fastest to slowest.

We learned O(n), or linear time complexity, in Big O Linear Time Complexity. We’re going to skip O(log n), logarithmic complexity, for the time being. It will be easier to understand after learning O(n^2), quadratic time complexity.

Before getting into O(n^2), let’s begin with a review of O(1) and O(n), constant and linear time complexities.

The following function finds the sum of an array of numbers.

```
const nums = [1,2,3,4,5,6,7,8,9,10];
const sumHarder = arr => {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
const result = sumHarder(nums);
```

What’s the Big O?

O(n).

Why?

It’s rate of growth scales in direct proportion to the input. As the input grows, so do the number of operations, linearly. Our function needs to perform the same number of operations for every input, so its time complexity is linear, or O(n).

Can we do better?

We learned in How to Sum Integers 1 to n a party trick discovered by Little Carl Friedrich Gauss:

`n ( n + 1 ) / 2`

If we translate this equation to JavaScript:

`const sumSmarter = arr => arr.length * (arr.length + 1) / 2;`

What’s the order of our function now?

O(1), or constant time.

Why?

Regardless of the size of the input, our algorithm will perform the same, or, a *constant* number of operations.

“Quadratic” is the fancy adjective used to describe *squaring*, or raising to the power of 2.

It’s from the Latin *quadrus*, which means, you guessed it, square.

What’s a square in math?

The square of a number is the result of the number multiplied by itself.

Two to the power of two, or `2^2`

, is the same as `2 * 2`

, or 4.

Three to the power of 2, or `3^2`

, is the same as `3 * 3`

, or 9.

You know this already.

If we chart quadratic equations, we get a parabola:

We’re only concerned with the right-side, or positive, values of our parabola because we can’t go back in time.

Yet.

Speaking of time, Math O’Clock is over.

Back to Big O.

If an algorithm is in the order of O(n), or linear time complexity, the number of operations it performs scales in *direct* proportion to the input.

What if an algorithm is O(n^2)?

🔑 The number of operations it performs scales in proportion to the *square* of the input.

What’s happening in this example?

```
const fruit = ["🍓", "🍐", "🍊", "🍌", "🍍", "🍑", "🍎", "🍈", "🍊", "🍇"];
const matcher = array => {
for (let i = 0; i < array.length; i++){
for (let j = 0; j < array.length; j++){
if (i !==j && array[i] === array[j]){
return `Match found at ${i} and ${j}`;
}
}
}
return 'No matches found 😞';
}
```

We declare an array of fruit and pass it to our `matcher()`

function. Our `matcher()`

function then iterates over our array. For each element in the array, we then iterate over the array again. If the array indices *are not* the same and the elements at the indices *are* the same, then we return the locations of the matched fruit. If no matches are found, we return disappointment.

How many operations are performed?

Our outer loop is performing *n* iterations. Our inner loop *also* performs *n* iterations. But it performs *n* iterations for *every iteration* of the outer loop.

When `i`

is 0, we iterate `j`

10 times.

When `i`

is 1, we iterate `j`

10 times.

Etc.

Rather than O(n) or O(n + n), this is O(n * n), or O(n^2).

At this point, we are familiar with the following: O(1), O(n), and O(n^2). For comparison, this table outlines their growth by number of inputs:

Big O | # operations for 10 elements | # operations for 100 elements | # operations for 1000 elements |
---|---|---|---|

O(1) |
1 | 1 | 1 |

O(N) |
10 | 100 | 1000 |

O(N^2) |
100 | 10000 | 1000000 |

As we can see, O(n^2) isn’t great to begin with and only gets worse as the size of the input increases.

Remember this chart?

As we can see, O(n^2) is horrible.

But it’s not the worst!

The following are common pitfalls and gotchas when calculating Big O quadratic time complexity.

What’s happening in this contrived example?

```
const summer = (num, t = false) => {
let sum = 0;
if (t === 'quadratic'){
for (i = 0; i <= num; i++){
for (j = 0; j < num; j++){
sum += j;
}
}
} else if (t === 'linear') {
for (i = 0; i <= num; i++){
sum += i;
}
} else {
return num * (num + 1) / 2;
}
return sum;
}
```

If our first condition is met, we run the nested iterations and the order of our function is O(n^2).

If our second condition is met, we run one iteration and the order of our function is O(n).

If neither of the conditions are met, our function is O(1).

What is the *actual* order of our function?

We *could* calculate this as O(n^2 + n + 1).

Why would we *not* do this?

When using Big O to measure the rate of growth of our algorithms, what do we *really* want to know?

We want to know the *worst-case scenario*.

Here, it’s `n^2`

.

This is the dominant term.

We learned earlier that we drop constant terms.

We also drop `n`

, the non-dominant term, because it doesn’t provide any meaningful additional information. Whether or not `n`

influences the rate of growth of our algorithm is irrelevant. With or without it, our algorithm is still quadratic.

What we *really* want to know is the *order of* our function, not the details of its specific implementation. So the example above is O(n^2).

What happens if we make a small optimization to our `matcher()`

algorithm?

```
const fruit = ["🍓", "🍐", "🍊", "🍌", "🍍", "🍑", "🍎", "🍈", "🍊", "🍇"];
const matcher = array => {
for (let i = 0; i < array.length; i++){
for (let j = i + 1; j < array.length; j++){
if (array[i] === array[j]){
return `Match found at ${i} and ${j}`;
}
}
}
return 'No matches found 😞';
}
```

If we’re checking for a match, we know that it will never be in the same index in the array.

We can initialize the inner loop with `let j = i + 1`

, so the iterator of the inner loop will always be one element ahead of the outer loop.

This allows us to remove the `i !== j`

operation in the `if`

statement.

Here’s the catch: how many times will the inner loop, *j*, run?

Let’s make a table!

i | j | number of iterations of j |
---|---|---|

0 | 0 + 1 | 5 - 1 = 4 |

1 | 1 + 1 | 5 - 2 = 3 |

2 | 2 + 1 | 5 - 3 = 2 |

3 | 3 + 1 | 5 - 4 = 1 |

4 | 4 + 1 | 5 - 5 = 0 |

Total: 10 |

Let’s say we have an array with five elements. When the value of the iterator in our outer loop, *i*, is 0, the value of the iterator in our inner loop, *j*, is the value of *i* plus 1. As the value of *i* increases, the number of iterations performed by *j* decreases correspondingly.

What if our array length is variable, or, unknown? How would we calculate the number of iterations of our inner loop?

i | j | number of iterations of j |
---|---|---|

0 | 0 + 1 | n - 1 |

1 | 1 + 1 | n - 2 |

2 | 2 + 1 | n - 3 |

… | … | … |

n - 3 | n - 3 + 1 | 2 |

n - 2 | n - 2 + 1 | 1 |

n - 1 | n - 1 + 1 | 0 |

The first time our outer loop runs, our inner loop runs `n - 1`

.

The second time our outer loop runs, our inner loop runs `n - 2`

.

The third time, `n - 3`

.

Hey! This is starting to look familiar. Where have we seen this pattern before?

It’s very similar to summing consecutive integers from 1 to n.

We can calculate this with the following equation:

`n * ( n - 1 ) / 2`

We can simplify this *mathemagically*.

While it’s not exactly *square*, recall that in Big O, we’re not interested in the details. So we drop the constants and multiply the terms with a result of *n^2*.

Let’s visualize it: imagine a function that logs the pairs of *i* and *j*, with an output of the following:

```
[
[ [ 0, 1 ], [ 0, 2 ], [ 0, 3 ], [ 0, 4 ] ],
[ [ 1, 2 ], [ 1, 3 ], [ 1, 4 ] ],
[ [ 2, 3 ], [ 2, 4 ] ],
[ [ 3, 4 ] ]
]
```

See a pattern?

It’s half a square matrix.

We could write that as:

`( n * n ) / 2`

If we drop the constant, the divisor, it’s just *n^2*.

In Cracking the Coding Interview, Gayle Laakmann McDowell directs us to think about what the code *means*.

What does *that* mean?

Our function is comparing pairs, *i* and *j*.

The total number of pairs to compare is *i* * *j*, or, because we are using the same input, *n* * *n*, or *n^2*.

But! We are only comparing pairs where *j* is greater than *i*, which is approximately half of the total number of pairs.

We will never compare pairs where *j* is less than *i*.

Again, we can write this as:

`( n * n ) / 2`

And again, if we abstract away the details, it’s O(n^2).

What if our function accepts two inputs of varying, indeterminate length?

```
const matcher = (arr1, arr2) => {
for (let i = 0; i < arr1.length; i++){
for (let j = 0; j < arr2.length; j++){
if (arr1[i] === arr2[j]){
return `Match found at arr1[${i}] and arr2[${j}]`;
}
}
}
return 'No matches found 😞';
}
```

Is this O(n^2)?

Only if both arrays are equal in length, or *n*.

Because we don’t know what we don’t know, we need to treat each value separately.

So this is *O(n * m)*.

In mathematics, this is known as a *multilinear* function. Bust that out in your next technical interview!

Does O(n^2) scale?

We can do worse.

We can definitely do better.

We’ll see how in the next tutorial.

In this tutorial, you learned the fundamentals of Big O quadratic time complexity with examples in JavaScript. Stay tuned for part four of this series on Big O notation where we’ll look at O(log n), or logarithmic time complexity.

Want to level up your problem solving skills? I write a weekly newsletter about programming, problem solving and lifelong learning. Join now