Running Time and Recurrences
Basics of Big-Oh Notation
We will assume that you are already somewhat familiar with the concept of the big-oh notation for describing the growth of functions, but here is a quick review. Recall that, when comparing growth rates of functions, say and , we only care about the long-run behavior (i.e., for large enough values of ), and we ignore constant scaling factors. That is, we consider and to have the same growth rate (both are ), and both grow faster than (which is only ), even though is less than for all less than . The reasoning is that the time (or some other resource, such as memory or disk space) used for small values of might be an inessential overhead or "startup" cost of one program over another, and even a constant scaling factor might be insignificant when running on different hardware. The big-oh definition of growth rate thus allows us to compare the overall scaling behavior of algorithms without needing to know implementation details. It allows us to answer questions such as "if I need to process ten times the number of transactions, how much longer will it take?" or "if we double the amount of memory available, how much more input could we handle?"
The formal definition of a function1 having another function as an upper-bound in terms of growth rate, which we express by saying that , is:
That is, there is some constant factor such that is never more than times , whenever is at least as large as . Notice that we are only saying that is an upper-bound of ; there is nothing to stop us from making a very weak statement such as , even though is actually linear in .
Although the definition allows us to compare any two functions and , we will usually take to be one of the following common "representative" functions, ordered by increasing growth rate:
Function | Common name for |
---|---|
Constant | |
Logarithmic | |
Linear | |
Quasilinear | |
Quadratic | |
Cubic | |
Exponential | |
Factorial |
A constant growth rate means that the function does not depend on its input; a program that runs in constant time will do the same work for ten items as for ten million. A logarithmic growth rate is often almost as good, at least for practical purposes: for example, , so growing the size of the input by a factor of a thousand might only add ten "steps" to the operation; even billions of input items may only require a few dozen steps. This example uses logarithms base two, which matches common algorithms where problems are broken up into smaller problems of half the size, but in fact the base of the logarithm does not matter for big-oh, because for any constants and greater than one (more precisely, , so the constant factor is ). Therefore, we will usually omit the base of the logarithm when talking about growth rates.
A linear growth rate is often the best that can be expected of a program that actually has to look at all of its input, since the act of just reading items must take time at least proportional to . Quasilinear growth is again almost as good for most practical purposes; going from twenty million operations to process a million items, to thirty billion operations to process a billion items, is not much of a penalty.
A quadratic (or cubic, or higher polynomial) growth rate starts to become a problem. Every time the input grows by a factor of one thousand, the running time of a quadratic algorithm will grow by a factor of one million. While it might be practical to take millions of steps to process thousands of inputs, it will probably be unacceptable to require trillions of steps when the input size is in the millions.
Beyond that, exponential and factorial growth rates are completely infeasible for all but the smallest input sizes. Since is around one billion, a modern processor should be able to handle thirty input items, but for each additional item the running time will double; at forty items it will already take a trillion steps. Should your exponential algorithm be presented with one hundred items, , so it will take more time to complete than the current age of the universe, even if your computer can perform a trillion operations per second. With factorial growth, , so even thirty items is infeasible.
We will occasional use some related notations:
If is an upper bound of , we will write ("big-omega"); this is the same as saying .
If both and , then we will write ("big-theta"); this says that is a "tight" bound, and has essentially the same growth rate as .
To express that grows strictly slower than , we will write ("little-oh"). One way to define this is when the limit of over approaches zero as goes to infinity:
We will not spend more time here on techniques to work with big-oh notation, other than to observe the very common case where is a sum of terms (such as a polynomial): . If we can determine such that has the fastest growth rate of all of the (that is, for all ), then we may conclude that . For example, if we know that , then we may ignore all of the terms except the first and say that .
Work and Span
As mentioned above, we may use big-oh estimates of function growth to evaluate more than just the running time of an algorithm. We may also be interested in predicting how much memory will be needed, or disk space, or the probability of some combination of events.
In the field of parallel programming, we can generalize the calculation of running times to predict the benefit from splitting a computation across multiple processors. For this, we will need to estimate two related quantities:
The work done by an algorithm is the total number of operations to be performed. It is frequently denoted , because it is the time required if you only have one processor available. This is the usual sequential running time.
The span of an algorithm is the length of the longest "chain of dependencies" in the calculation—that is, it counts the number of steps where the result of each is needed before the next can be performed. It is frequently denoted , because it is also the time required in the ideal case of achieving perfect parallelism across an unlimited number of processors. We have already seen an example of the span when we considered the delay of a combinational circuit.
A result known as Brent's Theorem now allows us to compute bounds on , the time to run the algorithm on processors:
Recurrences for Loops and Recursive Functions
If we have a program that takes some input and computes a result, we may calculate an estimate of the running time of that program.
We will proceed by performing a structural induction on the program code; we will not be doing this formally, but the idea is that our base cases will be the simple statements, such as performing arithmetic and binding values to variables—these will be assumed to take constant time.
The inductive cases will be the compound statements, such as conditionals (if
), loops (for
and while
), and function calls—the times for these will be calculated from the times of their components.
Here is an example in pseudocode that shows most of the cases:
function a(n):var s = 0 // a1for i = 1 to n: // a2s := s + i // a3return s // a4function b(n):if n = 0: // b1return 0 // b2else: // b3return n + b(n - 1) // b4main:var n = input // m1while n > 0: // m2if a(n) != b(n): // m3print "Mismatch" // m4n := n - 1 // m5print "Done" // m6
The main
program here reads one number (n) of input, and then loops that many times, counting down toward zero, checking whether the a
and b
functions (which each take one argument and return the sum from 1 to that number) agree.
For this program, we will be interested in the running time as a function of the input value; for other programs, the interesting parameter might be the number of inputs instead (for example, when sorting or searching).
We will start by calculating the running time of function a
.
The lines labeled a1
, a3
, and a4
are each simple statements that we will assume take time.
The loop in line a2
tells us to repeat line a3
a total of times; since a3
is , the entire loop will be .
Note that we are ignoring any overhead from initializing, continuing, or terminating the loop—at most, they will add another amount to each time through the loop, plus an additional at the beginning and end, but , or whatever, is still .
Adding up all of the time for the body of a
gives a total running time .
Turning to function b
, the line b2
is clearly an operation, but line b4
includes a function call.
If we express the time for function b
as , then we should say that the time for line b4
is .
Although we don't have a closed-form expression for yet, we will assume that it is Somebody Else's Problem to figure out , and rely on their answer to be able to evaluate the larger problem of calculating .
For the conditional (lines b1
and b3
), we start with the time to evaluate the condition—checking whether is zero can be done in constant time.
Since we are interested in an upper bound on the running time, we can then consider the two branches and take their maximum.
The true branch is , while the false branch is , so we will use the latter as our estimate.
Folding in the time for the test gives an overall running time of .
That now gives us a running time for b
given by the recurrence .
A recurrence is really just a recursive function, although rather than evaluating it for a particular we will be more interested in solving the equation to find a closed-form expression for .
For completeness, we should also give a base case for the recurrence: , because if we know that the input is 0 then we can improve our estimate of the time for the if
statement (we know that it will use the true branch).
We will often be sloppy and omit mentioning the base case in the common situation that it only contributes a constant amount of work.
We will discuss several approaches to solving recurrences below, but in this simple example it should be obvious that .
Finally, looking at the main
program, lines m1
, m4
, m5
, and m6
are all .
For the conditional at line m3
, the true branch is and the false branch is missing; if the program works correctly,
we actually expect it to always choose the false branch, but that doesn't mean that the statement takes zero time!
Remember that we also have to account for the time to evaluate the condition—in this case, that involves making two function calls, each of which take time, so the entire if
statement is also .
This leaves us with the while
loop at m2
.
It's body is , but notice that the value of is changing each time through the loop.
We may express the running time of the loop with another recurrence: .
That is, the time to do the while
loop for a given value of is for the body, plus an additional for the remaining times through the loop.2
To perform this calculation, we will temporarily replace the estimate with the exact value ; expanding out the recurrence then gives us the result .
Since the sum of the numbers from 1 to is , this gives us .
Therefore, the total running time of the program is quadratic in its input.
Examples of Recurrence Solution Techniques
Tabulation
Sometimes it helps to make a table of the first few values of a recurrence, to try to see a pattern. If we see a pattern and know a closed-form expression for that pattern, then we can plug it into the recurrence and check whether it works in general.
Consider the recurrence , with base case . Here is a table of the first few values:
If you recognize the values as each being one less than a successive power of two, then you can guess the closed-form expression . Plugging this in to the recurrence equation, we can first check that it works in the base case: . For the general case, we will substitute our expression for each occurrence of in . On the left we get , while on the right we get , which simplifies to , so it checks out.
Repeated Substitution
If we do not see a pattern in the small values, sometimes we can see it instead when working from the general case downward. Again, once we identify a candidate closed-form expression, we can plug it in and check (although by the time we reach that point we will probably already be convinced, because we have already been working with the general recurrence equation).
Consider the recurrence , with base case . Tabulating the first few values might not help (and here I will assume that we are using integer division by two, which discards any fractional part):
Instead, let us start with the general term , and expand it out a few times according to the recurrence:
In the last line, we have recognized a pattern in the previous lines and written an expression for the step of the expansion.
Now, consider what value of will lead us to the base case. The base case is , so we want , or , which will be true when . Plugging this information back into the expanded form above gives us . Sure enough, this checks out: .
Exercises
Give a simple, tight, big-oh upper-bound for the function .
Answer
The expression in parentheses is , so if we cube it the result will be .
What is the big-oh running time of the following program, in terms of the input ?
function f(n):if n = 0:return 0else:return 2 + f(n - 1)main:var n = inputvar s = 0while n > 1:for i = 1 to f(n):s := s + in := n / 2print s
Answer
The function f(n)
computes in time , so the for
loop in main
will execute times for a total running time of .
Therefore, a recurrence for the time taken by the while
loop is .
Expanding this out by repeated substitution gives .
The sum is approximately , so , and therefore the entire program is linear in its input.
Solve the recurrence , with base case .
Answer
By tabulation, the first few values are
so we may guess (and check) the closed-form .
Solve the recurrence to get a tight big-oh upper bound.
Answer
By repeated substitution, we find
To reach the base case (which we may assume is ), we need , so . This gives a final expression .
Solve the recurrence , where and . (Hint: The general form for solutions to this kind of recurrence is , for some constants , , , and . You might be able to see the solution by tabulation, or you can plug the general form in to the recurrence and try to solve for the constants. Another useful fact is that the recurrence will be true for all choices of the coefficients and ; they are only needed to match the base cases.)
Answer
Tabulation gives the following:
Each successive value is roughly three times the previous one, so we might start by looking at the powers of 3:
Calculating the differences between these powers and gives the following:
Those are the powers of two, so we may guess and check that the closed form is .
Alternately, using the hint, we can substitute the general form into the recurrence to get:
Since the recurrence equation will be satisfied for all choices of and , provided we choose the correct and , we may alternately set or to get the following equations:
Dividing by and gives and , so and are two solutions to the quadratic equation . Standard techniques from algebra give us the solutions and . Plugging these into the general form and looking at the base cases gives the equations
These simplify to and , which have the solution and . Therefore, we again find the closed form .
- For this section, all functions are assumed to be from the natural numbers to the positive reals, so that we do not have to worry about negative values.↩
- Technically we should also add an term for the overhead of checking the loop condition and branching back or out of the loop, but since that is dominated by the term it may be ignored.↩