Note for the reader: A lot of assumptions were made regarding the runtime of functions involving lists and strings. The actual runtime of these functions may even vary between different versions of python and vary even more when implemented on different programming languages. Runtimes for these were labeled as generic functions up until keeping them that way would complicate the result.
A recursive function is a function defined using itself. It is often a technique used when a problem can be solved by solving itself with a different input.
Let's look at an application using the Factorial Function. The factorial of is defined as the product of all positive integers less than or equal to :
Notice how the factors after can be rewritten as , and thus:
Which is now a function defined by the result of the same function with a smaller input and we can now write this as a recursive function:
Wait a minute, the code doesn't work?! We may have forgotten something. Our recursive function will continue to call itself indefinitely. Thus, we need to introduce a stop condition or what we call the base case. For the factorial function this is when reaches and the only positive integer less than or equal to it is itself. Using this, we can now properly end the recursion and actually compute the factorial:
Similar to how we define the function with itself, the running time, , of a recursive function or algorithm is defined with itself as well. This is called a recurrence relation. As an example, let's try and formulate the recurrence relation of the factorial function defined above:
When , is equal to the runtime of factorial(n-1)
plus the constant runtime of the other operations which are the multiplication, evaluation of the conditional, and calling and returning between functions which is lumped into a constant . Then, the runtime is also constant for the base case. Using the recurrence relation, there are several ways to get the time complexity.
For this method, we look for a pattern when we try to expand the recurrence relation and try to rewrite the recurrence relation without the term. Let's try expanding the recurrence relation for the factorial function above:
Notice that whatever is subtracted to the term inside the recursive call is multiplied to the term. We can substitute in lieu of this constant, and thus we can rewrite the recurrence relation as:
Note that this can be proven through induction but that is left as an exercise for some other time. Next, recall that when , simplifies to . So if we let :
Lastly, we get the asymptotic :
This is a more visual approach wherein we look at a recursive algorithm as a tree that branches out depending on the recurrence relation. Let us consider the recursive function below, binary(n)
, which prints all the n
-digit binary numbers:
Note that all n
-digit binary numbers are composed of a n-1
-digit number appended with either 1
or 0
. This is equivalent to the two recursive calls binary (n-1, b + "0")
and binary(n-1, b + "1")
. The argument b
acts as a temporary storage
The recurrence relation of the function can be expressed as:
where is the runtime of all other non-recursive instructions (i.e. evaluating the conditional, concatenation strings) and is the runtime of the base case (i.e. printing a string with x
characters). The substitution method is still applicable but this section is not about that method so that can be done at another time.
A recursion tree is basically a graphical way of expanding the recurrence relation:
In the final form of the tree, only the and terms remain since all the recursive calls have been 'expanded'. To get the time complexity from the recursion tree, we just need to add all of the terms, which looks easier said than done at this point.
I would help to put labels depending on how deep a node is in the recursion tree like so:
This way, we can first determine the total runtime per level, which should be easier, before getting the total runtime. For level , we get that the total runtime is:
For the succeeding level we get:
Then for level :
We should probably notice a pattern for the per-level subtotal runtime:
Which we can confirm by plugging in and verifying the result from the tree. At this point, it would be pretty difficult to proceed without knowing the actual expression for . For the purposes of this discussion, we assume that or a constant operation. Thus, we can compute the total runtime as:
Notice that we have a special case for the last level which is multiplied to instead. This is also referred to as the depth of the recursion which is sometiems used outside of complexity analysis as well.
Finding can also be done in multiple ways. One of the simplest ways is to deduce it based on our observations of the recurrence tree as well. The total runtime for level based on our equation above is:
But, from the tree itself, we know that level is made up of terms only. Thus:
Substituting this back to we get:
Note that:
Assuming is linear or , the worst case time complexity of binary(n)
would be:
Auxiliary space or also known as stack depth is the additional program stack memory needed by the function or algorithm denoted as . Auxiliary space complexity describes the growth rate of the needed auxiliary space of the program or algorithm. Similar to time complexity, the various asymptotic notations are considered for this (, , big-) and the analysis is similar as well.
So far, you may have only encountered programs and algorithms which require a constant amount of space to store temporary variables and counters. Thus, the space complexities of these functions are most likely constant or . However, for recursive functions, the function itself is called multiple times, and thus multiple copies of the function are stored in the program stack. As an example, below is a visualization of how the program stack fills up when we run factorial(5)
:
With factorial(5)
the factorial
is called times, and we can even predict that calling factorial(n)
would mean factorial
would be called times. Since the function itself has a constant space complexity, we can say that the auxiliary space complexity is linear with respect to or
However, simply counting the number function calls isn't exactly correct. Let's look at the visualization of the program stack when we call binary(3)
:
As you can see, not all the function calls appear at the stack at the same time and this is true for most recursive functions. When binary(0)
is reached, the function returns and frees up some space before binary(1)
makes another function call. Thus, we can generalize that the functions in a 'path' from the topmost level (binary(n)
) to the deepest level (binary(0)
) would be the most memory needed by a recursive function as shown here:
is the memory needed to store the function itself in memory and is the memory needed to store one character in a string which is used as a function argument. Knowing that there are at most functions in the call stack, the total memory needed can be computed as:
We already know that and that the second term is just the sum of integers from to . Thus, the space complexity of binary(n)
is:
Program stack limitations put recursive functions at a disadvantage. Consider an iterative version of factorial(n)
below:
The iterative version features a for loop with iterations equal to so this version would also have a linear or time complexity. However, the iterative function only has a constant space complexity which makes it far more superior to the recursive version.
So recursion made it worse? For making a function that calculates the factorial of a number, that would be a yes. However, this would not be true for all recursive functions. Suppose we want to implement binary(n)
in an iterative way:
Assuming the array all
would contain all the binary numbers with i
digits during the end of each iteration, the inner for
loop would then append either 1
or 0
to generate all the i+1
-digit binary numbers for the i+1
th iteration, making it function similarly to our previous recursive implementation. You can see in the comments the time complexity analysis of each line.
Starting with the innermost append
commands, these are actually two commands in a single line where the runtime can be temporarily labeled as f(t, b)
. The loop containing this iterates depending on the number of elements in all
. Assuming that f(t, b)
is constant or O(1)
, the inner loop would then have a complexity dependent on the length of all
or O(len(all))
. The all = t
command copies the contents of t
to all
and would scale linearly with the lenght of t
so that line is O(len(t))
. This brings the time complexity of the outerloop to be O(n * len(all))
considering that the len(t)
and len(all)
are equal after every loop iteration. Meanwhile, at the end of the function is a loop that prints all the results. Assuming the runtime of the print operation takes longer when the printed string gets longer, this loop overall would have a O(len(all) * len(a))
time complexity. However, we know we are printing n
-digit binary numbers so len(a) = n
which means the loop is O(n*len(all))
. We know all
would contain, in the final loop iteration, elements since that is how many n
-digit binary numbers exist.
So, (tl;dr) binary_i(n)
is also like the original recursive binary(n)
.
Looking at the space complexity, binary_i(n)
uses a temporary list t
to store the i
-digit numbers at the end of each loop. At the last iteration, t
would contain elements with each element being an n-1
-digit number which means a of additional space on the last iteration. If we just needed to print these numbers, then the original binary(n)
with a quadractic or memory complexity is significantly better.
So it looks like the recursive binary(n)
function was able to utilize the way the program stack works to actually use less memory than needed compared to an iterative approach. This was done through what is commonly referred to as backtracking. Backtracking is being able to undo your last action and do something else instead.
For the binary(n)
function, we can consider adding a 1
or a 0
to be one action. The function first adds only 0
's to generate the first n
-digit number (00...00
). When the final binary()
function call terminates, the previous function call resumes, effectively causing a backtracks. This removes the last 0
and adds 1
instead (00...01
). This would then terminate the last 2 function calls making the program backtrack twice, add a 1
instead, and the process repeats for the last digit as well.
Backtracking effectively modifies a currently existing 'solution' to look for another 'solution' instead of creating another 'solution' from scratch, or, in the case of binary_i(n)
, storing all the partial solutions in a list which increased space complexity. Because of the program stack, an 'action' is automatically undone when a function terminates and a different 'action' or function call can be made to explore another 'solution'.
While recursive functions seem very complex, they could potentially be the simpler choice in the correct applications. Sometimes, recursive functions can even 'translate' more easily to spoken language.
Consider binary(n)
and binary_i(n)
. In binary(n)
, knowing that adding either 1
or 0
at the end of all n-1
digit numbers would result to all the n
-digit numbers and that can be precisely seen in the code. On the other hand, binary_i(n)
essentially does the same but with extra steps like temporary storage and nested for
loops. While this may not be the best iterative implementation of the binary(n)
function, needing to come up with a different approach is still an additional challenge.
Recursive functions are functions which call themselves and are especially helpful when a problem can be solved with a smaller version of itself. Getting the time complexity of a recursive function always starts with the recurrence relation which would probably have the form:
where would describe how the input is modified for each recursive call in the function (i.e. for binary(n)
there were two recursive calls that modified the input the same way: ), describes the runtime of the non-recursive function calls and commands, is the runtime of the base case which could be a function of different paramaters, and indicates when the base case occurs. The recurrence relation can be solved in multiple ways:
A. Substitution Method
B. Recursion Tree Method
Recursion uses additional space since functions do not finish until the base case is reached. Space complexity is computed similarly to time complexity using the recursion tree method where the memory needed for each function call is considered instead of the runtime. However, only the 'deepest' path from the initial function call to one of the base cases is considered since memory is freed up when a function terminates.
Additional space used by recursion may even be beneficial when used correctly for the right application. Even though there are some additional considerations when using recursive functions, they would often be the best solution to several problems that are can be broken down to simpler versions of themselves.