Implementing Algorithms Using Control Structures

Week 2 Day 2: Control Flow Fundamentals

Introduction to Algorithm Implementation

Welcome to your assignment on implementing algorithms using control structures! Algorithms are the heart of computer science and programming. They are step-by-step procedures for solving problems, making decisions, or performing calculations. In this assignment, we'll explore how to translate algorithmic thinking into Python code using the control structures we've learned: conditionals and loops.

Think of algorithms as recipes. Just as a chef follows specific steps to create a dish, a programmer uses algorithms to solve problems. The control structures we've learned (if-statements, loops) are the tools that allow us to express the logic of these algorithms in code.

The code for this assignment can be found in the /week2/day2/algorithm_implementation.py file in your course repository.

Assignment Overview

In this assignment, you'll implement several classic algorithms using the control structures we've covered. Each algorithm will exercise different aspects of conditional statements and loops. We'll start with simpler algorithms and progress to more complex ones, providing detailed explanations and examples along the way.

Objectives

Assignment Structure

  1. Simple numeric algorithms
  2. Search algorithms
  3. Sorting algorithms
  4. Pattern recognition algorithms
  5. Challenging problems that combine multiple concepts

Simple Numeric Algorithms

Let's start with some basic numeric algorithms that demonstrate conditional statements and loops.

Algorithm 1: Factorial Calculation

The factorial of a non-negative integer n is the product of all positive integers less than or equal to n. It's denoted as n! and is a fundamental calculation in mathematics and computing.

Problem Statement

Implement a function that calculates the factorial of a given number n.

Algorithm Steps
  1. If n is 0 or 1, return 1 (base case)
  2. Otherwise, initialize a result variable to 1
  3. Loop from 2 to n (inclusive)
  4. For each number, multiply the result by that number
  5. Return the final result
Implementation
def factorial(n):
    """
    Calculate the factorial of a non-negative integer n.
    
    Args:
        n: A non-negative integer
        
    Returns:
        The factorial of n (n!)
    """
    # Handle base cases and input validation
    if not isinstance(n, int) or n < 0:
        raise ValueError("Input must be a non-negative integer")
    
    if n == 0 or n == 1:
        return 1
    
    # Calculate factorial using a loop
    result = 1
    for i in range(2, n + 1):
        result *= i
    
    return result

# Test cases
print(f"factorial(0) = {factorial(0)}")  # 1
print(f"factorial(1) = {factorial(1)}")  # 1
print(f"factorial(5) = {factorial(5)}")  # 120
print(f"factorial(10) = {factorial(10)}")  # 3628800
Analysis

This algorithm demonstrates:

Notice how we handle edge cases (0 and 1) differently, since their factorials are defined as 1. This is a common pattern in algorithm implementation: identify special cases and handle them separately.

Algorithm 2: Prime Number Check

A prime number is a natural number greater than 1 that is not divisible by any positive integer other than 1 and itself. Checking whether a number is prime is a classic algorithm with applications in cryptography and number theory.

Problem Statement

Implement a function that determines whether a given number is prime.

Algorithm Steps
  1. If n is less than 2, return False (by definition, primes are greater than 1)
  2. If n is 2, return True (2 is prime)
  3. If n is even and not 2, return False (even numbers greater than 2 are not prime)
  4. Check divisibility from 3 to the square root of n, stepping by 2 (to check only odd numbers)
  5. If any number divides n evenly, return False
  6. If no divisors are found, return True
Implementation
def is_prime(n):
    """
    Check if a number is prime.
    
    Args:
        n: An integer to check
        
    Returns:
        True if n is prime, False otherwise
    """
    # Handle special cases
    if n < 2:
        return False
    
    if n == 2:
        return True
    
    # Even numbers greater than 2 are not prime
    if n % 2 == 0:
        return False
    
    # Check odd divisors up to the square root of n
    # We only need to check up to the square root because
    # if n has a divisor larger than sqrt(n), it must also
    # have a smaller divisor already checked
    sqrt_n = int(n ** 0.5) + 1
    
    for divisor in range(3, sqrt_n, 2):
        if n % divisor == 0:
            return False
    
    return True

# Test cases
for num in [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 17, 19, 20, 23]:
    print(f"{num} is {'prime' if is_prime(num) else 'not prime'}")
Analysis

This algorithm demonstrates:

The optimization techniques used here significantly improve the algorithm's efficiency. For large numbers, checking every possible divisor would be impractical. By handling even numbers separately and only checking divisors up to the square root, we greatly reduce the number of iterations required.

Algorithm 3: Fibonacci Sequence

The Fibonacci sequence is a series of numbers where each number is the sum of the two preceding ones, usually starting with 0 and 1. This sequence appears throughout nature and mathematics.

Problem Statement

Implement a function that generates the first n numbers in the Fibonacci sequence.

Algorithm Steps
  1. If n <= 0, return an empty list
  2. If n == 1, return [0]
  3. If n == 2, return [0, 1]
  4. Initialize the sequence with [0, 1]
  5. Loop from 2 to n-1
  6. For each iteration, calculate the next Fibonacci number by adding the last two numbers
  7. Append the new number to the sequence
  8. Return the complete sequence
Implementation
def fibonacci_sequence(n):
    """
    Generate the first n numbers in the Fibonacci sequence.
    
    Args:
        n: The number of Fibonacci numbers to generate
        
    Returns:
        A list containing the first n Fibonacci numbers
    """
    # Handle edge cases
    if n <= 0:
        return []
    
    if n == 1:
        return [0]
    
    # Initialize the sequence with the first two Fibonacci numbers
    fib_sequence = [0, 1]
    
    # Generate the rest of the sequence
    for i in range(2, n):
        # Next number is the sum of the previous two
        next_fib = fib_sequence[i-1] + fib_sequence[i-2]
        fib_sequence.append(next_fib)
    
    return fib_sequence

# Test cases
print(f"fibonacci_sequence(0) = {fibonacci_sequence(0)}")   # []
print(f"fibonacci_sequence(1) = {fibonacci_sequence(1)}")   # [0]
print(f"fibonacci_sequence(2) = {fibonacci_sequence(2)}")   # [0, 1]
print(f"fibonacci_sequence(10) = {fibonacci_sequence(10)}")  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Analysis

This algorithm demonstrates:

The Fibonacci sequence is a classic example of a recurrence relation, where each term depends on previous terms. This implementation stores all the Fibonacci numbers in a list, making it memory-efficient for small values of n but potentially memory-intensive for large values.

Search Algorithms

Search algorithms are fundamental in computer science, allowing us to find specific items within collections of data. We'll implement two classic search algorithms: linear search and binary search.

Algorithm 4: Linear Search

Linear search is the simplest search algorithm, where we check each element in a collection one by one until we find the target or exhaust the collection.

Problem Statement

Implement a function that searches for a target value in a list and returns its index (or -1 if not found).

Algorithm Steps
  1. Start from the first element of the list
  2. Compare each element with the target value
  3. If a match is found, return its index
  4. If the end of the list is reached without finding the target, return -1
Implementation
def linear_search(arr, target):
    """
    Search for a target value in a list using linear search.
    
    Args:
        arr: A list of elements to search through
        target: The value to search for
        
    Returns:
        The index of the target if found, -1 otherwise
    """
    # Loop through each element in the list
    for i in range(len(arr)):
        # Check if the current element matches the target
        if arr[i] == target:
            return i  # Found the target, return its index
    
    # Target was not found
    return -1

# Test cases
numbers = [5, 10, 15, 20, 25, 30, 35, 40]
print(f"linear_search({numbers}, 25) = {linear_search(numbers, 25)}")  # 4
print(f"linear_search({numbers}, 7) = {linear_search(numbers, 7)}")    # -1
Analysis

This algorithm demonstrates:

Linear search is straightforward and works on any list, whether sorted or not. However, it's inefficient for large lists, as it may need to check every element in the worst case.

Algorithm 5: Binary Search

Binary search is a more efficient algorithm for finding an item in a sorted list. It repeatedly divides the search space in half, making it much faster than linear search for large datasets.

Problem Statement

Implement a function that searches for a target value in a sorted list using binary search and returns its index (or -1 if not found).

Algorithm Steps
  1. Initialize left pointer to 0 and right pointer to the last index of the list
  2. While the left pointer is less than or equal to the right pointer:
    1. Calculate the middle index as (left + right) // 2
    2. If the middle element is the target, return its index
    3. If the middle element is greater than the target, move the right pointer to middle - 1
    4. If the middle element is less than the target, move the left pointer to middle + 1
  3. If the left pointer exceeds the right pointer, the target is not in the list, so return -1
Implementation
def binary_search(arr, target):
    """
    Search for a target value in a sorted list using binary search.
    
    Args:
        arr: A sorted list of elements to search through
        target: The value to search for
        
    Returns:
        The index of the target if found, -1 otherwise
    """
    # Initialize the left and right pointers
    left = 0
    right = len(arr) - 1
    
    # Continue searching while the pointers haven't crossed
    while left <= right:
        # Calculate the middle index
        # Using (left + right) // 2 can cause integer overflow for large lists
        # This alternative calculation is safer
        middle = left + (right - left) // 2
        
        # Check if the middle element is the target
        if arr[middle] == target:
            return middle  # Found the target, return its index
        
        # Decide which half to search next
        if arr[middle] > target:
            # Target is in the left half
            right = middle - 1
        else:
            # Target is in the right half
            left = middle + 1
    
    # Target was not found
    return -1

# Test cases
sorted_numbers = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
print(f"binary_search({sorted_numbers}, 30) = {binary_search(sorted_numbers, 30)}")  # 5
print(f"binary_search({sorted_numbers}, 7) = {binary_search(sorted_numbers, 7)}")    # -1
Analysis

This algorithm demonstrates:

Binary search is much more efficient than linear search for large sorted lists, with a time complexity of O(log n) compared to linear search's O(n). However, it requires the list to be sorted beforehand, which may not always be practical.

As an analogy, think of looking up a name in a phone book. You wouldn't start from the first page and check each entry (linear search). Instead, you'd open the book to the middle, see if your target comes before or after that page, and repeat the process with the appropriate half (binary search).

Sorting Algorithms

Sorting is a fundamental operation in computer science, with applications in data processing, database systems, and more. We'll implement two simple sorting algorithms: selection sort and bubble sort.

Algorithm 6: Selection Sort

Selection sort is a simple sorting algorithm that repeatedly selects the smallest element from the unsorted portion and puts it at the beginning.

Problem Statement

Implement a function that sorts a list in ascending order using the selection sort algorithm.

Algorithm Steps
  1. Iterate through the list from index 0 to n-1:
    1. Find the minimum element in the unsorted part of the list (from the current index to the end)
    2. Swap the minimum element with the element at the current index
  2. After each iteration, the smallest element from the unsorted portion is placed in its correct position
Implementation
def selection_sort(arr):
    """
    Sort a list in ascending order using the selection sort algorithm.
    
    Args:
        arr: A list of comparable elements
        
    Returns:
        The sorted list (modifies the original list in-place)
    """
    n = len(arr)
    
    # Iterate through the list
    for i in range(n):
        # Assume the current index has the minimum value
        min_index = i
        
        # Find the minimum element in the remaining unsorted portion
        for j in range(i + 1, n):
            if arr[j] < arr[min_index]:
                min_index = j
        
        # Swap the minimum element with the element at position i
        # (Only if they're different, to avoid unnecessary swaps)
        if min_index != i:
            arr[i], arr[min_index] = arr[min_index], arr[i]
    
    return arr  # Return the sorted list (though it's sorted in-place)

# Test cases
unsorted1 = [64, 25, 12, 22, 11]
print(f"selection_sort({unsorted1}) = {selection_sort(unsorted1)}")  # [11, 12, 22, 25, 64]

unsorted2 = [5, 3, 8, 1, 2]
print(f"selection_sort({unsorted2}) = {selection_sort(unsorted2)}")  # [1, 2, 3, 5, 8]
Analysis

This algorithm demonstrates:

Selection sort is not efficient for large lists, with a time complexity of O(n²), but it's simple to implement and performs well for small lists. It's also more efficient than bubble sort in terms of the number of swaps performed.

Algorithm 7: Bubble Sort

Bubble sort is another simple sorting algorithm that repeatedly steps through the list, compares adjacent elements, and swaps them if they're in the wrong order.

Problem Statement

Implement a function that sorts a list in ascending order using the bubble sort algorithm.

Algorithm Steps
  1. Iterate through the list multiple times, each time bubbling the largest element to the end:
    1. For each pair of adjacent elements, compare them and swap if they're in the wrong order
    2. After each complete pass, the largest element will be in its correct position
    3. Repeat for n-1 passes, where n is the length of the list
  2. Optimize by tracking whether any swaps were made in a pass; if no swaps are made, the list is already sorted
Implementation
def bubble_sort(arr):
    """
    Sort a list in ascending order using the bubble sort algorithm.
    
    Args:
        arr: A list of comparable elements
        
    Returns:
        The sorted list (modifies the original list in-place)
    """
    n = len(arr)
    
    # Optimization: track whether any swaps were made in a pass
    # If no swaps are made, the list is already sorted
    for i in range(n):
        # Flag to track if any swaps were made in this pass
        swapped = False
        
        # Last i elements are already in place, so we don't need to check them
        for j in range(0, n - i - 1):
            # Compare adjacent elements
            if arr[j] > arr[j + 1]:
                # Swap them if they're in the wrong order
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        
        # If no swaps were made in this pass, the list is already sorted
        if not swapped:
            break
    
    return arr  # Return the sorted list (though it's sorted in-place)

# Test cases
unsorted1 = [64, 34, 25, 12, 22, 11, 90]
print(f"bubble_sort({unsorted1}) = {bubble_sort(unsorted1)}")  # [11, 12, 22, 25, 34, 64, 90]

unsorted2 = [5, 1, 4, 2, 8]
print(f"bubble_sort({unsorted2}) = {bubble_sort(unsorted2)}")  # [1, 2, 4, 5, 8]
Analysis

This algorithm demonstrates:

Bubble sort is one of the simplest sorting algorithms, but it's not efficient for large lists (O(n²) time complexity). However, it has the advantage of detecting whether the list is already sorted during execution and exiting early.

Imagine bubble sort as bubbles rising in water: larger elements "bubble up" to their correct positions at the end of the list, one by one.

Pattern Recognition Algorithms

Pattern recognition algorithms identify specific patterns or structures in data. We'll implement two algorithms: finding palindromes and identifying Armstrong numbers.

Algorithm 8: Palindrome Check

A palindrome is a word, phrase, number, or other sequence that reads the same backward as forward, such as "radar" or "level".

Problem Statement

Implement a function that checks if a given string is a palindrome, ignoring case, punctuation, and spaces.

Algorithm Steps
  1. Convert the string to lowercase
  2. Remove all non-alphanumeric characters (punctuation, spaces)
  3. Compare the processed string with its reverse
  4. Return True if they match, False otherwise
Implementation
def is_palindrome(text):
    """
    Check if a given string is a palindrome, ignoring case, spaces, and punctuation.
    
    Args:
        text: The string to check
        
    Returns:
        True if the string is a palindrome, False otherwise
    """
    # Convert to lowercase
    text = text.lower()
    
    # Remove non-alphanumeric characters
    # This creates a new string with only letters and numbers
    processed_text = ""
    for char in text:
        if char.isalnum():  # Check if the character is a letter or number
            processed_text += char
    
    # Alternative approach using list comprehension and join:
    # processed_text = ''.join(char for char in text if char.isalnum())
    
    # Check if the processed string is equal to its reverse
    return processed_text == processed_text[::-1]

# Test cases
print(f"is_palindrome('radar') = {is_palindrome('radar')}")                          # True
print(f"is_palindrome('A man, a plan, a canal: Panama') = {is_palindrome('A man, a plan, a canal: Panama')}")  # True
print(f"is_palindrome('race a car') = {is_palindrome('race a car')}")                # False
print(f"is_palindrome('Was it a car or a cat I saw?') = {is_palindrome('Was it a car or a cat I saw?')}")      # True
Analysis

This algorithm demonstrates:

The algorithm efficiently handles various edge cases, such as mixed case, punctuation, and spaces, by preprocessing the input before checking for palindrome properties.

Algorithm 9: Armstrong Number Check

An Armstrong number (also known as a narcissistic number) is a number that is equal to the sum of its own digits each raised to the power of the number of digits. For example, 153 is an Armstrong number because 1³ + 5³ + 3³ = 153.

Problem Statement

Implement a function that checks if a given number is an Armstrong number.

Algorithm Steps
  1. Convert the number to a string to count its digits
  2. Calculate the sum of each digit raised to the power of the number of digits
  3. Check if the sum equals the original number
Implementation
def is_armstrong_number(n):
    """
    Check if a number is an Armstrong number.
    
    An Armstrong number is a number that is equal to the sum of its own digits
    each raised to the power of the number of digits.
    
    Args:
        n: The number to check
        
    Returns:
        True if the number is an Armstrong number, False otherwise
    """
    # Convert to string to count digits and process each digit
    num_str = str(n)
    num_digits = len(num_str)
    
    # Calculate the sum of each digit raised to the power of num_digits
    armstrong_sum = 0
    for digit in num_str:
        armstrong_sum += int(digit) ** num_digits
    
    # Check if the sum equals the original number
    return armstrong_sum == n

# Test cases
armstrong_numbers = [1, 153, 370, 371, 407]
non_armstrong_numbers = [10, 100, 154, 372]

print("Armstrong numbers:")
for num in armstrong_numbers:
    print(f"{num} is {'an Armstrong number' if is_armstrong_number(num) else 'not an Armstrong number'}")

print("\nNon-Armstrong numbers:")
for num in non_armstrong_numbers:
    print(f"{num} is {'an Armstrong number' if is_armstrong_number(num) else 'not an Armstrong number'}")
Analysis

This algorithm demonstrates:

Armstrong numbers are a fascinating mathematical curiosity that require us to extract individual digits and perform calculations on them. This algorithm efficiently identifies such numbers by leveraging Python's ability to convert between numeric and string representations.

Challenging Problems

Let's tackle more complex problems that combine multiple concepts and require deeper algorithmic thinking.

Algorithm 10: Pascal's Triangle

Pascal's Triangle is a triangular array of binomial coefficients. Each number is the sum of the two numbers directly above it. It has applications in probability, algebra, and combinatorics.

Problem Statement

Implement a function that generates the first n rows of Pascal's Triangle.

Algorithm Steps
  1. Create an empty list to store the rows of the triangle
  2. If n <= 0, return an empty list
  3. If n >= 1, add the first row [1]
  4. For each subsequent row (from 1 to n-1):
    1. Start with [1] (the first element of each row is always 1)
    2. For each pair of adjacent elements in the previous row, add them and append to the current row
    3. Append 1 (the last element of each row is always 1)
    4. Add the completed row to the triangle
  5. Return the completed triangle
Implementation
def generate_pascals_triangle(n):
    """
    Generate the first n rows of Pascal's Triangle.
    
    Args:
        n: The number of rows to generate
        
    Returns:
        A list of lists, where each inner list represents a row of Pascal's Triangle
    """
    # Handle edge case
    if n <= 0:
        return []
    
    # Initialize the triangle with the first row
    triangle = [[1]]
    
    # Generate subsequent rows
    for i in range(1, n):
        # The previous row
        prev_row = triangle[i - 1]
        
        # Start the new row with 1
        current_row = [1]
        
        # Calculate the middle values based on the previous row
        for j in range(1, i):
            # Each value is the sum of the two values above it
            current_row.append(prev_row[j - 1] + prev_row[j])
        
        # End the row with 1
        current_row.append(1)
        
        # Add the row to the triangle
        triangle.append(current_row)
    
    return triangle

# Function to print the triangle in a visually appealing way
def print_pascal_triangle(triangle):
    """Print Pascal's Triangle in a readable format."""
    width = len(' '.join(map(str, triangle[-1])))
    
    for row in triangle:
        # Convert numbers to strings and join with spaces
        row_str = ' '.join(map(str, row))
        # Center the row for better visualization
        print(row_str.center(width))

# Test case
rows = 6
pascal_triangle = generate_pascals_triangle(rows)
print(f"Pascal's Triangle ({rows} rows):")
print_pascal_triangle(pascal_triangle)
Analysis

This algorithm demonstrates:

Pascal's Triangle demonstrates a beautiful mathematical pattern where each element is calculated from the elements above it. This recursive relationship makes it a perfect candidate for implementation with loops.

Algorithm 11: Sieve of Eratosthenes

The Sieve of Eratosthenes is an ancient algorithm for finding all prime numbers up to a specified limit. It efficiently filters out non-prime numbers by marking multiples of each prime.

Problem Statement

Implement the Sieve of Eratosthenes algorithm to find all prime numbers up to a given limit.

Algorithm Steps
  1. Create a boolean array "is_prime" of size n+1, initialized as True
  2. Set is_prime[0] and is_prime[1] to False (0 and 1 are not prime)
  3. For each number p starting from 2 up to the square root of n:
    1. If is_prime[p] is True, p is prime
    2. Mark all multiples of p as non-prime by setting is_prime[p*p], is_prime[p*p+p], ... to False
  4. Collect all indices p where is_prime[p] is True, these are the prime numbers
Implementation
def sieve_of_eratosthenes(n):
    """
    Find all prime numbers up to n using the Sieve of Eratosthenes algorithm.
    
    Args:
        n: Upper limit for finding primes
        
    Returns:
        A list of all prime numbers less than or equal to n
    """
    # Handle edge cases
    if n < 2:
        return []
    
    # Initialize a boolean array for tracking prime numbers
    # Initially, assume all numbers are prime
    is_prime = [True] * (n + 1)
    
    # 0 and 1 are not prime
    is_prime[0] = is_prime[1] = False
    
    # Process each potential prime number
    # We only need to check up to the square root of n
    for i in range(2, int(n**0.5) + 1):
        # If i is prime, mark all its multiples as non-prime
        if is_prime[i]:
            # Start marking from i*i, as smaller multiples would have been
            # marked by smaller primes already
            for j in range(i*i, n + 1, i):
                is_prime[j] = False
    
    # Collect all prime numbers
    primes = [i for i in range(2, n + 1) if is_prime[i]]
    
    return primes

# Test cases
limit = 50
primes = sieve_of_eratosthenes(limit)
print(f"Prime numbers up to {limit}: {primes}")
print(f"Count: {len(primes)}")
Analysis

This algorithm demonstrates:

The Sieve of Eratosthenes is an efficient algorithm for finding all primes up to a limit, with a time complexity of O(n log log n). It's much faster than testing each number individually for primality, especially for large values of n.

Visualize this algorithm as systematically crossing out numbers in a table: first all multiples of 2, then all multiples of 3, and so on. The numbers that remain unmarked are the primes.

Submission Guidelines

For this assignment, you will submit a Python file containing all the implemented algorithms. Make sure to:

  1. Include docstrings for each function explaining what the algorithm does, its parameters, and its return values
  2. Add comments to explain the key steps in your implementations
  3. Test your algorithms with various inputs to ensure they work correctly
  4. Name your submission file algorithm_implementation.py

Submit your file through the course platform by the specified deadline. Your assignment will be evaluated based on:

Conclusion

In this assignment, you've implemented a variety of algorithms using Python's control structures. Through these implementations, you've learned how to:

These fundamental skills will serve as a strong foundation as you continue your journey in programming and computer science. The algorithms you've implemented are classics that appear in various forms across different applications and domains.

Remember, learning to implement algorithms is not just about solving specific problems—it's about developing a problem-solving mindset that can be applied to any computational challenge you encounter in the future.

Additional Resources

If you'd like to explore algorithms further, here are some recommended resources:

Practice is key to mastering algorithms. Challenge yourself with different problems, experiment with variations of the algorithms we've covered, and always look for ways to improve your solutions.