Algorithm Analysis Fundamentals

Introduction

The overall goal of this chapter is to deal with a seemingly simple question: Given an algorithm, how good is it? I say "seemingly" simple because unless we define what we mean by "good", we cannot answer the question. Do we mean how elegant it is? How easy it is to understand? How easy it is to update if/when necessary? Whether or not it can be generalized?

Although all of these may be important questions, in algorithm analysis we are usually more interested in the following two questions: How long does the algorithm take to run, and how much space (memory) does the algorithm require. In fact, we follow the tradition of most books and focus most of our discussion on running time. This is usually reasonable since the amount of memory used by most algorithms is not large enough to matter. There are times, however, when analyzing the space required by an algorithm is important. For instance, when the data is really large (e.g. the graph that represents friendships on Facebook) or when you are implementing a space-time-tradeoff algorithm.

This page introduces the basic ideas used in algorithm analysis: why raw running times can be misleading, how input size affects running time, how best-case, worst-case, and average-case analysis differ, and how to recognize common time complexities.

Why Asymptotic Analysis?

Since this page focuses mostly on running time, our next task is to decide what "running time" should mean. At first, it might seem enough to time a program with a stopwatch, but that measurement depends on the computer, the programming language, the compiler, the operating system, and even what else the machine is doing. To compare algorithms rather than particular program runs, we need a more stable way to measure time.

So what should we count? There are several reasonable possibilities:

Question 1

Why aren't wall-clock time and CPU time the same?

Because the running time of an algorithm is greatly affected by the characteristics of the computer system (e.g. processor speed, number of processors, amount of memory, file-system type, etc.), the running time does not necessarily provide a comparable measure, regardless of whether you use CPU time or wall-clock time. The next question asks you to think about why.

Question 2

Sue and Stu were competing to write the fastest algorithm to solve a problem. After a week, Sue informs Stu that her program took 1 hour to run. Stu declared himself victorious since his program took only 3 minutes. But the real question is this: Whose algorithm was more efficient? Can we be certain Stu's algorithm was better than Sue's? Explain. (Hint: Make sure you don't jump to any conclusion too quickly. Think about all of the possibilities.)

The answer to the previous question should make it clear that you cannot compare the running times of algorithms if they were run on different machines. Even if two algorithms are run on the same computer, the wall-clock times may not be comparable.

Question 3

Why isn't the wall-clock time of two algorithms that are run on the same computer always a reliable indicator of their relative performances?

In fact, if you run the same algorithm on the same machine multiple times, it will not always take the same amount of time. Sometimes the differences between trial runs can be significant.

Question 4

If two algorithms are run on the same machine, can we reliably compare the CPU-times?

So the CPU-time turns out to be a pretty good measure of algorithm performance. Unfortunately, it does not really allow one to compare two algorithms. It only allows us to compare specific implementations of the algorithms. It also requires us to implement the algorithm in an actual programming language before we even know how good the algorithm is (that is, before we know if we should even spend the time to implement it).

But we can analyze and compare algorithms before they are implemented if we use the number of instructions as our measure of performance. There is still a problem with this measure. What is meant by an "instruction"? When you write a program in a language such as Java or C++, it is not executed exactly as you wrote it. It is compiled into some sort of machine language. The process of compiling does not generally involve a one-to-one mapping of instructions, so counting Java instructions versus C++ instructions wouldn't necessarily be fair. On the other hand, we certainly do not want to look at the machine code in order to count instructions—machine code is ugly. Further, when analyzing an algorithm, should we even take into account the exact implementation in a particular language, or should we analyze the algorithm apart from implementation?

O.K., that's enough of the complications. Let's get to the bottom line. When analyzing algorithms, we generally want to ignore what sort of machine it will run on and what language it will be implemented in. We also generally do not want to know exactly how many instructions it will take. Instead, we want to know the rate of growth of the number of instructions. This is sometimes called the asymptotic running time of an algorithm. In other words, as the size of the input increases, how does that affect the number of instructions executed? We will typically use Asymptotic Notation to specify the running time of an algorithm. We will call this the time complexity (or often just complexity) of the algorithm.

Analyzing Algorithms

Algorithm analysis involves determining the size of the input, \(n\), and then finding a function based on \(n\) that tells us how long the algorithm will take if the input is of size \(n\). By "how long" we of course mean how many operations.

The examples in this section use algorithms that should be familiar from an earlier programming or data structures course. The goal is not to introduce new algorithms, but to practice analyzing simple ones carefully.

We will start by deciding what counts as the input size, then look at how to count operations, how best-case, worst-case, and average-case analysis differ, how to choose which operation to count, and how to analyze simple loops.

Input Size

Given an algorithm, the size of the input is the amount of data the algorithm receives. In this course, we usually describe input size using the most natural counting measure for the problem. For an array, this is usually the number of elements. For a matrix, it is usually the number of entries. For a graph, it is usually the number of vertices, the number of edges, or both. When the input is a single number, the size can be measured in terms of the number of digits or bits needed to write it down, but most examples in this section use arrays, matrices, or graphs.

Example 1: Sequential Search

Given an array of \(n\) elements, often one needs to determine if a given number \(val\) is in the array. One way to do this is with the sequential search algorithm that simply looks through all of the elements in the array until it finds it or reaches the end. The most common version of this algorithm returns the index of the element, or \(-1\) if the element is not in the array. Here is one implementation.

    int sequentialSearch(int a[], int n, int val) {
        for(int i=0; i<n; i++) {
            if(a[i]==val) {
                return i;
            }
        }
        return -1;
    }

What is the size of the input to this algorithm?

Solution

There are a few possible answers to this question. The input technically consists of an array of \(n\) elements, the number \(n\), and the value we are searching for. So we could consider the size of the input to be \(n+2\). However, we usually ignore a constant number of extra numeric parameters when choosing the input-size variable. So we will say the size of the input is \(n\).

In general, if an algorithm takes as input an array of size \(n\) and some constant number of other numeric parameters, we will consider the size of the input to be \(n\).

Example 2: Graph Input Size

Suppose an algorithm takes as input a graph with \(V\) vertices and \(E\) edges. What is the size of the input?

Solution

We usually describe the input size using both \(V\) and \(E\). The number of vertices alone does not always tell us how large the graph is, because two graphs with the same number of vertices can have very different numbers of edges. For example, many graph algorithms are analyzed in terms of \(V+E\), \(E\log V\), or \(V^2\), depending on the graph representation and the algorithm.

Exercise 1

Consider an algorithm that takes as input an \(n\) by \(m\) matrix, an integer \(v\), and a real number \(r\). What is the size of the input?

Counting Operations

Once we have identified the input size, the next step is to count how much work the algorithm performs. At first, we can try to count individual operations such as assignments, comparisons, increments, array accesses, and return statements. This kind of exact count is useful for learning, but later we will usually count only the operations that determine the rate of growth.

Example 3

How many operations does sequentialSearch take on an array of size \(n\)?

    int sequentialSearch(int a[], int n, int val) {
        for(int i = 0; i < n; i++) {
            if(a[i] == val) {
                return i;
            }
        }
        return -1;
    }

Solution

We could try to count every operation. The assignment i = 0 happens once. Each time the loop condition is checked, the algorithm compares i with n. Each time through the body of the loop, the algorithm accesses a[i], compares a[i] with val, and later increments i. If the value is found, the algorithm returns i. If the value is not found, the loop condition is checked one extra time before the algorithm returns \(-1\).

Already this is a little annoying. For example, should we count a[i] == val as one operation, or as an array access plus a comparison? Should we count the increment i++ as one operation, or as an addition plus an assignment? Should returning a value count the same as an assignment? Different reasonable counting rules can give different exact formulas.

To illustrate the issue, suppose \(val\) is found at index \(k\), where indexing starts at \(0\). Then the loop body executes \(k+1\) times. The loop condition i < n is also checked \(k+1\) times, the comparison a[i] == val is made \(k+1\) times, and the increment i++ is executed \(k\) times before the successful return. Depending on the exact rules we use, we might get a formula such as \[ c_1 + c_2(k+1) + c_3(k+1) + c_4k + c_5, \] where the constants \(c_1,c_2,c_3,c_4,c_5\) represent the costs of different low-level operations.

If \(val\) is not in the array, then the loop body executes \(n\) times, the loop condition is checked \(n+1\) times, the comparison a[i] == val is made \(n\) times, and the final return -1 executes once. Again, the exact formula depends on what we decide to count, but it will have the form \[ d_1n + d_2 \] for some constants \(d_1\) and \(d_2\).

The important point is that the exact constants are not what we usually care about. If the value is found at index \(k\), the running time grows linearly with \(k+1\). If the value is not found, the running time grows linearly with \(n\). Thus, exact counting suggests that a successful search ending at index \(k\) takes \(\Theta(k+1)\) time, while an unsuccessful search takes \(\Theta(n)\) time.

In practice, we usually simplify the analysis by choosing one operation that captures the main work. For sequential search, the natural choice is the comparison a[i] == val. This comparison happens once for each array element that is examined. If the target is at index \(k\), the comparison executes \(k+1\) times, so a successful search ending at index \(k\) takes \(\Theta(k+1)\) time. If the target is not present, the comparison executes \(n\) times, so an unsuccessful search takes \(\Theta(n)\) time. Therefore, the best case is \(\Theta(1)\), while the worst case is \(\Theta(n)\). We will use this same idea throughout the rest of the section: choose an operation that represents the main work, then count how many times it executes.

The next example uses this simplified approach directly.

Example 4

Approximately how many operations does the following code perform? What is its asymptotic running time?

    int total = 0;
    for (int i = 0; i < n; i++) {
        total = total + a[i];
    }

Solution

The assignment total = 0 happens once. The loop executes \(n\) times. During each iteration, the algorithm checks the loop condition, accesses a[i], adds it to total, stores the result back in total, and increments i. Each of these takes constant time.

So the exact operation count depends on the details of what we count, but it has the form \[ c_1n + c_2 \] for constants \(c_1\) and \(c_2\). Therefore, the asymptotic running time is \(\Theta(n)\).

Equivalently, we could choose the assignment total = total + a[i] as the representative operation. It executes exactly \(n\) times, and the remaining work per iteration is also constant. This again gives \(\Theta(n)\).

Best, Worst, and Average Cases

The previous examples bring up another important question: what if the same algorithm does different amounts of work on different inputs of the same size? For instance, sequential search might find the target immediately, near the end, or not at all. To describe these different possibilities, we often analyze three cases.

In each case, the input size \(n\) is fixed. We are not comparing small inputs to large inputs. We are comparing different inputs of the same size.

When performing a best-case analysis, we are trying to determine the smallest possible number of operations an algorithm will take on an input of size \(n\). This is often the least useful type of analysis. If someone tells you a repair will take "only an hour," but it actually takes three hours, they may have told you something closer to the best case than the case you actually experienced.

The phrase for an input of size \(n\) is important. Otherwise every algorithm would have a constant-time best case by choosing the smallest possible input, often of size \(0\) or \(1\). That sort of analysis is not very informative.

Remark

When you are asked to do a best-case analysis of an algorithm, remember that it is implied that what is being asked is the best-case analysis for an input of size \(n\). This applies to average-case and worst-case analysis as well, but it is easier to make this mistake when doing a best-case analysis.

Worst-case analysis considers the largest number of operations that can execute on an input of size \(n\). This is probably the most common and often the most useful type of analysis. When we prove a worst-case bound, we are saying that no input of size \(n\) can make the algorithm take longer than that bound.

The average case is more complicated, both to define and to compute. The first problem is deciding what "average" means for a particular problem. For instance, what does an "average" array look like? The second problem is that even with a good definition, computing the average-case complexity is usually harder than computing the best or worst case. Average-case analysis also has to be interpreted carefully: an average tells us what happens overall, not what must happen on every input.

I sometimes use the term expected running time instead of average-case running time. This is almost synonymous with average case, but when I use this term I am being less formal. Think of it as describing how long an algorithm will usually take. It will often coincide with either the average-case or worst-case complexity.

Example 5

Continuing the sequentialSearch example, what are the best-case, worst-case, and average-case complexities?

Solution

The best case occurs when the target value is the first element in the array. In that case, the algorithm performs one comparison and returns immediately, so the best-case complexity is \(\Theta(1)\).

The worst case occurs when the target value is not in the array, or when it appears only at the last position. In either case, the algorithm must examine all \(n\) array elements, so the worst-case complexity is \(\Theta(n)\).

For a rough expected-case estimate, suppose the target is usually found somewhere near the middle of the array. Then the algorithm examines about \(n/2\) elements. Since \(n/2\) grows at the same rate as \(n\), this suggests expected running time \(\Theta(n)\). This is useful intuition, but it is not yet a full average-case analysis because we have not specified a probability model for the inputs.

Notice that the average-case and worst-case complexities in the previous example are the same. This makes sense: the average case is about half as much work as the worst case, but half of \(n\) is still \(\Theta(n)\). Also notice why the best case is \(\Theta(1)\). It is not because the best-case input has size \(1\). It is because, for any input size \(n\), the target might appear in the first position.

Example 6

Let's determine the average-case complexity of sequentialSearch. We will count comparisons of the form a[i] == val.

Solution

If we assume that the element is equally likely to be anywhere in the array, then there is a \(1/n\) chance that it will be in any given spot. If it is in the first spot, the comparison executes once. If it is in the second spot, it executes twice. In general, it takes \(k\) comparisons if it is in the \(k\)th spot. Since each possibility has a \(1/n\) chance, the average expected search time is \[ \sum_{k=1}^{n}\frac{k}{n} = \frac{1}{n}\sum_{k=1}^{n}k = \frac{1}{n}\frac{n(n+1)}{2} = \frac{n+1}{2} = \Theta(n). \]

Our analysis simplified things a bit: it did not take into account the possibility that the element was not in the array. To do so, let's assume the element searched for is equally likely to be anywhere in the array or not in the array. That is, there is now a \(1/(n+1)\) chance that it will be in any of the \(n\) spots in the array and a \(1/(n+1)\) chance that it is not in the array. We divide by \(n+1\) because there are now \(n+1\) possibilities, each equally likely. If it is not in the array, the number of comparisons is \(n\). In this case the expected time is \[ \sum_{k=1}^{n}\left(\frac{k}{n+1}\right)+\frac{n}{n+1} = \frac{1}{n+1}\left(\sum_{k=1}^{n}k+n\right) = \frac{1}{n+1}\left(\frac{n(n+1)}{2}+n\right) = \frac{n^2+3n}{2(n+1)} = \Theta(n). \]

The previous calculation is more complete than simply saying the target is usually near the middle, but it still depends on an assumption we made. Is it really valid to assume that there is a \(1/(n+1)\) chance that the element searched for is not in the array? If we are searching for a lot of values in a small array, perhaps most of the values we search for are not in the array. Maybe it is more realistic to assume there is a \(50\%\) chance it is in the array and a \(50\%\) chance that it is not in the array. We could propose several other reasonable assumptions, too.

As stated before, it can be difficult to define "average." In this case, the exact assumption does not matter much asymptotically: under many reasonable assumptions, the average-case complexity still comes out as \(\Theta(n)\). For more complicated algorithms, however, the choice of probability model can matter much more.

This example demonstrates why average-case analysis is usually more difficult than best-case or worst-case analysis. Even for a simple algorithm, we had to decide which inputs were possible and how likely each one was. As algorithms become more complex, defining and computing the average case can become much harder, so we will sometimes skip it or discuss it informally.

Example 7

Consider an algorithm that adds all \(n\) values in an array.

    int sumArray(int a[], int n) {
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum = sum + a[i];
        }
        return sum;
    }

What are the best-case, worst-case, and average-case complexities?

Solution

The loop executes exactly \(n\) times no matter what values are stored in the array. The values affect the sum, but they do not affect how many iterations the loop performs. Therefore, the best-case, worst-case, and average-case complexities are all \(\Theta(n)\).

This is different from sequential search, where the number of iterations depends on where the target value appears or whether it appears at all.

Choosing What to Count

The previous examples suggest a useful shortcut: instead of counting every low-level operation, we can choose one operation that represents the main work of the algorithm and count how many times it executes. This shortcut is valid only when the chosen operation really does capture the growth rate of the whole algorithm.

A good representative operation is usually one that occurs inside the most frequently executed part of the algorithm, such as the body of a loop. We must also check that the other operations being ignored take constant time or occur no more often than the operation we are counting.

Example 8

What is the complexity of max(x,y)? Justify your answer.

    int max(int x, int y) {
        if (x >= y) {
            return x;
        } else {
            return y;
        }
    }

Solution

This method performs one comparison, then returns one of the two values. There is no loop or recursion, and the number of operations does not depend on the sizes or values of \(x\) and \(y\). Therefore, the best-case, worst-case, and average-case complexities are all \(\Theta(1)\).

In this case there is not much need to choose a representative operation. Any reasonable count gives a constant number of operations.

Example 9

Analyze the following algorithm that finds the maximum value in an array. Start by deciding which operation should be counted.

    int maximum(int a[], int n) {
        int currentMax = Integer.MIN_VALUE;
        for (int i = 0; i < n; i++) {
            currentMax = max(currentMax, a[i]);
        }
        return currentMax;
    }

Solution

The loop executes exactly \(n\) times. One reasonable representative operation is the call to max(currentMax, a[i]), since it happens once per loop iteration. Another reasonable choice is the assignment to currentMax, which also happens once per iteration.

To make this analysis valid, we must know that max takes constant time. The previous example showed that it does: max performs one comparison and one return. Therefore each loop iteration takes constant time, and the loop executes \(n\) times.

Thus, the best-case, worst-case, and average-case complexities are all \(\Theta(n)\). They are the same because the loop always examines every array element, no matter what values are stored in the array.

The previous example also shows why method calls need to be treated carefully. We were allowed to treat max(currentMax, a[i]) as constant time only because we knew what max did. In general, a method call might take constant time, linear time, logarithmic time, or something else entirely.

Example 10

Suppose the following code is used to add up the values stored in a list-like data structure. What extra information do we need before we can analyze it correctly?

    int total = 0;
    for (int i = 0; i < n; i++) {
        total = total + list.get(i);
    }

Solution

We need to know how long list.get(i) takes. If list is backed by an array, then get(i) might take \(\Theta(1)\) time, so the whole loop would take \(\Theta(n)\) time.

If list is implemented as a linked list and get(i) starts at the front each time, then get(i) might take \(\Theta(i)\) time. In that case the loop would take \[ \Theta(1+2+\cdots+n)=\Theta(n^2). \]

The code looks like a simple loop, but the running time depends on the cost of the operation inside the loop. We cannot assume that every method call takes constant time.

Sometimes a single representative operation is enough. Other times, it is better to count several kinds of operations or analyze different parts of the algorithm separately. The key is to make sure the operations being counted determine the same rate of growth as the whole algorithm.

Let's see one more example.

Example 11

Consider the following algorithm, which checks whether an array is sorted in nondecreasing order.

    boolean isSorted(int a[], int n) {
        for (int i = 0; i < n - 1; i++) {
            if (a[i] > a[i + 1]) {
                return false;
            }
        }
        return true;
    }

What are the best-case, worst-case, and average-case complexities?

Solution

The natural operation to count is the comparison a[i] > a[i + 1]. The best case occurs when the first two elements are out of order. Then the algorithm performs one comparison and returns false, so the best-case complexity is \(\Theta(1)\).

The worst case occurs when the array is sorted, or when the first out-of-order pair occurs at the very end. Then the algorithm must check all \(n-1\) adjacent pairs, so the worst-case complexity is \(\Theta(n)\).

The average case depends on what kinds of arrays we expect. If out-of-order pairs tend to appear early, then the algorithm usually returns quickly. If arrays are often sorted or nearly sorted, then it may usually examine most of the array. Without a probability model for the inputs, we should not claim a precise average-case complexity. In many basic analyses, we would report the best case as \(\Theta(1)\) and the worst case as \(\Theta(n)\), and discuss the average case only if the input distribution is specified.

Remark

When you are asked for the complexity of an algorithm, you should do the following three things:

  1. Give the best, average, and worst-case complexities unless otherwise specified. Sometimes the average case is quite complicated; in those cases, it may be skipped, approximated informally, or replaced with a clearly stated expected-case discussion.

  2. Give answers in the form \(\Theta(f(n))\) for some function \(f(n)\), or \(O(f(n))\) if a tight bound is not possible. The function \(f(n)\) should be as simple as possible. For instance, instead of \(\Theta(3n^2+2n+89)\), use \(\Theta(n^2)\).

  3. Clearly justify your answers by explaining what operation or operations you counted, how many times they execute as a function of the input size, and why that count represents the running time of the whole algorithm. Also mention any assumptions you are making, such as treating a method call or array access as constant time.

Analyzing Simple Loops

Many running-time analyses come down to understanding how often loops execute. In this section, we focus on simple loops whose iteration counts are easy to determine directly: consecutive loops, nested loops with fixed bounds, and nested loops whose bounds are still straightforward to multiply. More complicated loops, especially loops whose counts require summations, will be handled later.

Example 12

Give the complexity of the following code.

    int q = 0;

    for (int i = 1; i <= n; i++) { 
        q = q + i * i;
    }

    for (int j = 1; j <= n; j++) {
        q = q * j;
    }

Solution

This algorithm has two independent loops, each of which do slightly different things. Thus, we cannot pick a single operation to count. Instead we will pick the assignment statements that involve \(q\). That is, we will use both q=q+i*i and q=q*j.

The first assignment executes \(n\) times since the first loop executes for every value of \(i\) from \(1\) to \(n\). The second loop also executes its assignment \(n\) times for the same reason. Since the loops happen one after another, we add the number of operations, so the total is \(n+n=2n\) assignment statements. Since there are no conditional statements, this is the best, worst, and average-case number of assignment statements. Thus, the complexity for all three cases is \(\Theta(n)\).

Example 13

Give the complexity of the following code.

    double v = 0;

    for (int i = 1; i <= n; i++) { 
        for (int j = 1; j <= n; j++) { 
            v = v + A[i] * A[j];
        }
    }

Solution

Clearly the assignment (v=v + A[i] * A[j]) occurs the most often. The inner loop always executes \(n\) times, each time doing one assignment. The outer loop executes \(n\) times, and each time it executes, it executes the inner loop. Therefore the total time is \(n\cdot n=\Theta(n^2)\). This is the best, worst, and average case complexity since nothing about the input can change what the algorithm does.

Here is another way to think about it. The inner loop executes the assignment statement \(n\) times every time it executes. The first time through the outer loop, the whole inner loop executes and calls the assignment \(n\) times. The second time through the outer loop, the whole inner loop executes and calls the assignment \(n\) times. This happens all the way until the \(n\)th time through the outer loop during which the whole inner loop executes and calls the assignment \(n\) times. Thus, the total number of times the assignment is called is \(n+n+\cdots+n\) times (where there are \(n\) terms in the sum), which is just \(n\cdot n\). Thus the complexity is \(\Theta(n^2)\).

Remark

Always analyze loops from the inside out. The more practice you get, the more it will be obvious that this is the only way that will consistently work. Although you probably would have gotten the right answer in in the previous example by analyzing the outer loop first, it is not always that easy. We will see examples later where analyzing the outer loop first will lead to a wrong answer and/or become very confusing.

The previous two examples are easy to confuse. In Example 12, one loop happens after another, so the work is \(n+n\). In Example 13, one loop happens inside the other, so the work is \(n\cdot n\).

Here is an analogy. If you climb a flight of \(10\) stairs followed by another flight of \(10\) stairs, you climbed a total of \(10+10=20\) stairs. Now assume you go into a building that has \(10\) floors. There are \(10\) steps between floors (so it takes \(10\) steps to get from floor 1 to 2, etc.) If you climb to the top of the building, how many stairs did you climb? It is \(10+10+\cdots+10\) (where there are \(10\) terms in the sum), which is \(10^2=100\). How does this relate to the previous examples? Simple. In the first case, you executed:

for(stair 1 through 10)
    climb stair
for(stair 1 through 10)
    climb stair

and in the second case you executed:

for(floors 1 through 10)
    for(stair 1 through 10)
        climb stair

Hopefully you can see the similarity in the analysis in this situation and the previous examples.

Remark

Consecutive loops usually lead to adding running times. Nested loops often lead to multiplying running times. However, the exact result depends on the loop bounds, so do not assume every nested loop is quadratic.

Now it is your turn to try to analyze some algorithms:

Exercise 3

What is the worst-case complexity of the following algorithm?

    int k = 50;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < k; j++) {
            a[i][j] = b[i][j] * x;
        }
    }

Exercise 4

What is the worst-case complexity of the following algorithm? (This one is a little more tricky! Read the code carefully.)

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n * n; j++) {
            a[i][j] = b[i][j] * x;
        }
    }

Common Time Complexities

Once we analyze an algorithm, the final answer often matches one of a small number of common growth rates. The table below lists several common complexity classes in increasing order of growth of the function \(f(n)\). Since \(f(n)\) represents running time, larger growth rates generally describe algorithms that scale worse as \(n\) increases.

Complexity Name Common source Example pattern
\(\Theta(1)\) constant A fixed amount of work independent of \(n\) Accessing one array entry; swapping two values
\(\Theta(\log n)\) logarithmic Repeatedly discarding a constant fraction of the input Binary search
\(\Theta(n)\) linear Examining each input item a constant number of times Sequential search in the worst case; summing an array
\(\Theta(n \log n)\) linearithmic Many efficient divide-and-conquer algorithms Merge sort; average-case quicksort
\(\Theta(n^2)\) quadratic Comparing or combining many pairs of items; examining every element of an \(n\times n\) matrix Many simple sorting algorithms; two \(n\)-by-\(n\) nested loops
\(\Theta(n^k)\) polynomial A fixed number of nested loops or stages that each depend on \(n\) Standard matrix multiplication is \(\Theta(n^3)\)
\(\Theta(k^n)\) exponential Trying all subsets, strings, assignments, or choices Brute force over all \(2^n\) subsets

These names are useful shortcuts, but they are not substitutes for analysis. A loop does not automatically imply linear time, and nested loops do not automatically imply quadratic time. The bound depends on how many times the relevant operations execute.

Let's see an example of an algorithm with each of the given complexities.

Example 14: Constant Time

The following code returns the fifth element of an array.

    int theFifthElement(int a[], int n) {
        return a[4];
    }

Array indexing and returning a value both take constant time. The number of operations does not depend on \(n\), so the complexity is \(\Theta(1)\).

Example 15: Logarithmic Time

Binary search is a typical logarithmic-time algorithm. Each iteration compares the target value to the middle element of the current search range and then discards about half of the remaining elements.

Since the input size is repeatedly divided by \(2\), the number of iterations is proportional to the number of times \(n\) can be divided by \(2\) before reaching \(1\). Therefore, binary search has worst-case complexity \(\Theta(\log n)\). If you aren't buying it that quickly, do not fret. We will give a more detailed analysis of binary search shortly.

Example 16: Linear Time

The following code sums all \(n\) entries in an array.

    int sumArray(int a[], int n) {
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum = sum + a[i];
        }
        return sum;
    }

The loop executes exactly \(n\) times, and each iteration takes constant time. Therefore, the complexity is \(\Theta(n)\).

Example 17: \(n \log n\) Time

Many efficient sorting algorithms, such as merge sort, have running time \(\Theta(n \log n)\). Very roughly, merge sort has \(\log n\) levels of recursive splitting, and each level performs \(\Theta(n)\) total work to merge the pieces.

Multiplying the work per level by the number of levels gives \[ \Theta(n)\cdot \Theta(\log n)=\Theta(n\log n). \] We will analyze algorithms like this more carefully when we study recurrences.

Example 18: Quadratic Time

The following code computes all pairwise sums from an array.

    int[][] pairSums(int a[], int n) {
        int sums[][] = new int[n][n];

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                sums[i][j] = a[i] + a[j];
            }
        }

        return sums;
    }

The inner loop executes \(n\) times for each of the \(n\) iterations of the outer loop. Thus, the assignment executes \(n\cdot n=n^2\) times, so the complexity is \(\Theta(n^2)\).

Example 19: Polynomial Time

Standard matrix multiplication is a common cubic-time algorithm.

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            C[i][j] = 0;
            for (int k = 0; k < n; k++) {
                C[i][j] = C[i][j] + A[i][k] * B[k][j];
            }
        }
    }

There are three nested loops, each with \(n\) iterations. The innermost statement executes \(n\cdot n\cdot n=n^3\) times, so the complexity is \(\Theta(n^3)\). Since \(n^3\) is \(n^k\) for the constant \(k=3\), this is polynomial time.

Later we will see an algorithm for matrix multiplication with a more interesting complexity. It is called Strassen's Algorithm and it has a complexity of about \(\Theta(n^{2.8})\) (to be precise, its complexity is \(\Theta(n^{\log_2 7})\)).

Example 20: Exponential Time

Suppose an algorithm prints every subset of an \(n\)-element set. Each element is either included or not included in a subset, so there are \(2^n\) possible subsets.

Since the algorithm must output \(2^n\) subsets, its running time is at least proportional to \(2^n\). A straightforward brute-force version therefore has complexity \(\Theta(2^n)\), which is exponential.

Generally speaking, we consider algorithms whose complexity is no worse than polynomial to be efficient. This does not mean that every polynomial-time algorithm is practical. For example, an algorithm with complexity \(\Theta(n^{100})\) would be polynomial, but it would be useless for all but very small inputs. Still, polynomial time is an important dividing line because, for a fixed exponent, the running time grows much more slowly than exponential functions such as \(2^n\).

Exponential-time algorithms are usually considered impractical, especially when \(n\) can be even moderately large. There are exceptions, however. Sometimes the problem itself requires exponential work. For example, an algorithm that lists all subsets of an \(n\)-element set must take at least \(\Theta(2^n)\) time because there are \(2^n\) subsets to output. In that case, the exponential running time is caused by the size of the required output, not by a poor algorithm design.

Summary

Algorithm analysis describes how the work done by an algorithm grows as the input size grows. We usually do this by choosing representative operations, counting how often they execute, and simplifying the result using asymptotic notation.

Remember that a faster-growing complexity function describes a slower-scaling algorithm. For example, \(n^2\) grows faster than \(n\), so a \(\Theta(n^2)\) algorithm generally scales worse than a \(\Theta(n)\) algorithm.

The examples on this page used relatively simple loops and familiar operations. On the Algorithm Analysis Examples page, we will apply the same ideas to algorithms where the loop bounds, helper methods, or data structures make the analysis less direct.

Reading Comprehension Questions

  1. Measuring Running Time: Why do we usually avoid using wall-clock time as the main way to compare algorithms?
  2. Input Size: For an algorithm that takes an array of \(n\) values and one extra number \(val\), why do we usually say the input size is \(n\), not \(n+1\)?
  3. Representative Operation: In sequential search, why is the comparison a[i] == val a reasonable operation to count?
  4. Best Case: Why is the best-case complexity of sequential search \(\Theta(1)\) even when the input array has size \(n\)?
  5. Average Case: Why is average-case analysis usually more difficult than worst-case analysis?
  6. Method Calls: Why is it dangerous to assume that a method call inside a loop takes constant time?
  7. Nested Loops: Explain why nested loops do not automatically imply \(\Theta(n^2)\) running time.
  8. Growth Rates and Algorithms: If \(n^2\) grows faster than \(n\), why do we say a \(\Theta(n)\) algorithm scales better than a \(\Theta(n^2)\) algorithm?

In-Class Activities

  1. Timing Is Not Enough: Suppose two students implement algorithms for the same problem. One program takes \(12\) seconds on a laptop, and the other takes \(4\) seconds on a lab computer. In small groups, list at least four reasons why this does not prove that the second algorithm is better. Then decide what information would make the comparison more meaningful.
  2. Choose the Input Size: For each situation below, identify a reasonable input-size variable or variables. Briefly justify your choices.
    1. An algorithm processes an array of \(n\) integers and a target value.
    2. An algorithm processes an \(r\) by \(c\) image.
    3. An algorithm processes a graph with \(V\) vertices and \(E\) edges.
    4. An algorithm processes two arrays, one of size \(n\) and one of size \(m\).
  3. What Should We Count? Consider the following code.
    int countPositive(int a[], int n) {
        int count = 0;
        for (int i = 0; i < n; i++) {
            if (a[i] > 0) {
                count++;
            }
        }
        return count;
    }

    Identify two reasonable operations to count. For each choice, decide whether it captures the growth rate of the whole algorithm.
  4. Best, Worst, and Average Inputs: For sequential search on an array of size \(8\), write down one input/search-value pair that gives the best case and one that gives the worst case. Then describe what assumptions would be needed before computing an average case.
  5. Same Code Shape, Different Costs: The following loop looks simple.
    int total = 0;
    for (int i = 0; i < n; i++) {
        total = total + list.get(i);
    }

    Analyze it twice: first assuming list.get(i) takes \(\Theta(1)\) time, and then assuming list.get(i) takes \(\Theta(i)\) time. Explain why the answers differ even though the loop structure did not change.
  6. Loop Bounds Matter: Each of the following fragments contains nested or repeated loops. For each one, count how many times x++ executes and give the complexity. Be careful: the answer is not determined just by the number of loops.
    1. for (int i = 0; i < n; i++) {
          for (int j = 0; j < 20; j++) {
              x++;
          }
      }
    2. for (int i = 0; i < 20; i++) {
          for (int j = 0; j < n; j++) {
              x++;
          }
      }
    3. for (int i = 0; i < n; i++) {
          x++;
      }
      for (int j = 0; j < n * n; j++) {
          x++;
      }
    4. for (int row = 0; row < r; row++) {
          for (int col = 0; col < c; col++) {
              x++;
          }
      }
  7. Classify the Complexity: Match each expression with its common name: constant, logarithmic, linear, linearithmic, quadratic, polynomial, or exponential. \[ \Theta(1),\quad \Theta(n^2),\quad \Theta(2^n),\quad \Theta(n\log n),\quad \Theta(\log n),\quad \Theta(n),\quad \Theta(n^5) \] Then rank the expressions in increasing order of growth.
  8. Faster-Growing Versus Faster Algorithm: Work with a partner to explain the following statement in your own words: "A faster-growing complexity function describes a slower-scaling algorithm." Use \(\Theta(n)\) and \(\Theta(n^2)\) as your example, and include a short numerical comparison using \(n=10\), \(n=100\), and \(n=1000\).

Homework Problems

  1. Choosing Input Size: For each algorithm description below, identify a reasonable input-size variable or variables. Briefly justify your answer.
    1. An algorithm checks whether a word of length \(n\) is a palindrome.
    2. An algorithm multiplies two \(n\) by \(n\) matrices.
    3. An algorithm determines whether a graph with \(V\) vertices and \(E\) edges is connected.
    4. An algorithm compares every value in an array of size \(n\) with every value in an array of size \(m\).
  2. Representative Operation: Consider the following algorithm.
    int countOccurrences(int a[], int n, int val) {
        int count = 0;
    
        for (int i = 0; i < n; i++) {
            if (a[i] == val) {
                count++;
            }
        }
    
        return count;
    }
    1. Choose a representative operation to count.
    2. How many times does that operation execute?
    3. Give the best-case, worst-case, and average-case complexities.
    4. Explain why this differs from sequential search, even though both compare array entries to val.
  3. Best Case Is Not Smallest Input: Consider the following algorithm.
    boolean containsNegative(int a[], int n) {
        for (int i = 0; i < n; i++) {
            if (a[i] < 0) {
                return true;
            }
        }
    
        return false;
    }
    1. Describe an input of size \(n\) that results in the best case complexity.
    2. Describe an input of size \(n\) that results in the worst case complexity.
    3. Give the best-case and worst-case complexities.
    4. Explain why the best case is not found by setting \(n=0\).
  4. Hidden Cost of a Method Call: Suppose bag.contains(x) takes \(\Theta(1)\) time in one implementation and \(\Theta(n)\) time in another implementation, where \(n\) is the number of items in bag. Analyze the following code under both assumptions.
    int countMatches(int a[], int m, Bag bag) {
        int count = 0;
    
        for (int i = 0; i < m; i++) {
            if (bag.contains(a[i])) {
                count++;
            }
        }
    
        return count;
    }
    1. Give the complexity if bag.contains(x) takes \(\Theta(1)\) time.
    2. Give the complexity if bag.contains(x) takes \(\Theta(n)\) time.
    3. Explain why the answer should use both \(m\) and \(n\) in the second case.
  5. Loop Bounds: For each code fragment below, count how many times x++ executes and give the tight asymptotic complexity.
    1. for (int i = 0; i < n; i++) {
          x++;
      }
      
      for (int j = 0; j < n * n; j++) {
          x++;
      }
    2. for (int i = 0; i < n; i++) {
          for (int j = 0; j < 5; j++) {
              x++;
          }
      }
    3. for (int row = 0; row < r; row++) {
          for (int col = 0; col < c; col++) {
              x++;
          }
      }
    4. for (int i = 0; i < n * n; i++) {
          for (int j = 0; j < 3; j++) {
              x++;
          }
      }
  6. Rank the Algorithms: Suppose several algorithms solve the same problem and have the following worst-case complexities: \[ \Theta(n),\quad \Theta(n^2),\quad \Theta(\log n),\quad \Theta(2^n),\quad \Theta(n\log n),\quad \Theta(1). \]
    1. Rank them from best-scaling to worst-scaling.
    2. Which of these would usually be considered efficient?
    3. Explain why "best-scaling" does not always mean "fastest on every input."
  7. Output Size and Exponential Time: An algorithm prints every binary string of length \(n\).
    1. How many binary strings of length \(n\) are there?
    2. Why must any algorithm that prints all of them take at least exponential time?
    3. Explain why this exponential running time is not necessarily evidence of a poor algorithm.