Fundamentals Recursion

Module 1: Python Fundamentals

Review time!


Docstrings

A docstring is a string that provides documentation for a function or module. It helps developers understand how to use the code. Docstrings are enclosed in triple quotes.

Example. Add a docstring to our previous greet function.

def greet(name):
  """
  This function greets a person.

  Parameters
  ----------
  name : str
    The name of the person to greet.

  Returns
  -------
  str
    A greeting message.
  """
  return f"Hello, {name}!"

Type Hints

Type hints are a way to specify the types of function arguments and return values in your code. While Python does not enforce these types at runtime, they serve as valuable documentation for understanding code.

To add a type hint for function arguments, simply place a colon : after the parameter name. To hint the output type, use a right arrow ->.

Example. Implement an addition function with type hints.

def add(a: int, b: int) -> int:
  return a + b

Example. Implement a function that takes a student’s name and their grade, then returns a text saying whether they passed or not. Include a docstring and type hints.

def check_pass(name: str, grade: int) -> str:
  """
  This function returns a message saying if the student
  has passed or not.

  Parameters
  ----------
  name : str
    The student's name
  grade : int
    The student's score, between 0 and 10

  Returns
  -------
  str
    Message saying whether the student passed or not
  """
  if grade >= 5:
    return f"{name} has passed!"
  else:
    return f"{name} has failed"

Both docstrings and type hints can be seen on Google Colaboratory when we hover our cursor on the function name. Try it now!

check_pass("Sebastian", 10)

Recursive Functions

You can use one function inside another!

Example. Implement a function closest_lower_prime(m) that takes a number m and returns the closest prime number that is lower than m. You can use the is_prime() function you previously defined to help you.

def closest_lower_prime(number: int) -> int:
  # As we want to get the closest lower prime, we will use the range in
  # reverse order: start from the highest number
  for x in range(number, 2, -1):
    if is_prime(x):
      # Using return inside a loop will finish the whole function execution
      return x

print(closest_lower_prime(12))
print(closest_lower_prime(100))

Example. Implement a function that takes a list of numbers and prints the closest prime number that is lower than each of them, using a message like “x is the closest lower prime number to n”.

def closest_lower_prime_list(ls_numbers: list[int]):
  for n in ls_numbers:
    x = closest_lower_prime(n)
    print(f"{x} is the closest lower prime number to {n}")

closest_lower_prime_list([5, 136, 782])

In fact, a function can even call itself. A recursive function is a function that solves a problem by breaking it down into smaller instances of the same problem, calling itself with these smaller inputs.

Each recursive function needs a base case (a condition under which it stops calling itself) to avoid an infinite loop. When the base case is reached, the function begins to return its results, eventually building back up to the original call.

For example, the factorial function is often defined recursively:

def factorial(n: int) -> int:
  """Computes the factorial of any integer by using recursion"""
  if n == 0:
    return 1  # base case
  else:
    return n * factorial(n - 1)  # recursive call


# Try the function
print(factorial(10))

This approach is elegant and powerful, especially for problems that have a naturally recursive structure, like computing Fibonacci numbers, traversing trees, or solving puzzles like the Tower of Hanoi.

Exercise

Write a recursive function fibonacci(n) that returns the n-th number in the Fibonacci sequence, defined as:

  • \(F(0) = 0\)
  • \(F(1) = 1\)
  • \(F(n) = F(n-1) + F(n-2) for n \geq 2\)
def fibonacci(n: int) -> int:
  if n == 0:
    return 0  # base case
  elif n == 1:
    return 1  # base case
  else:
    return fibonacci(n - 1) + fibonacci(n - 2)  # recursive call


# Try the function
print(fibonacci(6))
Exercise

Write a recursive function hanoi(n, source, target, auxiliary) that prints the steps to solve the Tower of Hanoi puzzle for n disks. Rules:

  • There are three towers: A, B and C.
  • There are \(n\) disks of different sizes.
  • All disks start at tower A, forming a pyramid (bigger at bottom, smaller at top).
  • You need to move all disk to tower C.
  • Only one disk can be moved at a time.
  • A larger disk cannot be placed on top of a smaller one.
def hanoi(n: int, source: str, target: str, auxiliary: str):
  if n == 1:
    print(f"Move disk 1 from {source} to {target}")
  else:
    hanoi(n - 1, source, auxiliary, target)
    print(f"Move disk {n} from {source} to {target}")
    hanoi(n - 1, auxiliary, target, source)

# Try the function
hanoi(3, "A", "C", "B")

Generator Functions

In Python, a generator function is a special type of function that allows you to generate a sequence of values on-the-fly. Unlike regular functions that compute all values at once and store them in memory, generator functions generate values one at a time, which can be more memory-efficient. This session will introduce us to generator functions and the yield statement.

Generator functions are defined like regular functions but use the yield statement instead of return. When a generator function is called, it returns a generator object, which can be iterated over to retrieve the values generated by the function.

Let’s start by defining a simple generator function that generates a sequence of even numbers:

def generate_even_numbers(limit: int) -> int:
  """
  This is a generator function that yields even numbers
  less than a specified limit

  Parameters
  ----------
  limit : int

  Returns
  -------
  int
    Even number less than `limit`
  """
  n = 0
  while n < limit:
    print(f"  [Function enters loop and yields {n}]")
    yield n
    n += 2
    print("  [Function moves to next loop]")

What is the output of this expression? Lets see:

x = generate_even_numbers(10)

print(x)
x = generate_even_numbers(10)

print(type(x))

The output of a generator function (a function that uses yield) is a generator.

⚠ You cannot operate with generators!

x = generate_even_numbers(10)
y = 3

print(x + y)

Using a Generator

To use a generator, you can iterate over it using a for loop or by explicitly calling next() to retrieve the next value.

Here’s how you can use our generate_even_numbers function:

even_generator = generate_even_numbers(10)
print(f"Type of `even_generator`: {type(even_generator)}")

# This code will print even numbers from 0 to 8
print("Start looping on `even_generator`")
for num in even_generator:
  print(f"  `even_generator` returns {num}")
  print("  Next step!\n")
print("End of loop")

Alternatively, this is how we use the next statement:

even_generator = generate_even_numbers(10)

num = next(even_generator)
print(f"`even_generator` returns {num}\n")

num = next(even_generator)
print(f"`even_generator` returns {num}\n")

num = next(even_generator)
print(f"`even_generator` returns {num}")

Difference between return and yield Python

The yield keyword in Python is similar to a return statement used for returning values in Python which returns a generator object to the one who calls the function which contains yield, instead of simply returning a value. The main difference between them is, the return statement terminates the execution of the function. Whereas, the yield statement only pauses the execution of the function. Another difference is return statements are never executed. Whereas, yield statements are executed when the function resumes its execution.

Advantages of yield: - Using yield keyword is highly memory efficient, since the execution happens only when the caller iterates over the object. - As the variables states are saved, we can pause and resume from the same point, thus saving time.

Disadvantages of yield:

  • Sometimes it becomes hard to understand the flow of code due to multiple times of value return from the function generator.
  • Calling of generator functions must be handled properly, else might cause errors in program.

Reference

What will happen with this function?

def generate_even_numbers(limit: int) -> int:
  n = 0
  while n < limit:
    print(f"  [Function enters loop and returns {n}]")
    return n
    n += 2
    print("  [Function moves to next loop]")
x = generate_even_numbers(10)
print(f"Type of output is {type(x)}")
print(f"x is {x}")