TAIL RECURSION - EXAMPLES AND DISCUSSION

What is tail recursion?

If the last instruction of a function f() is another function call (be it itself or some other function), then it is tail recursion.

Examples of tail recursion:

foo(a) {    // this is tail recursion because foo()'s stack space is not released until the bar() call returns.
  print a;
  bar(a);
}

Another example:

foo1(a) {    // example of a tail recursion where a function calls itself at the end instead of some other function
    if(a<=1) return;
    print a;
    foo1(a-1);
}

Example of a recursive function that does not have tail recursion:

int Factorial(n) {
    if(n<=1) return 1;
    return n*Factorial(n-1);
}

Even though the above may appear like a tail recursion, it is NOT. The reason is that the last instruction in the Factorial(n) is not Factorial(n-1) but it is the multiplication operation between n and the answer of Factorial(n-1). In other words, because there is some operation that is left to happen inside Factorial(n) after the call to Factorial(n-1) returns, it is not a case of tail recursion, and what that means is that it is imperative to keep the function space of Factorial(n) alive during the entire execution of Factorial(n-1).

Does tail recursion matter?

The answer is - it depends! As discussed in class, tail recursion is bad because it allows an unnecessary growth in stack space and could be a cause of stack overflows. In some cases, we may be able to easily avoid stack overflow by replacing tail recursion with an iteration. However, there is no guarantee that eliminating tail recursion will always provide this benefit. Let us consider a few examples below.

Example where eliminating tail recursion could make a difference:

The above foo1() could be modified into an equivalent non-tail-recursive version as follows:

foo1_no_tail_rec(a) {
    if(a<=1) return;
    i=a;
    while(i>1) {        // this loop basically substitutes the tail recursive call in the original function
        print i;
        i--; 
    }
}

In this example, by attempting to remove the tail recursion, we have totally eliminated stack growth beyond the foo1_no_tail_rec() function call. So this version will be more space/memory-efficient than the original version with tail recursion.

Example where eliminating tail recursion need not make any difference:

We can modify the above Factorial(n) function into one that is tail-recursive as follows:

int Factorial_tail_rec(n, value) {
    if(n<=1) return value;
    return Factorial_tail_rec(n-1, n*value);
}

This version is tail recursive because the inner call to Factorial_tail_rec() is really the end statement of the caller instance. In other words, there is absolutely no need to keep the function space for the Factorial_tail_rec(n..) alive when executing Factorial_tail_rec(n-1..) call, as the return value is directly returned without being used anywhere.

Now, how about the stack space usage between the two versions: ie., Factorial() vs. Factorial_tail_rec() ?

For stack space usage, what really should matter is its "peak" usage during execution. Because, the peak usage is what would determine whether there will be a stack overflow or not. If you compare the peak stack space usage of the two versions, then you would notice that both are going to generate the same depth of calls at their peak:

Factorial(n) => Factorial(n-1) => ...    => Factorial(2) => Factorial(1)

Factorial_tail_rec(n,n*1) => Factorial_tail_rec(n-1,n*n-1*1) => ...    => Factorial(2,n*n-1*n-2*...*2*1) => Factorial(1,n*n-1*n-2*...*2*1*1)

This means that in this case, eliminating tail recursion in this way is not really any more space efficient than the one with tail recursion!

Fortunately for this example there is another way to eliminate tail recursion:

int Factorial_no_tail_rec_2(n) {
    if(n<=1) return value;
    value = 1;
    i=n;
    while(n>1) {
        value = value*i;
        i--;
    }
    return value;
}

Now, this version's peak stack space usage is going to be far less when compared to either of the other two versions. The moral of this example is that, when asked to eliminate tail recursion, we are better off looking for a version that makes at least some (if not a whole lot of) peak stack space usage, provided one such version exists.

Postscript:

Now put the example we discussed in class for the Fibonacci number in context.

int Fib(n) {
    if(n<=1)    return 1;
    return Fib(n-1) + Fib(n-2);
}

It should now be clear to see that the above version does NOT have a tail recursion because of the addition operator. So there is no tail recursion to eliminate here. That said, the peak stack space usage can be drastically reduced by resorting to a completely non-recursive version of this function (recall the array based iterative implementation discussed in class).