…read Section 1
In this part of the article we will focus on estimating the time complexity for recursive programs. In essence, this will lead to finding the order of growth for solutions of recurrence equations. Don’t worry if you don’t understand what exactly is a recurrence solution, we will explain it in the right place at the right time. But first we will consider a simpler case – programs without recursion.
Nested loops
First of all let’s consider simple programs that contain no function calls. The rule of thumb to find an upper bound on the time complexity of such a program is:
estimate the maximum number of times each loop can be executed,
add these bounds for cycles following each other.
multiply these bounds for nested cycles/parts of code,
Example 1. Estimating the time complexity of a random piece of code.
1
2
3
4
5
6
7
8
9
10
11
12
13
int result = 0; // 1
for (int i = 0; i < N; i++) // 2
for (int j = i; j < N; j++) { // 3
for (int k = 0; k < M; k++) { // 4
int x = 0; // 5
while (x < N) {
result++;
x += 3;
} // 6
} // 7
for (int k = 0; k < 2 * M; k++) // 8
if (k % 7 == 4) result++; // 9
} // 10
The time complexity of the while-cycle in line 6 is clearly O(N) – it is executed no more than N/3 + 1 times.
Now consider the for-cycle in lines 4-7. The variable k is clearly incremented O(M) times. Each time the whole while-cycle in line 6 is executed. Thus the total time complexity of the lines 4-7 can be bounded by O(MN).
The time complexity of the for-cycle in lines 8-9 is O(M). Thus the execution time of lines 4-9 is O(MN + M) = O(MN).
This inner part is executed O(N2) times – once for each possible combination of i and j. (Note that there are only N(N + 1)/2 possible values for [i, j]. Still, O(N2) is a correct upper bound.)
From the facts above follows that the total time complexity of the algorithm in Example 1 is O(N2.MN) = O(MN3).
From now on we will assume that the reader is able to estimate the time complexity of simple parts of code using the method demonstrated above. We will now consider programs using recursion (i.e. a function occasionally calling itself with different parameters) and try to analyze the impact of these recursive calls on their time complexity.
Using recursion to generate combinatorial objects
One common use of recursion is to implement a backtracking algorithm to generate all possible solutions of a problem. The general idea is to generate the solution incrementally and to step back and try another way once all solutions for the current branch have been exhausted.
This approach is not absolutely universal, there may be problems where it is impossible to generate the solution incrementally. However, very often the set of all possible solutions of a problem corresponds to the set of all combinatorial objects of some kind. Most often it is the set of all permutations (of a given size), but other objects (combinations, partitions, etc.) can be seen from time to time.
As a side note, it is always possible to generate all strings of zeroes and ones, check each of them (i.e. check whether it corresponds to a valid solution) and keep the best found so far. If we can find an upper bound on the size of the best solution, this approach is finite. However, this approach is everything but fast. Don’t use it if there is any other way.
Example 2. A trivial algorithm to generate all permutations of numbers 0 to N – 1.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
vector permutation(N);
vector used(N, 0);
void
try (int which, int what) {
// try taking the number “what” as the “which”-th element
permutation[which] = what;
used[what] = 1;
if (which == N - 1)
outputPermutation();
else
// try all possibilities for the next element
for (int next = 0; next < N; next++)
if (!used[next])
try (which + 1, next);
used[what] = 0;
}
int main() {
// try all possibilities for the first element
for (int first = 0; first < N; first++)
try (0, first);
}
In this case a trivial lower bound on the time complexity is the number of possible solutions. Backtracking algorithms are usually used to solve hard problems – i.e. such that we don’t know whether a significantly more efficient solution exists. Usually the solution space is quite large and uniform and the algorithm can be implemented so that its time complexity is close to the theoretical lower bound. To get an upper bound it should be enough to check how much additional (i.e. unnecessary) work the algorithm does.
The number of possible solutions, and thus the time complexity of such algorithms, is usually exponential – or worse.
Divide&conquer using recursion
From the previous example we could get the feeling that recursion is evil and leads to horribly slow programs. The contrary is true. Recursion can be a very powerful tool in the design of effective algorithms. The usual way to create an effective recursive algorithm is to apply the divide & conquer paradigm – try to split the problem into several parts, solve each part separately and in the end combine the results to obtain the result for the original problem. Needless to say, the “solve each part separately” is usually implemented using recursion – and thus applying the same method again and again, until the problem is sufficiently small to be solved by brute force.
Example 3. The sorting algorithm MergeSort described in pseudocode.
1
2
3
4
5
6
7
8
MergeSort(sequence S) {
if (size of S <= 1) return S;
split S into S_1 and S_2 of roughly the same size;
MergeSort(S_1);
MergeSort(S_2);
combine sorted S_1 and sorted S_2 to obtain sorted S;
return sorted S;
}
Clearly O(N) time is enough to split a sequence with N elements into two parts (Depending on the implementation this may be even possible in constant time.) Combining the shorter sorted sequences can be done in (N): Start with an empty S. At each moment the smallest element not yet in S is either at the beginning of S1 or at the beginning of S2. Move this element to the end of S and continue.
Thus the total time to MergeSort a sequence with N elements is (N) plus the time needed to make the two recursive calls.
Let f (N) be the time complexity of MergeSort as defined in the previous part of our article. The discussion above leads us to the following equation:
(1)
(2)
(3)
level | 1 | 2 | 3 | … |
---|---|---|---|---|
work | cN3 | cN3 | cN3 | … |
Clearly as we go deeper in the tree, the total amount of work on the current level decreases. The question is, how fast does it decrease? As we move one level lower, there will be three times that many subproblems. However, their size gets divided by 2, and thus the time to process each of them decreases to one eighth of the original time. Thus the amount of work is decreased by the factor 3/8.
But this means that the entries in the table above form a geometric progression. For a while assume that this progression is infinite. Then its sum would be
level | 1 | 2 | 3 | … |
---|---|---|---|---|
work | cN3 | cN3 | cN3 | … |
This time we have the opposite situation: As we go deeper in the tree, the total amount of work on the current level increases. As we move one level lower, there will be five times that many subproblems, each of them one third of the previous size, the processing time is linear in problem size. Thus the amount of work increased by the factor 5/3.
Again, we want to compute the total amount of work. This time it won’t be that easy, because the most work is done on the lowest level of the tree. We need to know its depth.
The lowest level corresponds to problems of size 1. The size of a problem on level k is N/3k. Solving the equation 1 = N/3k we get k = log3N. Note that this time we explicitly state the base of the logarithm, as this time it will be important.
Our recursion tree has log3N levels. Each of the levels has five times more vertices than the previous one, thus the last level has levels. The total work done on this level is then .
Note that using the trick (3) we may rewrite this as .
Now we want to sum the work done on all levels of the tree. Again, this is a geometric progression. But instead of explicitly computing the sum, we now reverse it. Now we have a decreasing geometric progression…and we are already in the same situation as in the previous example. Using the same reasoning we can show that the sum is asymptotically equal to the largest element.
It follows that the total amount of work in our tree is and we are done.
Note that the base-3 logarithm ends in the exponent, that’s why the base is important. If the base was different, also the result would be asymptotically different.
The Master Theorem
We already started to see a pattern here. Given a recurrence equation, take the corresponding recurrence tree and compute the amounts of work done on each level of the tree. You will get a geometric sequence. If it decreases, the total work is proportional to work done in the root node. If it increases, the total work is proportional to the number of leaves. If it remains the same, the total work is (the work done on one level) times (the number of levels).
Actually, there are a few ugly cases, but almost often one of these three cases occurs. Moreover, it is possible to prove the statements from the previous paragraph formally. The formal version of this theorem is known under the name Master Theorem.
For reference, we give the full formal statement of this theorem. (Note that knowing the formal proof is not necessary to apply this theorem on a given recurrence equation.)
Let a 1 and b > 1 be integer constants. Let p be a non-negative non-decreasing function. Let f be any solution of the recurrence equation
If for some > 0 then
If , then f (N) = (p(N)log N).
If for some > 0, and if ap(N/b) cp(N) for some c < 1 and for almost all N, then f (N) = (p(N)).
Case 1 corresponds to our Example 7. Most of the time is spent making the recursive calls and it’s the number of these calls that counts.
Case 2 corresponds to our Example 5. The time spent making the calls is roughly equal to the time to prepare the calls and process the results. On all levels of the recursion tree we do roughly the same amount of work, the depth of the tree is always logarithmic.
Case 3 corresponds to our Example 6. Most of the time is spent on preparing the recursive calls and processing the results. Usually the result will be asymptotically equal to the time spent in the root node.
Note the word “usually” and the extra condition in Case 3. For this result to hold we need p to be somehow “regular” – in the sense that for each node in the recursion tree the time spent in the node must be greater than the time spent in its chidren (excluding further recursive calls). This is nothing to worry about too much, most probably all functions p you will encounter in practice will satisfy this condition (if they satisfy the first condition of Case 3).
Example 8. Let f (N) be the time Strassen’s fast matrix multiplication algorithm needs to multiply two N x N square matrices. This is a recursive algorithm, that makes 7 recursive calls, each time multiplying two (N/2) x (N/2) square matrices, and then computes the answer in (N2) time.
This leads us to the following recurrence equation: