Algorithm complexity is a crucial concept in computer science that helps us analyze the efficiency of algorithms. Whether you’re a budding programmer or a seasoned developer, understanding algorithm complexity is essential for writing efficient and scalable code. In this blog post, we’ll explore the basics of algorithm complexity and guide you through the process of calculating it.
What is Algorithm Complexity?
Algorithm complexity is a measure of the efficiency of an algorithm in terms of the resources it consumes. The two primary resources considered are time and space:
- Time complexity: estimates the amount of time an algorithm takes to complete based on the input size
- Space complexity: estimates the amount of memory an algorithm uses to run based on the input size
To describe this measurement rigorously, we need to get into the math using Big O notation.
Big O Notation
Big O notation is a mathematical notation that describes the limiting behavior of a function when the argument tends towards a particular value or infinity.
It is used, among other things, to describe the efficiency of an algorithm, like the upper bound on the growth rate of an algorithm’s time complexity or space complexity as a function of the input size. In other words, it provides an asymptotic analysis of how the runtime or memory requirements increase as the size of the input increases.
It is expressed using the “O
” letter followed by a function that represents the upper bound.
For example, if an algorithm has a time complexity of O(n)
, it means that the running time of the algorithm grows linearly with the size of the input (n).
When analyzing algorithms, Big O notation is particularly useful for comparing the efficiency of different algorithms and understanding how their performance scales with larger inputs. Usually it is used to focus on the worst-case scenario, providing an upper bound on the execution time or space requirements, but it can also be used to describe the average-case scenario or the best-case scenario as well.
It’s important to note that Big O notation describes the growth rate without considering constant factors, so two algorithms with the same Big O complexity may still have slightly different actual runtimes.
Here some examples (ordered from best to worst in terms of performance):
O(1)
: Constant time complexityO(log n)
: Logarithmic time complexityO(n)
: Linear time complexityO(n log n)
: Linearithmic time complexityO(n^2)
,O(n^3)
, etc: Polynomial time complexity (⚠️ Here things are getting bad)O(2^n)
, etc: Exponential time complexity (🚨 Here things are getting really bad)O(n!)
: Factorial time complexity (☢️ Here things are getting really really bad)
Calculating Time Complexity
Here are the steps to calculate the time complexity of an algorithm:
- Counting operations: The first step in calculating time complexity is to count the number of basic operations an algorithm performs. This step involves identifying key operations like assignments, comparisons, and loops.
- Nested loops: If an algorithm has nested loops, multiply the number of operations in each loop to calculate the total number of operations. For example, if an algorithm has two nested loops, one with
n
iterations and the other withm
iterations, the total number of operations isn * m
. - Recursive algorithms: If an algorithm is recursive, use the recursion tree method to calculate the time complexity.
- Nested loops: If an algorithm has nested loops, multiply the number of operations in each loop to calculate the total number of operations. For example, if an algorithm has two nested loops, one with
- Big O Notation: Once you’ve counted the operations, express the growth rate of the algorithm’s running time in terms of Big O notation. For example, if an algorithm performs
n
operations, its time complexity isO(n)
. - Simplify: Finally, simplify the Big O expression by removing the constants and lower-order terms. For example, if an algorithm performs
3n + 2
operations, its time complexity isO(n)
. - Best, worst, and average case: In some cases, you may need to calculate the time complexity for the best, worst, and average case.
Example 1 - Maximum element in an array
function findMax(arr):
maxElement = arr[0]
for element in arr:
if element > maxElement:
maxElement = element
return maxElement
- Counting operations
- The first assignment
maxElement = arr[0]
can be ignored, since it’s executed only once per execution - Then we have one comparison
element > maxElement
and one assignmentmaxElement = element
performed for every item of the input array
- The first assignment
- Big O Notation
- We have 2 operations (one comparison and one assignment) performed for each item of the array (that has
n
items, son
times) - So our Big O Notation is
O(2n)
- We have 2 operations (one comparison and one assignment) performed for each item of the array (that has
- Simplify
- We can remove the constant term from our expression, so our final complexity is
O(n)
- We can remove the constant term from our expression, so our final complexity is
Example 2 - Binary search
# We assume that the input array is sorted.
def binary_search(arr, target):
low, high = 0, len(arr) - 1
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return True
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return False
- Counting operations
- We can ignore the first assignment
low, high = 0, len(arr) - 1
, since it’s executed only once per execution - Then we have one comparison
low <= high
and one assignmentmid = (low + high) // 2
performed for every iteration of the loop - Inside the loop we have one comparison
arr[mid] == target
(that become 2 in the worst case scenario) and one assignments (low = mid + 1
orhigh = mid - 1
, always in the worst case scenario) - So, in total, we have 5 operations performed for every iteration of the loop
- We can ignore the first assignment
- Big O Notation
- In each step, the search range is reduced by half, so the number of iterations is
log(n)
- So our Big O Notation is
O(5 log(n))
- In each step, the search range is reduced by half, so the number of iterations is
- Simplify
- We can remove the constant term from our expression, so our final complexity is
O(log(n))
- We can remove the constant term from our expression, so our final complexity is
Example 3 - Bubble sort
def bubble_sort(arr):
n = len(arr)
for i in range(n):
# Last i elements are already sorted, so we don't need to check them
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
# Swap if the element found is greater than the next element
arr[j], arr[j+1] = arr[j+1], arr[j]
- Counting operations
- We can ignore the first assignment
n = len(arr)
, since it’s executed only once per execution - Then we have one comparison
arr[j] > arr[j+1]
and one assignmentarr[j], arr[j+1] = arr[j+1], arr[j]
performed for every iteration of the inner loop
- We can ignore the first assignment
- Big O Notation
- The outer loop is executed
n
times - The inner loop is executed
n - i - 1
times - So our Big O Notation is
O(2 * n * (n - i - 1))
- The outer loop is executed
- Simplify
- We can remove the constant and lower order terms from our expression, so our final complexity is
O(n^2)
- We can remove the constant and lower order terms from our expression, so our final complexity is
Calculating Space Complexity
To calculate space complexity, we need to consider the memory used by the algorithm in terms of auxiliary space (extra space used by the algorithm, not including the input) and input space (space required by the input itself).
Here are the steps to calculate the space complexity of an algorithm:
- Identify Variables and Data Structures
- Identify all the variables and data structures used by the algorithm. This includes not only the input variables but also any additional variables or data structures created during the execution of the algorithm.
- Determine the size of each variable and data structure in terms of the input size. This step involves understanding how the memory requirements for different types of variables (e.g., integers, arrays, objects) scale with the size of the input.
- If the algorithm involves recursion, consider the space required for each recursive call. This may include the function call stack, where each recursive call adds a new frame to the stack.
- Count any additional space used by the algorithm that is not directly related to the input size. This includes constants, temporary variables, and any other auxiliary space.
- Big O Notation: Once you’ve determined the space usage in terms of the input size, express the relationship using big O notation.
- Simplify: Similar to time complexity, ignore constant factors and lower-order terms, focusing on the dominant factor that determines how the space requirements grow with the input size.
- Best, worst, and average case: In some cases, you may need to calculate the space complexity for the best, worst, and average case.
Example - Array sum
def sum_of_array(arr):
sum = 0
for element in arr:
sum += element
return sum
- Identify Variables and Data Structures
- We have one variable
sum
and one arrayarr
- The size of the variable
sum
is constant - The size of the array
arr
isn
- We have one variable
- Big O Notation
- The space complexity is
O(1 * n)
- The space complexity is
- Simplify
- We can remove the constant term from our expression, so our final complexity is
O(n)
- We can remove the constant term from our expression, so our final complexity is
Conclusions
Understanding algorithm complexity is fundamental in the realm of computer science and software development. The efficiency of an algorithm can significantly impact the performance of a system, influencing everything from response times to resource utilization. Whether it’s optimizing existing algorithms or designing new ones, a good understanding of time and space complexity empowers developers to make informed decisions that improve the overall user experience.