CSC 347 - Concepts of Programming Languages

Tail Recursion

Instructor: Stefan Mitsch

Fibonacci Numbers

  • In Scala 3 new syntax, implement a recursive function to compute the Fibonacci numbers
    1. def fibonacci(n: Int): Int = n match {
    2. case 0 => 0
    3. case 1 => 1
    4. case _ => fibonacci(n - 1) + fibonacci(n - 2)
    5. }
  • Try fibonacci(30) and fibonacci(45)

Fibonacci Numbers

  • Make it fast
    1. def fibonacci(n: Int, memo: mutable.Map[Int, Int] = mutable.Map()): Int = n match {
    2. if n <= 0 then return 0
    3. if n == 1 then return 1
    4. if memo.contains(n) then return memo(n)
    5. // Calculate Fibonacci and store in the map
    6. val result = fibonacci(n - 1, memo) + fibonacci(n - 2, memo)
    7. memo(n) = result
    8. result
    9. }
  • Do we need to keep all the numbers ever computed?

Learning Objectives

How to get recursive iteration without stack penalty and without recomputing intermediate results?

  • Understand tail recursion

Call Stack

  • Contains activation records (AR) for active calls, also known as stack frames
  • Changes to call stack
    • AR pushed when a function/method call is made
    • AR popped when a function/method returns
  • Runtime environments limit size of call stacks?
  • Can cause problems with deep recursion
    • Java, Scala: StackOverflowError
    • C: stack limits set by operating system

Recursion and Stack Limitations

  1. def countDown (x:Int) : Int = if x == 0 then 0 else 1 + countDown (x - 1)
  • Each (1 + ...) represents a new AR
  1. countDown (5)
  2. --> 1 + countDown (4)
  3. --> 1 + (1 + countDown (3))
  4. --> 1 + (1 + (1 + countDown (2)))
  5. --> 1 + (1 + (1 + (1 + countDown (1))))
  6. --> 1 + (1 + (1 + (1 + (1 + countDown (0)))))
  7. --> 1 + (1 + (1 + (1 + (1 + 0))))
  • Summing up left to after the last recursive call returns
  • How does the stack look like?

Tail Recursive Calls

  1. // *tail-recursive functions* because all recursive calls are tail-recursive
  2. def countDownAux (x:Int,result:Int) : Int =
  3. if x == 0 then result
  4. else countDownAux(x-1,1+result) // *tail-recursive call*
  5. def countDown (int x) = countDownAux(x,0)
  1. countDownAux (5,0)
  2. --> countDownAux (4,1)
  3. --> countDownAux (3,2)
  4. --> countDownAux (2,3)
  5. --> countDownAux (1,4)
  6. --> countDownAux (0,5)
  • Result sum computed before recursive call is made, no work left
  • How is the stack now different?

Tail Call Optimization

  • Many compilers implement tail-call optimization
    • overwrite existing activation record instead of creating new
  • Recursive calls must be tail-recursive
  • Includes mutual recursion
    • f calls to g, which calls back to f

Exponential Growth

  • Create list of length
  1. def longList (n:Int) : List[Int] =
  2. if n == 0 then
  3. List (1)
  4. else
  5. val sublist = longList (n - 1)
  6. sublist ::: sublist

Tail Recursion: Scala

  • tailrec annotation
  1. import scala.annotation.tailrec
  2. def sumTailRecursive (xs:List[Int]) : Int =
  3. @tailrec
  4. def aux (xs:List[Int], result:Int) : Int =
  5. xs match
  6. case Nil => result
  7. case y::ys => aux (ys, y + result)
  8. aux (xs, 0)
  1. scala> longList (20).length
  2. res0: Int = 1048576
  3. scala> sumTailRecursive (longList (20))
  4. res1: Int = 1048576

Tail Recursion: Scala

  • tailrec annotation fails if not optimized
  1. import scala.annotation.tailrec
  2. def sumTailRecursive (xs:List[Int]) : Int =
  3. @tailrec
  4. def aux (xs:List[Int], result:Int) : Int =
  5. xs match
  6. case Nil => result
  7. case y::ys => 1 + aux (ys, y + result) // bogus "1 + ..."
  8. aux (xs, 0)
  • Scala compiler rejects the code
    1. error: could not optimize @tailrec annotated method aux:
    2. it contains a recursive call not in tail position

Exercise: Recursive vs. Tail-Recursive Fibonacci

  1. def fib(n:Int) : Long =
  2. if n <= 1 then n
  3. else fib(n-1) + fib(n-2)
  • Time complexity
  • How to improve?
  • fib(0) fib(1) fib(2) fib(3) fib(4) fib(5) fib(6) fib(7) fib(8)
    0 1 1 2 3 5 8 13 21
  • Represent sliding window in result
    (not tail-recursive!)
    1. def fib(n:Int) : (Long, Long) =
    2. if n <= 1 then (0, n)
    3. else
    4. val (a, b) = fib(n-1)
    5. (b, a+b)
  • Represent sliding window in arguments
    (tail-recursive)
    1. def fib(n:Int, a:Long=0, b:Long=1) : Long =
    2. if n == 0 then a
    3. else if n == 1 then b
    4. else fib(n-1, b, a+b)

Tail-recursive Fibonacci Numbers

  • In Scala 3, implement a tail-recursive function to compute the Fibonacci numbers
    1. def fibonacci(n: Int): Int = {
    2. @tailrec
    3. def fibHelper(n: Int, a: Int, b: Int): Int = n match {
    4. case 0 => a
    5. case _ => fibHelper(n - 1, b, a + b)
    6. }
    7. fibHelper(n, 0, 1)
    8. }
  • Specific instructions help generate good code

Translate Loop to Recursion

Loop (mutable data)

  1. def factorial (n:Int) : Int =
  2. val result = 1
  3. var m = n
  4. while m > 1 do
  5. result = result * m
  6. m = m - 1
  7. result
  • Recursive (mutable)

    1. def factorial (n:Int) : Int =
    2. var result = 1
    3. var m = n
    4. def loop () : Unit =
    5. if m > 1 then
    6. result = result*m
    7. m = m-1
    8. loop()
    9. loop()
    10. result
  • Recursive (mutable)

    1. def factorial (n:Int) : Int =
    2. var result = 1
    3. def loop (m:Int) : Unit =
    4. if m > 1 then
    5. result = result*m
    6. loop(m-1)
    7. loop(n)
    8. result
  • Tail-recursive

    1. def factorial (n:Int) : Int =
    2. def loop (m:Int, result:Int) : Int =
    3. if m > 1 then loop(m-1, m*result)
    4. else result
    5. loop(n,1)
  • Recursive (immutable)

    1. def factorial (n:Int) : Int =
    2. def loop (m:Int) : Int =
    3. if m > 1 then m * loop(m-1)
    4. else 1
    5. loop(n)

Summary

  • Tail-call optimization
    • avoids the performance penalty of creating activation records
    • overwrites an existing activation record
    • all recursive calls must be in tail position (last operation)

TODO: attempt to translate into a loop

* Time complexity $O(n)$ (additional penalty for activation records) * Space complexity $O(n)$

* Time complexity $O(n)$ (no penalty for creating activation records) * Space complexity $O(1)$