No history yet

Time and Space Complexity

How We Measure Code

Not all code is created equal. Imagine you and a friend are asked to write a program that sorts a list of a million numbers. You both write code that works, but your friend's program finishes in two seconds, while yours takes two minutes. What's the difference? The answer lies in the algorithm—the method you used to solve the problem.

To compare algorithms, we need a standard measure of efficiency. Counting the exact number of seconds isn't useful, because that depends on the computer's speed, the programming language, and other factors. Instead, we use a more abstract and powerful tool: Big O notationnotation. It describes how an algorithm's performance changes as the size of the input grows.

Thinking Big

The core idea behind Big O is It sounds complex, but it's simple: we only care about how an algorithm behaves when the input size, which we call 'n', gets very large. We ignore small details and focus on the part of the algorithm that has the biggest impact on performance as 'n' scales up. This is often called the growth rate.

Let's look at the most common growth rates.

NotationNameTypical Example
O(1)O(1)ConstantAccessing an array element by its index.
O(logn)O(\log n)LogarithmicFinding an item in a sorted array (Binary Search).
O(n)O(n)LinearLooping through all elements in a list once.
O(n2)O(n^2)QuadraticComparing every element in a list to every other element.
O(2n)O(2^n)ExponentialTrying every possible subset of a set of items.

A constant time operation, O(1)O(1), is the holy grail of efficiency. It takes the same amount of time regardless of the input size.

// O(1) - Constant Time
// The time it takes to get the element does not depend
// on the size of the array.
function getFirstElement(items) {
  return items[0];
}

A linear time operation, O(n)O(n), scales directly with the input size. If the input doubles, the runtime roughly doubles.

// O(n) - Linear Time
// The loop runs 'n' times, where 'n' is the number of items.
function findValue(items, valueToFind) {
  for (let item of items) {
    if (item === valueToFind) {
      return true;
    }
  }
  return false;
}

And a quadratic time operation, O(n2)O(n^2), is where things start to get slow. The runtime grows by the square of the input size. These are often caused by nested loops that iterate over the same collection.

// O(n^2) - Quadratic Time
// The outer loop runs 'n' times, and the inner loop
// also runs 'n' times for each outer iteration.
function hasDuplicates(items) {
  for (let i = 0; i < items.length; i++) {
    for (let j = 0; j < items.length; j++) {
      if (i !== j && items[i] === items[j]) {
        return true; // Found a duplicate
      }
    }
  }
  return false;
}

Case by Case

An algorithm’s performance isn't always the same. It can change based on the specific data it receives. Consider searching for a name in a phone book. The best case is finding the name on the very first page you open. The average case involves flipping through some portion of the book. The is finding the name on the very last page, or discovering it's not in the book at all, forcing you to check every single entry.

In algorithm analysis, we usually focus on the worst-case performance. It gives us a reliable upper bound and a guarantee of how the algorithm will perform. While average-case analysis is also valuable, it's often much more complex to calculate and depends on assumptions about the data's distribution.

The Space-Time Trade-off

Efficiency isn't just about speed (time complexity). It's also about memory usage (space complexity). Often, you can make an algorithm faster by using more memory, or reduce its memory footprint at the cost of speed. This is the classic space-time trade-off.

A common example is caching. You can store the results of expensive calculations in memory (using more space) so you don't have to re-compute them later (saving time).

Imagine you need to write a function that checks for duplicate values in a large list. One way is to use a nested loop, comparing every element with every other element. This has a time complexity of O(n2)O(n^2) but uses very little extra memory—an O(1)O(1) space complexity.

Alternatively, you could create a new data structure, like a hash set, and add each item from the list to it one by one. If you try to add an item that's already in the set, you've found a duplicate. This approach is much faster, with a time complexity of O(n)O(n). However, it requires extra memory to store the hash set, giving it a space complexity of O(n)O(n).

Neither approach is universally 'better'. The right choice depends on the constraints of your problem. If memory is extremely limited, the slower O(n2)O(n^2) approach might be necessary. If speed is the top priority and you have memory to spare, the O(n)O(n) solution is superior.

Ready to test your understanding?

Quiz Questions 1/5

What is the primary purpose of Big O notation in computer science?

Quiz Questions 2/5

An algorithm with a time complexity of O(n2)O(n^2) is generally slower than an algorithm with a time complexity of O(n)O(n) for very large inputs.

Understanding time and space complexity is the foundation for writing efficient and scalable code. It's the framework you'll use to evaluate every data structure and algorithm you learn from here on out.