Python Full Stack Web Developer Course

Week 2: Thursday Afternoon - Recursion Basics

Introduction to Recursion

Welcome to our exploration of recursion! Today, we'll dive into one of the most elegant and powerful concepts in computer science. Recursion might seem challenging at first, but once you grasp its beauty, you'll see its applications everywhere - from simple algorithms to complex data structures.

This tutorial will be stored in your course files as: /week2_thursday_recursion_basics.html

What is Recursion?

Recursion is a programming technique where a function calls itself to solve a problem. It's like looking at yourself in a mirror that faces another mirror - you see reflections within reflections, extending seemingly infinitely.

At its core, recursion involves:

Real-world Analogy: Russian Nesting Dolls (Matryoshka)

Think of recursion like Russian nesting dolls. Each doll contains a smaller version of itself, until you reach the smallest doll (the base case) that can't be opened further. To put them back together, you start with the smallest doll and work your way outward (unwinding the recursion).

Why Use Recursion?

You might wonder why we need recursion when loops can solve many of the same problems. Here's why recursion is valuable:

Real-world Application: File System Navigation

Your computer's file system is hierarchical - folders contain files and other folders, which can contain more files and folders. Searching through a directory structure is a naturally recursive process, as you need to look inside each subfolder and its subfolders.

Anatomy of a Recursive Function

Every recursive function follows this general pattern:

def recursive_function(parameters):
    # Base case(s): condition to stop recursion
    if base_case_condition:
        return base_case_value
    
    # Recursive case: function calls itself
    else:
        # Process data and call function with modified parameters
        return recursive_function(modified_parameters)

The two critical components are:

  1. Base case: Prevents infinite recursion by providing an exit condition
  2. Recursive step: Makes progress toward the base case with each call

Important Warning!

Always ensure your recursive function has:

  • At least one base case that doesn't make a recursive call
  • Recursive calls that move toward the base case

Without these, you'll create an infinite recursion that will cause a stack overflow!

Simple Recursion Examples

Example 1: Factorial Calculation

The factorial of a number n (written as n!) is the product of all positive integers less than or equal to n.

Mathematical definition: n! = n × (n-1) × (n-2) × ... × 2 × 1

Recursive definition: n! = n × (n-1)!

def factorial(n):
    # Base case
    if n == 0 or n == 1:
        return 1
    
    # Recursive case
    else:
        return n * factorial(n - 1)

# Examples
print(factorial(5))  # 5! = 5 × 4 × 3 × 2 × 1 = 120
Execution Trace for factorial(5):
  1. factorial(5) calls factorial(4) and waits
  2. factorial(4) calls factorial(3) and waits
  3. factorial(3) calls factorial(2) and waits
  4. factorial(2) calls factorial(1) and waits
  5. factorial(1) returns 1 (base case)
  6. factorial(2) resumes and returns 2 * 1 = 2
  7. factorial(3) resumes and returns 3 * 2 = 6
  8. factorial(4) resumes and returns 4 * 6 = 24
  9. factorial(5) resumes and returns 5 * 24 = 120

Example 2: Fibonacci Sequence

The Fibonacci sequence is: 0, 1, 1, 2, 3, 5, 8, 13, 21, ...

Each number is the sum of the two preceding ones, starting from 0 and 1.

def fibonacci(n):
    # Base cases
    if n == 0:
        return 0
    elif n == 1:
        return 1
    
    # Recursive case
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

# Examples
print(fibonacci(6))  # 8

Fibonacci in Nature

The Fibonacci sequence appears throughout nature - in the arrangement of leaves on stems, the pattern of florets in a flower, the spirals of shells, and even in the structure of galaxies. This recursive mathematical pattern helps organisms grow in the most efficient ways.

Note on Efficiency:

The recursive Fibonacci implementation above is elegant but inefficient for large values because it recalculates the same values multiple times. For real applications, you'd use dynamic programming (memoization) or an iterative approach for better performance.

Practical Recursion Examples

Example 3: Directory Size Calculation

Let's use recursion to calculate the total size of all files in a directory and its subdirectories.

import os

def directory_size(path):
    # Base case: if path is a file, return its size
    if os.path.isfile(path):
        return os.path.getsize(path)
    
    # Recursive case: if path is a directory, calculate size of all contents
    total_size = 0
    for item in os.listdir(path):
        item_path = os.path.join(path, item)
        # Recursive call for each file/directory
        total_size += directory_size(item_path)
    
    return total_size

# Example usage
print(f"Total size: {directory_size('/path/to/directory')} bytes")

This is a perfect example of a naturally recursive problem - directory structures are hierarchical by nature.

Example 4: Recursive Binary Search

Binary search is an efficient algorithm for finding an item in a sorted list.

def binary_search(arr, target, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    
    # Base case: item not found
    if low > high:
        return -1
    
    # Find middle index
    mid = (low + high) // 2
    
    # Base case: item found
    if arr[mid] == target:
        return mid
    
    # Recursive cases
    elif arr[mid] > target:
        # Search left half
        return binary_search(arr, target, low, mid - 1)
    else:
        # Search right half
        return binary_search(arr, target, mid + 1, high)

# Example
sorted_list = [1, 3, 5, 7, 9, 11, 13, 15, 17]
print(binary_search(sorted_list, 11))  # 5

Binary Search Analogy: Phone Book

Imagine looking for a name in a phone book. You don't check every page - you open to the middle, see if the name would come before or after that page, and then only search that half. You repeat this process, cutting the search area in half each time, until you find the name. This "divide and conquer" approach is what makes binary search so efficient.

Visualizing Recursion: The Call Stack

When a function calls itself recursively, each call is placed on the "call stack" - a memory structure that keeps track of function calls.

Let's visualize the call stack for factorial(3):

Call Stack (grows downward):
                                                    Return Value
factorial(3)                                            6
└── factorial(2)                                        2
    └── factorial(1)                                    1

Execution flow:

  1. factorial(3) calls factorial(2)
  2. factorial(2) calls factorial(1)
  3. factorial(1) returns 1 (base case)
  4. factorial(2) calculates 2 * 1 = 2 and returns
  5. factorial(3) calculates 3 * 2 = 6 and returns

Call Stack Analogy: Stack of Plates

Think of the call stack like a stack of plates. Each time you make a recursive call, you place a new plate on top of the stack. When a function returns (hits its base case), you take a plate off the stack. You have to remove plates from the top (complete the most recent calls) before you can get to the plates below (complete the earlier calls).

Common Recursion Patterns

Linear Recursion

Each function makes one recursive call (like factorial).

Binary Recursion

Each function makes two recursive calls (like Fibonacci).

Tail Recursion

A special case where the recursive call is the last operation in the function.

# Normal recursive factorial
def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n - 1)

# Tail recursive factorial (with accumulator)
def factorial_tail(n, accumulator=1):
    if n == 0 or n == 1:
        return accumulator
    else:
        return factorial_tail(n - 1, n * accumulator)

Tail recursion is important because many modern compilers can optimize it to use constant stack space (although Python currently doesn't do this optimization).

Mutual Recursion

Two or more functions call each other in a cycle.

def is_even(n):
    if n == 0:
        return True
    else:
        return is_odd(n - 1)

def is_odd(n):
    if n == 0:
        return False
    else:
        return is_even(n - 1)

print(is_even(4))  # True
print(is_odd(3))   # True

Limitations and Considerations

Stack Overflow

Python limits the recursion depth (usually around 1000 calls) to prevent stack overflow. For very deep recursion, you might need to:

import sys
print(f"Default recursion limit: {sys.getrecursionlimit()}")

# Increase limit (be careful!)
# sys.setrecursionlimit(3000)

Performance Considerations

Recursive solutions can be less efficient than iterative ones due to:

Optimization Techniques:
  • Memoization: Cache results to avoid redundant calculations
  • Tail recursion: Write recursion so the recursive call is the last operation
  • Convert to iteration: For performance-critical code

Improved Fibonacci with Memoization

def fibonacci_memo(n, memo={}):
    # Check if we've already calculated this value
    if n in memo:
        return memo[n]
    
    # Base cases
    if n == 0:
        return 0
    elif n == 1:
        return 1
    
    # Recursive case with memoization
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]

print(fibonacci_memo(100))  # Fast calculation of a large Fibonacci number
Warning about the above code:

Using a mutable default argument (like the empty dictionary) can cause unexpected behavior if the function is called multiple times. In production code, you'd want to create the memo dictionary inside the function or pass it as a parameter without a default value.

Real-world Applications of Recursion

Web Development Applications

Example: Parsing Nested JSON

def find_all_values(json_obj, key):
    """Find all values for a given key in a nested JSON object"""
    results = []
    
    # Base case: json_obj is a dictionary
    if isinstance(json_obj, dict):
        # Check if this dictionary contains the key
        if key in json_obj:
            results.append(json_obj[key])
        
        # Recursively search all values in this dictionary
        for k in json_obj:
            results.extend(find_all_values(json_obj[k], key))
    
    # Base case: json_obj is a list
    elif isinstance(json_obj, list):
        # Recursively search each item in the list
        for item in json_obj:
            results.extend(find_all_values(item, key))
    
    # Base case: json_obj is a primitive value (str, int, etc.)
    # In this case, no recursion needed, return empty list
    
    return results

# Example usage
nested_data = {
    "user": {
        "name": "John",
        "roles": ["admin", "user"],
        "preferences": {
            "theme": "dark",
            "notifications": {
                "email": True,
                "push": False
            }
        }
    },
    "meta": {
        "theme": "system"
    }
}

print(find_all_values(nested_data, "theme"))  # ['dark', 'system']

Example: Building a Nested Comment System

In modern web applications, comment systems often allow nested replies. Recursion is perfect for rendering these threaded comments.

def render_comment_thread(comment, depth=0):
    """Render a comment and all its replies with proper indentation"""
    # Indent based on depth
    indent = "  " * depth
    
    # Render the comment
    output = f"{indent}- {comment['author']}: {comment['text']}\n"
    
    # Recursively render all replies
    if 'replies' in comment:
        for reply in comment['replies']:
            output += render_comment_thread(reply, depth + 1)
    
    return output

# Example comment thread
comments = {
    "author": "User1",
    "text": "Great article!",
    "replies": [
        {
            "author": "User2",
            "text": "I agree, very informative.",
            "replies": [
                {
                    "author": "User1",
                    "text": "Thanks for your feedback!"
                }
            ]
        },
        {
            "author": "User3",
            "text": "I have a question about section 3..."
        }
    ]
}

print(render_comment_thread(comments))
Output:
- User1: Great article!
  - User2: I agree, very informative.
    - User1: Thanks for your feedback!
  - User3: I have a question about section 3...

Practice Exercises

Exercise 1: Sum of a List

Write a recursive function to calculate the sum of all elements in a list.

Hint

Think about the base case (empty list) and how to break down the problem into a smaller subproblem.

Solution
def recursive_sum(lst):
    # Base case: empty list
    if not lst:
        return 0
    
    # Recursive case: first element + sum of rest
    return lst[0] + recursive_sum(lst[1:])

print(recursive_sum([1, 2, 3, 4, 5]))  # 15

Exercise 2: Power Function

Write a recursive function to calculate x raised to the power n (x^n).

Hint

Remember that x^n = x * x^(n-1) and x^0 = 1.

Solution
def power(x, n):
    # Base case
    if n == 0:
        return 1
    
    # Recursive case
    return x * power(x, n - 1)

print(power(2, 5))  # 32

Exercise 3: Palindrome Check

Write a recursive function to check if a string is a palindrome (reads the same forward and backward).

Hint

A string is a palindrome if the first and last characters match, and the substring between them is a palindrome.

Solution
def is_palindrome(s):
    # Convert to lowercase and remove non-alphanumeric characters
    s = ''.join(c.lower() for c in s if c.isalnum())
    
    # Base cases
    if len(s) <= 1:
        return True
    
    # Recursive case: check first and last characters, then check the rest
    if s[0] != s[-1]:
        return False
    
    # Recursive call with the substring excluding first and last characters
    return is_palindrome(s[1:-1])

print(is_palindrome("racecar"))  # True
print(is_palindrome("Hello"))    # False
print(is_palindrome("A man, a plan, a canal: Panama"))  # True

Exercise 4: Recursive Count

Write a recursive function that counts the occurrences of a specific element in a nested list.

Hint

You'll need to handle different data types and recursively process nested lists.

Solution
def count_element(nested_list, element):
    # Initialize count
    count = 0
    
    # Iterate through items
    for item in nested_list:
        # If item is a list, recursively count
        if isinstance(item, list):
            count += count_element(item, element)
        # If item matches element, increment count
        elif item == element:
            count += 1
    
    return count

test_list = [1, [2, 3, [1, 2]], [1, [1]], 4, 1]
print(count_element(test_list, 1))  # 4

Additional Resources

Books and Tutorials

Practice Problems

Visualization Tools

Assignment: Building a Recursive Directory Explorer

Create a recursive directory explorer that:

  1. Takes a directory path as input
  2. Recursively lists all files and subdirectories
  3. For each file, shows the file size and extension
  4. For each directory, shows the total size of its contents
  5. Formats the output with proper indentation to show the hierarchy

Bonus challenges:

Tips:

Submission location: /week2_thursday_assignment.py

Conclusion

Recursion is a powerful technique that can lead to elegant solutions for complex problems. While it may take some time to "think recursively," it's a valuable skill that will serve you well throughout your programming career.

Remember these key points:

Next time, we'll build on these concepts as we explore modules and packages in Python. Recursion will appear again when we discuss topics like tree structures, web crawling, and many algorithms.

"To understand recursion, you must first understand recursion."