Recursion

Master the art of recursive programming in Python and learn how to solve complex problems elegantly.

45 minutes Intermediate Functions

Basic Recursion

Understanding Recursive Functions

Definition

Recursion is a programming technique where a function calls itself to solve a problem by breaking it down into smaller subproblems.

Explanation

A recursive function must have a base case (stopping condition) and a recursive case (where it calls itself with a smaller problem).

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

# Example usage
print(factorial(5))  # Output: 120
print(factorial(0))  # Output: 1

Fibonacci Sequence

Implementing Fibonacci Recursively

Definition

The Fibonacci sequence is a series of numbers where each number is the sum of the two preceding ones, starting from 0 and 1.

Explanation

Fibonacci numbers can be calculated recursively, though this approach can be inefficient for large numbers due to repeated calculations.

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

# Example usage
for i in range(10):
    print(f"Fibonacci({i}) = {fibonacci(i)}")

# Output:
# Fibonacci(0) = 0
# Fibonacci(1) = 1
# Fibonacci(2) = 1
# Fibonacci(3) = 2
# Fibonacci(4) = 3
# Fibonacci(5) = 5
# Fibonacci(6) = 8
# Fibonacci(7) = 13
# Fibonacci(8) = 21
# Fibonacci(9) = 34

Tail Recursion

Optimizing Recursive Functions

Definition

Tail recursion occurs when the recursive call is the last operation in the function, allowing for optimization by the compiler.

Explanation

In tail recursion, the recursive call is the final operation, and no additional computation is needed after the recursive call returns.

def factorial_tail(n, accumulator=1):
    # Base case
    if n == 0 or n == 1:
        return accumulator
    # Tail recursive case
    return factorial_tail(n - 1, n * accumulator)

# Example usage
print(factorial_tail(5))  # Output: 120

def fibonacci_tail(n, a=0, b=1):
    # Base case
    if n == 0:
        return a
    if n == 1:
        return b
    # Tail recursive case
    return fibonacci_tail(n - 1, b, a + b)

# Example usage
print(fibonacci_tail(9))  # Output: 34

Tree Recursion

Multiple Recursive Calls

Definition

Tree recursion occurs when a function makes multiple recursive calls, creating a tree-like structure of function calls.

Explanation

Each recursive call can lead to multiple new recursive calls, creating a branching pattern that can be visualized as a tree.

def count_paths(n):
    # Base cases
    if n < 0:
        return 0
    if n == 0:
        return 1
    # Tree recursive case
    return count_paths(n-1) + count_paths(n-2) + count_paths(n-3)

# Example usage
print(count_paths(3))  # Output: 4
# Paths: 1-1-1, 1-2, 2-1, 3

def tree_sum(tree):
    # Base case
    if not isinstance(tree, list):
        return tree
    # Tree recursive case
    return sum(tree_sum(branch) for branch in tree)

# Example usage
tree = [1, [2, 3], [4, [5, 6]]]
print(tree_sum(tree))  # Output: 21

Memoization

Optimizing Recursive Functions with Caching

Definition

Memoization is a technique that stores the results of expensive function calls and returns the cached result when the same inputs occur again.

Explanation

By caching previously computed results, memoization can significantly improve the performance of recursive functions that make repeated calculations.

def memoize(func):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci_memo(n):
    if n <= 1:
        return n
    return fibonacci_memo(n-1) + fibonacci_memo(n-2)

# Example usage
print(fibonacci_memo(40))  # Much faster than the non-memoized version

# Alternative using functools
from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_lru(n):
    if n <= 1:
        return n
    return fibonacci_lru(n-1) + fibonacci_lru(n-2)

Recursive Data Structures

Working with Nested Structures

Definition

Recursive data structures are data structures that contain references to instances of the same type, such as trees and linked lists.

Explanation

Recursive functions are particularly well-suited for processing recursive data structures, as they can naturally traverse the nested structure.

class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

def tree_depth(node):
    # Base case
    if node is None:
        return 0
    # Recursive case
    return 1 + max(tree_depth(node.left), tree_depth(node.right))

def tree_sum(node):
    # Base case
    if node is None:
        return 0
    # Recursive case
    return node.value + tree_sum(node.left) + tree_sum(node.right)

# Example usage
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)

print(f"Tree depth: {tree_depth(root)}")  # Output: 3
print(f"Tree sum: {tree_sum(root)}")      # Output: 15

Divide and Conquer

Solving Problems Recursively

Definition

Divide and conquer is a problem-solving paradigm that breaks down a problem into smaller subproblems, solves them recursively, and combines their solutions.

Explanation

This approach is particularly effective for problems that can be naturally divided into smaller, similar subproblems.

def merge_sort(arr):
    # Base case
    if len(arr) <= 1:
        return arr
    
    # Divide
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    
    # Conquer
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    
    result.extend(left[i:])
    result.extend(right[j:])
    return result

# Example usage
arr = [64, 34, 25, 12, 22, 11, 90]
sorted_arr = merge_sort(arr)
print(sorted_arr)  # Output: [11, 12, 22, 25, 34, 64, 90]

Backtracking

Solving Constraint Satisfaction Problems

Definition

Backtracking is a recursive algorithm that builds solutions incrementally and abandons partial solutions that cannot be completed.

Explanation

This technique is particularly useful for solving problems with multiple constraints, such as the N-Queens problem or Sudoku.

def solve_n_queens(n):
    def is_safe(board, row, col):
        # Check column
        for i in range(row):
            if board[i][col] == 'Q':
                return False
        
        # Check upper diagonal
        for i, j in zip(range(row-1, -1, -1), range(col-1, -1, -1)):
            if board[i][j] == 'Q':
                return False
        
        # Check lower diagonal
        for i, j in zip(range(row-1, -1, -1), range(col+1, n)):
            if board[i][j] == 'Q':
                return False
        
        return True
    
    def solve(board, row):
        # Base case
        if row == n:
            return True
        
        # Try placing queen in each column
        for col in range(n):
            if is_safe(board, row, col):
                board[row][col] = 'Q'
                if solve(board, row + 1):
                    return True
                board[row][col] = '.'  # Backtrack
        
        return False
    
    # Initialize board
    board = [['.' for _ in range(n)] for _ in range(n)]
    if solve(board, 0):
        return board
    return None

# Example usage
solution = solve_n_queens(4)
for row in solution:
    print(row)

Dynamic Programming

Optimizing Recursive Solutions

Definition

Dynamic programming is a method for solving complex problems by breaking them down into simpler subproblems and storing their solutions.

Explanation

This approach combines recursion with memoization to avoid redundant calculations and improve efficiency.

def longest_common_subsequence(str1, str2):
    m, n = len(str1), len(str2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Fill the dp table
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if str1[i-1] == str2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    # Reconstruct the sequence
    lcs = []
    i, j = m, n
    while i > 0 and j > 0:
        if str1[i-1] == str2[j-1]:
            lcs.append(str1[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    
    return ''.join(reversed(lcs))

# Example usage
str1 = "ABCDGH"
str2 = "AEDFHR"
print(longest_common_subsequence(str1, str2))  # Output: "ADH"

Recursion Best Practices

Guidelines for Writing Recursive Functions

Definition

Best practices for writing efficient and maintainable recursive functions in Python.

Explanation

Following these guidelines helps avoid common pitfalls and ensures your recursive functions are both correct and efficient.

# Good: Clear base case and recursive case
def factorial(n):
    if n == 0:  # Clear base case
        return 1
    return n * factorial(n - 1)  # Clear recursive case

# Bad: Missing base case
def infinite_recursion(n):
    return n * infinite_recursion(n - 1)  # Will cause stack overflow

# Good: Using helper function for additional parameters
def fibonacci(n):
    def fib_helper(n, a, b):
        if n == 0:
            return a
        if n == 1:
            return b
        return fib_helper(n - 1, b, a + b)
    return fib_helper(n, 0, 1)

# Good: Using memoization for efficiency
from functools import lru_cache

@lru_cache(maxsize=None)
def expensive_recursion(n):
    if n <= 1:
        return n
    return expensive_recursion(n-1) + expensive_recursion(n-2)
Concept 1 of 10