- Define Big O notation
- Calculate time and space complexity using Big O notation
Big O notation is one of the most important concepts to understand when it comes to writing and analyzing the performance of algorithms. In any algorithm interview problem you're given, there's an extremely high chance that your interviewer will ask you to identify the time complexity (and possibly the space complexity) of your solution using Big O notation. In this lesson, we'll discuss what Big O notation is and how to determine the Big O of any given algorithm with a few simple steps.
Big O can sound like an intimidating term, but it's incredibly useful — and it's very likely that you already have an intuitive sense of how it works! Let's start with a definition:
In computer science, Big O notation is used to classify algorithms according to how their run time or space requirements grow as the input size grows. — Wikipedia
What does that mean? We'll use a real-world example. Imagine you're looking for a sock in a pile of unfolded laundry. An algorithm for finding the sock might look like this:
take one item from the pile
check if it's the sock we're looking for
if it is
put on the sock
otherwise, keep looking
How much time will it take us to find the sock? Well, that depends on a couple factors.
One factor to consider is how quickly we're able to pull out socks from the pile and check what they look like. That variable changes from person to person, so it's not a factor of our algorithm's efficiency, and not something we can account for in Big O notation.
The other factor to consider is the size of our input, or in this case, how big is the pile of laundry? If it's just one item, we'll find the sock right away! If it's 1000 items, the worst case scenario is that the sock we're looking for is the last one we'll pull out of the pile. So, for our sock finding algorithm, as the size of the input increases, the time requirement increases linearly (the bigger the pile, the longer it takes).
Let's translate this analogy to code:
function findSock(laundry) {
for (const item of laundry) {
if (item === "sock") return item;
}
}
findSock(["shirt", "shorts", "sock", "pants"]);
Let's think about the time complexity of this algorithm (how long it will take to run) step by step:
- The
for...of
block will iterate over every element in thelaundry
array. - On the first iteration, the first element,
"shirt"
is assigned to the variableitem
. - The
if
statement checks: is theitem
a"sock"
? Nope! We continue iterating. - On the second iteration, the second element,
"shorts"
is assigned to the variableitem
. - The
if
statement checks: is theitem
a"sock"
? Nope! We continue iterating. - On the third iteration, the third element,
"sock"
is assigned to the variableitem
. - The
if
statement checks: is theitem
a"sock"
? Yep! We stop iterating and return from the function.
What happens if our sock is the last element of the array? Or what if the sock isn't in the array at all?
If laundry
is an unsorted array of strings, the worst case scenario for
our algorithm is that it will have to iterate over every item in the array. In
regards to Big O, whether it might find the element on the first try, or on the
third try, or sometimes not at all is not important; developers simply care
about describing the algorithm from the highest, most abstract level.
Besides the size of the input, the other factor that will determine how long our algorithm will take to find a sock is how powerful the computer is that is running our code. Once again, that variable changes from computer to computer, so it's not a factor of our algorithm's efficiency.
Therefore, we can summarize the time complexity of our function like this:
Given an input array of n
elements, the worst case scenario is that the
algorithm needs to make n
iterations.
In Big O notation, we can express this as O(n)
, which is also known as
linear time.
When calculating the time complexity of an algorithm using Big O notation, we're looking for a high-level summary of the algorithm's performance. Let's look at another example of an algorithm. This one calculates the average of an array of numbers:
function average(array) {
let total = 0;
for (const num of array) {
total += num;
}
return total / array.length;
}
To determine the time complexity of any algorithm, all we need to do is:
- Count the number of steps the computer will take to run our code
- Remove any constants (more on this shortly!)
Let's start by counting the steps:
function average(array) {
// 1 step
let total = 0;
for (const num of array) {
// n steps (dependent on the size of the array)
total += num;
}
// 1 step
return total / array.length;
}
This gives us a runtime of O(n + 2)
. We can remove the constants (+ 2
)
because we're just looking for a high-level summary, so we end up with O(n)
.
What if we use the reduce
method instead of a for...of
loop?
function average(array) {
return array.reduce((total, num) => total + num, 0) / array.length;
}
Under the hood, the reduce
method still needs to iterate over every element in
the array, so we still end up with the same runtime: O(n)
. It's important to
keep this in mind for built-in methods: just because we might end up with fewer
lines of code doesn't mean our runtime improves.
Let's try another example. Here's another algorithm for determining if an array contains the average of all its values:
function containsAverage(array) {
const averageValue = average(array);
for (const num of array) {
if (num === averageValue) return true;
}
}
Let's count the steps once more:
function containsAverage(array) {
// n steps (depending on the size of the array)
const averageValue = average(array);
for (const num of array) {
// n steps (depending on the size of the array)
if (num === averageValue) return true;
}
}
Counting the steps here, we end up with O(n * 2)
. Even though it takes twice
as long as our first algorithm, we can still remove the constants and end up
with O(n)
. Since we're looking for a high-level summary, this approximation
still gives us a good sense as to how well our algorithm will scale to larger
and larger inputs when compared to some other common Big O runtimes.
We've spent some time discussing how to calculate time complexity (how long our function will take to run) using Big O notation, but there's another consideration: space complexity (how much memory our function will use).
Just like with time complexity, we measure space complexity relative to the
size of the input: for an input that takes up n
memory, how much memory will
our algorithm use? In addition to the amount of memory needed for our inputs, we
need to consider what auxiliary (additional) space is taken up for new data
structures when our algorithm runs.
Let's break down a couple algorithms to see some examples:
function reverseString(word) {
const wordArray = word.split("");
const reversedWordArray = wordArray.reverse();
const reversedWord = reversedWordArray.join("");
return reversedWord;
}
In this case, we need to allocate memory for the input word
, as well as these
auxiliary pieces of data:
- an array
wordArray
that will grow in size linearly with the input - an array
reversedWordArray
that will grow in size linearly with the input - a string
reversedWord
that will grow in size linearly with the input
Putting that together, we end up with O(4n)
space complexity: for an input
string of length n
, we also need to declare two arrays of length n
and
another string of length n
. We can simplify as O(n)
after dropping the
constant.
In general, space complexity can be a bit more difficult to calculate than time complexity because it can vary between different programming languages and environments, depending on how they allocate memory under the hood; so again, keeping a high-level summary is more important than trying to be too precise.
For a function that simply adds two numbers together:
function sum(num1, num2) {
return num1 + num2;
}
We only allocate memory for the inputs and the return value:
- A number
num1
- A number
num2
- A number (the return value)
In this case, we can summarize the space complexity as constant: O(1)
.
In many cases, you can improve an algorithm's space complexity by sacrificing its time complexity, and vice-versa; navigating these tradeoffs is one of the keys to being a good developer, and it comes with practice.
To recap:
- Big O notation is used to classify algorithms according to how their run time or space requirements grow as the input size grows.
- To calculate the time complexity of an algorithm using Big O notation, count
the number of steps the computer will take to run our code and then remove any
constants (so
O(2n + 1)
becomes justO(n)
). - To calculate the space complexity of an algorithm using Big O notation, check what memory is needed for the inputs as well as any auxiliary data structures (and also remove any constants).
In the next lesson, we'll look at some other examples of algorithms that use other common Big O formulas.