Python Execution Model

Generators are a powerful feature in Python that allow for lazy evaluation and memory-efficient computation. They’re commonly used to create iterators, but they also have other useful applications such as coroutines (which we won’t cover here). Here are some examples of how generators can be used:

1. Generating a sequence of numbers using the `yield` keyword:

# This function generates a sequence of numbers using the `yield` keyword
def generate_sequence(start=0, end=None):
    # Check if the end value is an integer and if the start value is smaller than the end value
    if not isinstance(end, int) or start > end:
        # Raise a ValueError if the end value is not an integer or if the start value is larger than the end value
        raise ValueError("Invalid range")
    
    # Use a for loop to iterate through the range of numbers from start to end+1
    for i in range(start, end+1):
        # Use the `yield` keyword to return the current value of i
        yield i
        
# Example usage:
# Use a for loop to iterate through the sequence of numbers generated by the `generate_sequence` function
for num in generate_sequence():
    # Print each number in the sequence
    print(num)

2. Generating a Fibonacci sequence using recursion and memoization (storing previously computed values to avoid redundant computations):

# Generating a Fibonacci sequence using recursion and memoization (storing previously computed values to avoid redundant computations):

# Define a function named "fibonacci" that takes in two parameters: n (the number in the sequence) and cache (a dictionary to store previously computed values)
def fibonacci(n, cache={}):
    # Check if the current number is already in the cache
    if n in cache:
        # If it is, return the value stored in the cache
        return cache[n]
    
    # If the current number is not in the cache, proceed with the following steps:
    # Check if the current number is less than or equal to 1
    if n <= 1:
        # If it is, set the result to be equal to the current number
        result = n
    else:
        # If it is not, use recursion to calculate the Fibonacci sequence for the previous two numbers and add them together
        result = fibonacci(n-1, cache) + fibonacci(n-2, cache)
        
    # Store the result in the cache for future use
    cache[n] = result
    # Return the result
    return result
    
# Example usage:
# Use a for loop to iterate through the first 30 numbers in the Fibonacci sequence
for i in range(30):
    # Call the fibonacci function and pass in the current number as the parameter
    print(fibonacci(i))

# Output:
# 0
# 1
# 1
# 2
# 3
# 5
# 8
# 13
# 21
# 34
# 55
# 89
# 144
# 233
# 377
# 610
# 987
# 1597
# 2584
# 4181
# 6765
# 10946
# 17711
# 28657
# 46368
# 75025
# 121393
# 196418
# 317811
# 514229
# 832040

3. Generating a list of prime numbers using the Sieve of Eratosthenes algorithm (marking as composite all multiples of each prime number encountered, and iteratively removing those composites from consideration):

# Generating a list of prime numbers using the Sieve of Eratosthenes algorithm
# (marking as composite all multiples of each prime number encountered, and iteratively removing those composites from consideration):

# Define a function that takes in a number n as input
def sieve_of_eratosthenes(n):
    # Create a list of booleans with n+1 elements, representing whether each number is composite or not
    is_composite = [False] * (n+1)
    
    # Loop through all numbers from 2 to the square root of n (inclusive)
    for i in range(2, int(n**0.5)+1):
        # Check if the current number is not marked as composite
        if not is_composite[i]:
            # Loop through all multiples of the current number, starting from its square and ending at n (inclusive)
            for j in range(i*i, n+1, i):
                # Mark each multiple as composite
                is_composite[j] = True
        
    # Return a list of all numbers that are not marked as composite and are greater than 1
    return [num for num, is_prime in enumerate(is_composite) if not is_prime and num > 1]
    
# Example usage:
# Loop through all prime numbers less than 100 and print them
for p in sieve_of_eratosthenes(100):
    print(p)

4. Generating a list of all permutations of a given set using recursion (recursively generating the remaining elements after fixing one element at each step, and concatenating those results with the fixed element):

# Generating a list of all permutations of a given set using recursion
# (recursively generating the remaining elements after fixing one element at each step, and concatenating those results with the fixed element):

# Define a function named "permute" that takes in a list as its parameter
def permute(lst):
    # Check if the length of the list is equal to 1
    if len(lst) == 1:
        # If it is, yield the only element in the list
        yield lst[0]
    
    # Loop through the indices of the list
    for i in range(len(lst)):
        # Recursively call the permute function on the list with the current element removed
        for p in permute(lst[:i]+lst[i+1:]):
            # Yield the current element concatenated with the result of the recursive call
            yield lst[i] + p
        
# Example usage:
# Loop through the permutations of the list [1, 2, 3]
for p in permute([1, 2, 3]):
    # Print each permutation
    print(p)

# Output:
# 123
# 132
# 213
# 231
# 312
# 321

These are just a few examples of the many ways generators can be used. They’re particularly useful for situations where you don’t need to compute all results at once (such as when working with large datasets), or when you want to avoid creating unnecessary intermediate data structures.

SICORPS