import time

def f(n):
    print(n)
    f(n)
    
# f(n) does something like an infinite loop - not very useful
# Is there any time where this might be useful?

# Computer Science Topic: Recursion

# Recursive functions are functions that call themselves
# This method of creating functions is not always the best way
# or even the most efficient, but it has a structure that can
# make expressing the solution to some problems 
# very simple (and in some cases, beautiful).

# Let's look at the 'factorial' problem: n!
# An iterative version of factorial. We are 'iterating' using a loop
def factorial_iter(n):
    '''Return the factorial of n
    n! = n * n - 1 * ... * 2 * 1
    OR n! = 1 * 2 * ... * n
    Precondition: n >= 1
    '''
    product = 1
    for i in range(1, n + 1):
        product = product * i
    return product

# A *recursive* version of factorial
def factorial(n):
    '''Return the factorial of n
    n! = 1 * 2 * ... * n
    0! is defined as 1
    '''    
    # Need a *base case* to tell the function to stop calling itself
      # What value of n will allow us to stop?
    # This is usually the simplest case for the function to handle,
    # and we usually already know the answer to calling the function
    # on the base case

    # for factorial, the base case is 0, so factorial(0) should be 1
    if n == 0:
        return 1
    else:
        # If it's not the base case, we still have work to do
           # still have to find the factorial of n
        # If we have n, what can we multiply it by to get the 
        #   factorial of n?
        # Example 5! = (5) * (1 * 2 * 3 * 4)
        #  (1 * 2 * 3 * 4) = 4!
        # 5! = 5 * 4!
        # mathematically fact(5) = 5 * fact(4)
        # in general fact(n) = n * fact(n - 1)            
        return n * factorial(n - 1)
    
# Another problem: fibonacci numbers
# 0, 1, 1, 2, 3, 5, 8, 13,..
# iterative fibonacci:
def fib_iter(n):
    result = 0
    next_value = 1
    for i in range(0, n):
        temp = result
        result = next_value
        next_value = next_value + temp
    return result

# Recursive fibonnaci
def fib(n):
    '''Returns the n'th fibonacci number
    '''
    # In a recursive function, we need a base case
    if n == 0:
        return 0
    elif n == 1:
        # we need a second base case because fib
        # needs two previous numbers to add together
        return 1
    else:
        # we still have work to do
        # in math: fib(n) = fib(n - 1) + fib(n - 2) 
        # in Python:
        return fib(n - 1) + fib(n - 2) 
    

# fib() appears to be pretty slow on large numbers
# how can we make it faster?

# initialize a memory dict outside of the function as a 'global' variable

memory = {0: 0, 1: 1}

def memo_fib(n):
    # if we don't have the value for fib(n) in memory,
    # find out the value
    if n not in memory:
        memory[n] = memo_fib(n - 1) + memo_fib(n - 2) 
    # if we do, return it
    return memory[n]


# notice the difference in time it takes to run fib and memo_fib

start_time = time.time()
fib(30)
end_time = time.time()

print('Recursive Fib, no memory: {}'.format(end_time - start_time))

start_time = time.time()
memo_fib(30)
end_time = time.time()

print('Recursive Fib, with memory: {:.10f}'.format(end_time - start_time))

    