Basics of Big O Notation
By: Nicky Liu
Tags: Big O Notation, JavaScript, Algorithms

I have written about algorithms in the past, and I have mentioned that algorithms are to improve code efficiency. There is however, a specific term to describe the efficiency of a function that I didn’t touch upon. That term is Big O.

Wait a minute…

Not that one.

There we go!

Big O notation describes the complexity of a function/algorithm, either its run-time (the amount of time an algorithm takes to complete its task) or space-complexity (the amount of space an algorithm uses). It is used for seeing how much more work has to be done by an algorithm when the input increases. The notation is simplified to focus on the input size (n) since the growth based on n will eclipse static values, so things like 2n are simplified to just n. It takes the form of O(the amount of steps needed relative to the input).

The chart above shows some of the general notations, as well as w̶h̶i̶c̶h̶ ̶c̶o̶l̶o̶r̶ ̶o̶f̶ ̶t̶h̶e̶ ̶r̶a̶i̶n̶b̶o̶w̶ ̶t̶h̶e̶y̶ ̶a̶r̶e̶ ̶m̶o̶s̶t̶ ̶c̶l̶o̶s̶e̶l̶y̶ ̶a̶s̶s̶o̶c̶i̶a̶t̶e̶d̶ ̶w̶i̶t̶h̶ whether they scale well (low increase in time/space taken) or poorly (large increase in time/space taken) with increased input size. Below I will give an example of some functions that have the complexities listed in the chart above, and why they have that complexity.

O(1) “constant time” is the simplest algorithm and does everything in one step, regardless of how the input increases.

function printArray(array) {

console.log(array)

}

This function will print the entire array once.

2. O(log n) “logarithmic time” isn’t quite as good as constant time, but still pretty good. For logarithmic time, the function starts off with more steps but the amount of steps quickly decreases as it gets closer to the goal. This means that if you increase the input, the initial steps will increase, but the function will quickly lower in steps again as it moves forward. An example of a function with logarithmic time is a binary search.

function binarySearch(array, value) {

let desiredValue = value let newArray = array let currentValue = null while (currentValue !== desiredValue) {

if(newArray.length > 1) {

currentValue = newArray[Math.floor(newArray.length/2)]

if (currentValue > desiredValue) {

newArray = newArray.slice(0, Math.floor(newArray.length/2))

} else {

newArray.slice(Math.floor(newArray.length/2) + 1, newArray.length)

}

} else {

currentValue = newArray[0]

}

}

return currentValue

}

A binary search cuts away half of the array each time, so even if the initial size of the array increases it quickly cuts it back down. If you started with 5 items [1, 2, 3, 4, 5] the slowest possible search (4) will take two steps, first step cuts the array to [4, 5], the next finds the 4. If the size were increased to 10 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] and you were to search for 4, it still only takes three steps [1, 2, 3, 4, 5] > [4, 5] > 4. If the size were increased to 20, it is still only plus one more step [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] > [1, 2, 3, 4, 5] > [4, 5] > 4. You can see that even increasing the size of the input drastically, the increase in steps needed increases very slightly.

3. O(n) “linear time” takes one extra step for each increase in the size of the input. If the input is increased by two items, two extra steps are taken, if it is increased by 7 items, 7 extra steps are taken.

function printArray(array) {

array.forEach((element) => {

console.log(element)

})

}

For the function above, for each item added to the array, the function will perform one extra step of console logging that element.

4. O(n log n) “linearithmic”, “loglinear”, “quasilinear” (this one has a lot of names huh?) One example is the merge sort.

function mergeSort(array) {

if (array.length === 1) {

return array

} let middleNum = Math.floor(array.length / 2)

let leftHalf = array.slice(0, middleNum)

let rightHalf = array.slice(middleNum) return cmrge(mergeSort(leftHalf), mergeSort(rightHalf))

}

function merge(leftArray, rightArray) {

let newArray = []

let leftIndex = 0

let rightIndex = 0 while (leftIndex < leftArray.length && rightIndex < rightArray.length) {

if (leftArray[leftIndex] < rightArray[rightIndex]) {

newArray.push(leftArray[leftIndex])

leftIndex + 1

} else {

newArray.push(rightArray[rightIndex])

rightIndex + 1

}

} if (leftIndex < leftArray.length) {

return newArray.concat(leftArray)

} else {

return newArray.concat(rightArray)

}

}

Merge sort takes an array and breaks it down into single elements, then combines them up again slowly by comparing the each element to the smallest remaining element of the adjacent array. The first function “mergeSort” will take an array; if the received array length is 1 it will return the array, otherwise it will break the array into halves and call the merge function which takes in two array. But here it will pass in the result of itself with both halves of the array, leading it to recursively call itself and break down until each array only contains 1 item. Once the arrays have one item, the merge function will finally start kicking in, and will compare each value starting from the first of each array and placing the smaller one in a new array, then after one array runs out of items it will add the remaining values of the other array into the new array and return it, giving you a sorted array. This process will repeat until it combines back into a sorted version of the original array.

If you started with [6, 5, 3, 1, 8, 7, 2, 4], it would be broken up until the elements are [6, 5, 3, 1] [8, 7, 2, 4] > [6, 5] [3, 1] [8, 7] [2, 4] > [6] [5] [3] [1] [8] [7] [2] [4]. Starting from here elements will be recombined by comparing the values with the one next to them and placing the smaller one in first in the next combined array. [5, 6] [1, 3] [7, 8] [2, 4]. Here the leftmost elements will be compared since they are already positioned to be smaller and placed last in the next array, this step will be repeated until an array is empty, after which the rest of the array will be added to the end of the next array. 1 is greater than 5 so the next array is [1], 3 is greater than 5 so [1, 3], the remaining array is then added to the back so [1, 3, 5, 6], same for other side so [1, 3, 5, 6] [2, 4, 7, 8]. Now 1 < 2 so [1], so 2 < 3 so [1, 2], 3 < 4 so [1, 2, 3] and so forth until they combine to [1, 2, 3, 4, 5, 6, 7, 8].

5. O(n²) is the “quadratic sort”. For each increase in size, the amount of steps needed increase by n². So if the array size were increase by 2, 4 extra steps would be needed. One example is the bubble sort in its worst case scenario.

function bubbleSort(array) {

let newArray = array

let swapped = true while(swapped === true) {

swapped = false for (let i = 0; i < newArray.length - 1, i++){

if (newArray[i] > newArray[i+1]) {

let var2 = newArray[i+1]

array[i+1] = array[i]

array[i] = var2

swapped = true

}

i++

}

}

return array

}

A bubble sort goes through and compares each item with the one next to it, swapping it with the next item if it is bigger and leaving it if it is smaller, so that by the end of one round the last item will be in its proper position. It then does this for the second item, and so on and so forth until it has done it with everything. A bubble sort should have a mechanism to stop it if it goes through once and there are no swaps, but in the worst case scenario in which every item has to be swapped for every loop, it will be a quadratic sort. If an item is added to the array here, it add one more item which every other item has to compare itself to during their loop and one extra loop added for the new item where it must compare itself to every other item, thus giving it n² bonus steps.

6.O(n!) “factorial sort”, also commonly known as the brute force method. This would be like trying to make a function to crack an 8 letter password containing only letters of the alphabet by going through and generating every possible combination.

Hope this article helped to give you a better understanding of Big O and some examples of it in use with some common algorithms!