Time and Space Complexity
Algorithms are often measured by how efficiently they run. Two key metrics for this are time complexity and space complexity. Time complexity helps us understand how the running time of an algorithm increases with the size of the input, while space complexity measures how much extra memory an algorithm uses.
Basic Operation Count
Even though different computers may execute the same algorithm at different speeds, and real running times are hard to calculate exactly, we can estimate performance by counting the number of basic operations the algorithm performs. Basic operations include arithmetic operations (addition, subtraction, multiplication, division), variable access (for fundamental data types), and assignment operations. By estimating the count of these operations, we can gauge the algorithm's efficiency without getting bogged down by machine-specific details.
Time Complexity
Definition
When we talk about how “fast” an algorithm is, we must consider how its running time increases with the size of the input. This input size can refer to the number of elements in a list, the number of vertices in a graph, the number of edges, etc. Generally, as the input size grows, the run time increases. In practice, we are less focused on the time required for a specific input size and more interested in the trend of the growth – this trend is what we call the time complexity of the algorithm.
Why Focus on the Trend?
There are several reasons to focus on the time trend rather than exact timings:
-
Modern computers can execute hundreds of millions of basic operations each second. As a result, we often deal with very large input sizes. For example, suppose we have two algorithms:
- Algorithm A runs in time proportional to .
- Algorithm B runs in time proportional to .
For very small inputs (say, when ), Algorithm B might be faster. However, if we allow a longer execution time, Algorithm A can handle much larger inputs compared to Algorithm B. This is why the growth trend with respect to is more important than the constant factors when input sizes are huge.
-
Counting basic operations abstracts away the machine-specific differences in execution time. Although different operations (like addition versus division) take different amounts of time, when we analyze time complexity, we ignore these differences along with constant factors. This approach simplifies the analysis and gives a clear picture of the algorithm's scalability.
Time complexity is often analyzed in two ways:
- Worst-case time complexity: The upper limit on the running time for any input of size . This is most common in competitive programming, where the algorithm must handle every possible input within a given range.
- Average (expected) time complexity: The expected running time averaged over all inputs of size , assuming a random distribution of inputs.
In essence, time complexity is a measure of how the running time grows with increasing input size, and we formalize this concept using asymptotic notations.
Asymptotic Notations
Asymptotic notations provide a formal way to express the growth rates of functions while ignoring lower-order terms and constant factors. The most common notations are as follows:
- Uppercase symbols (or non-strict inequalities) include equals within their definition, while lowercase symbols (or strict inequalities) do not. For instance, the notation is used when an expression is tightly bounded from above and below, whereas is used for an upper bound and for a lower bound. The notations and come from the Greek letter Omicron, representing similar ideas of “small” and “big.”
Big Theta (Θ) Notation
We write
if and only if there exist positive constants and such that for all ,
This means is bounded both above and below by the function multiplied by some constants. For example,
Big O (O) Notation
If we are only interested in an upper bound, then we use:
if and only if there exist positive constants and such that for all ,
In many analyses, especially when focusing on the worst-case scenario, we use notation.
Big Omega (Ω) Notation
Conversely, to express a lower bound we write:
if and only if there exist positive constants and such that for all ,
Little o (o) Notation
When we need a strict upper bound (ignoring the possibility of equality), we use:
if and only if for every positive constant , there exists an such that for all ,
Little omega (ω) Notation
Similarly, to express a strict lower bound, we write:
if and only if for every positive constant , there exists an such that for all ,
Useful Properties
- if and only if and
- For any constant ,
(Using the change-of-base formula, all logarithms grow at the same rate asymptotically, so we generally omit the base in complexity discussions.)
Simple Examples of Time Complexity Calculation
For Loops
Consider the following Python example that contains three nested loops:
n = int(input())
m = int(input())
for i in range(n):
for j in range(n):
for k in range(m):
print("hello world")
If we treat the input values and as our problem size, the running time of this code is proportional to the number of loop iterations, which is .
Depth-First Search (DFS)
When performing a DFS on a graph with vertices and edges, each vertex and each edge is visited a constant number of times. As a result, the overall time complexity of DFS is .
What Counts as a Constant?
When analyzing time complexity, it’s important to determine which variables are considered part of the input size. For instance, consider the following code snippet:
N = 100000
for i in range(N):
print("hello world")
If the value of is fixed and not part of the problem's variable input, then this loop runs in constant time, or . In complexity analysis, any value that does not scale with the input is treated as a constant factor (effectively counted as 1).
It’s important to note that theoretical discussions assume an algorithm can process any input size. Thus, precomputing answers for every possible input within a limited range doesn’t actually reduce an algorithm’s complexity to when the input size might potentially be unbounded.
The Master Theorem
The Master Theorem is a handy tool for quickly determining the time complexity of many recursive algorithms. Consider a recurrence of the form
then:
The intuition behind this theorem is that at each level of the recursion, a problem of size is divided into subproblems of size , with an additional work of done at that level. Summing the work done across all levels of the recursion tree leads to the overall complexity.
Let’s look at a few examples:
-
For the recurrence
we have , , and . Since the added work grows slower than , the Master Theorem tells us that .
-
For the recurrence
we have , , and . Here, the work dominates, so we conclude that .
-
For
with and (so ), the work is . In this case, the Master Theorem yields .
-
Finally, for
again with and , we find that .
Amortized Complexity
In some cases, while a single operation might be costly, most operations are cheap, leading to a low average or amortized cost. For a deeper introduction to this topic, see additional resources on amortized analysis.
Space Complexity
Just as we analyze running time, we can look at how an algorithm’s memory requirements grow with the input. Space complexity measures the additional memory needed relative to the size of the input, providing insight into the algorithm’s efficiency beyond just execution speed.
Computational Complexity
Although this article focuses on analyzing algorithms from an efficiency perspective, there is a broader field called computational complexity that dives deeper into the intrinsic difficulty of computational problems. Exploring that subject can provide further insights into how and why certain problems are hard to solve.
This tutorial has introduced the core concepts behind analyzing algorithm efficiency, including time complexity, space complexity, and asymptotic notations. With these tools, you'll be better equipped to compare algorithms and understand the trade-offs underlying different approaches.