Recursion Patterns¶
Types of Recursion¶
1. Direct Recursion¶
A function calls itself directly.
f → f → f → base case
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1) # Direct call to itself
print(factorial(5)) # 120
2. Indirect Recursion¶
Functions call each other in a cycle.
f → g → f → g → base case
n = 1
def print_odd():
global n
if n <= 10:
print(n + 1, end=" ")
n += 1
print_even()
def print_even():
global n
if n <= 10:
print(n - 1, end=" ")
n += 1
print_odd()
print_odd() # 2 1 4 3 6 5 8 7 10 9
Tail vs Non-Tail Recursion¶
1. Tail Recursion¶
The recursive call is the last operation — nothing happens after it returns.
def countdown(n):
if n == 0:
return
print(n, end=" ")
return countdown(n - 1) # Last operation
countdown(3) # 3 2 1
2. Non-Tail Recursion¶
Operations occur after the recursive call returns.
def countdown_reverse(n):
if n == 0:
return
countdown_reverse(n - 1) # Not the last operation
print(n, end=" ") # This happens after
countdown_reverse(3) # 1 2 3
3. Why It Matters¶
Tail recursion can theoretically be optimized to use constant stack space (tail call optimization). However, Python does not implement TCO, so both forms use O(n) stack space.
# Tail recursive factorial
def factorial_tail(n, acc=1):
if n <= 1:
return acc
return factorial_tail(n - 1, n * acc)
# Still causes stack overflow for large n in Python
Fibonacci: A Case Study¶
The Fibonacci sequence demonstrates different recursion strategies.
1. Naïve Recursion — O(2^n)¶
def fib_naive(n):
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
Problem: Exponential time due to repeated calculations.
fib(5)
├── fib(4)
│ ├── fib(3)
│ │ ├── fib(2)
│ │ └── fib(1)
│ └── fib(2)
└── fib(3)
├── fib(2)
└── fib(1)
Time complexity: \(T(n) \in \Theta(\phi^n)\) where \(\phi = \frac{1+\sqrt{5}}{2} \approx 1.618\)
2. Memoization (Top-Down) — O(n)¶
Cache results to avoid redundant calculations.
memo = {0: 0, 1: 1}
def fib_memo(n):
if n in memo:
return memo[n]
memo[n] = fib_memo(n - 1) + fib_memo(n - 2)
return memo[n]
print(fib_memo(50)) # 12586269025
Or using @lru_cache:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_cached(n):
if n < 2:
return n
return fib_cached(n - 1) + fib_cached(n - 2)
- Time: O(n)
- Space: O(n) for cache + O(n) for call stack
3. Tabulation (Bottom-Up) — O(n) time, O(1) space¶
Build solution iteratively from base cases.
def fib_iterative(n):
if n < 2:
return n
a, b = 0, 1
for _ in range(n - 1):
a, b = b, a + b
return b
print(fib_iterative(50)) # 12586269025
- Time: O(n)
- Space: O(1)
- No recursion stack
4. Binet's Formula — O(1)¶
Closed-form solution using the golden ratio.
import math
def fib_binet(n):
phi = (1 + math.sqrt(5)) / 2
return round((phi**n - (-phi)**(-n)) / math.sqrt(5))
print(fib_binet(50)) # 12586269025
Caveat: Floating-point errors make this inaccurate for large n.
Complexity Comparison¶
| Method | Time | Space | Stack Depth | Best For |
|---|---|---|---|---|
| Naïve Recursion | O(2^n) | O(n) | Deep | Education only |
| Memoization | O(n) | O(n) | Moderate | When recursion preferred |
| Tabulation | O(n) | O(1) | None | Production use |
| Binet's Formula | O(1) | O(1) | None | Approximation only |
Classic Recursive Problems¶
1. Sum to N¶
def sum_to_n(n):
if n == 0:
return 0
return n + sum_to_n(n - 1)
print(sum_to_n(10)) # 55
2. Triangular Numbers¶
def triangular(n):
if n == 1:
return 1
return triangular(n - 1) + n
for i in range(1, 7):
print(f"T_{i} = {triangular(i)}")
# T_1 = 1, T_2 = 3, T_3 = 6, T_4 = 10, T_5 = 15, T_6 = 21
3. Tower of Hanoi¶
Minimum moves to solve n-disk puzzle.
def hanoi_moves(n):
if n == 1:
return 1
return 2 * hanoi_moves(n - 1) + 1
for i in range(1, 8):
print(f"n={i}: {hanoi_moves(i)} moves")
# n=1: 1, n=2: 3, n=3: 7, n=4: 15, n=5: 31, n=6: 63, n=7: 127
Advantages and Disadvantages¶
Advantages¶
- Elegant code — Natural expression for recursive problems
- Divide-and-conquer — Breaks complex problems into subproblems
- Tree structures — Natural fit for trees and graphs
Disadvantages¶
- Stack overhead — Each call adds a frame
- Performance — Function call overhead vs iteration
- Stack overflow — Deep recursion hits Python's limit
- Debugging — Harder to trace execution flow
When to Use Recursion¶
Use recursion when:
- Problem has recursive structure (trees, graphs)
- Divide-and-conquer is natural
- Code clarity matters more than performance
- Depth is bounded and manageable
Use iteration when:
- Simple loops suffice
- Performance is critical
- Deep recursion is possible
- Stack overflow is a concern
Summary¶
- Direct recursion: Function calls itself
- Indirect recursion: Functions call each other cyclically
- Tail recursion: Recursive call is last operation (no TCO in Python)
- Memoization: Top-down DP, caches results
- Tabulation: Bottom-up DP, iterative
- Trade-off: Recursion for clarity, iteration for performance
Runnable Example: greedy_algorithm_example.py¶
"""
Greedy Algorithm: The Knapsack Problem
A greedy algorithm makes the locally optimal choice at each step,
hoping to find a global optimum. It doesn't always find the best
solution, but it finds a satisfactory one quickly.
Topics covered:
- Greedy strategy: best value-to-weight ratio first
- OOP with @property for computed attributes
- sorted() with custom key functions
Based on concepts from Python-100-Days example04 and ch05/recursion materials.
"""
# =============================================================================
# Example 1: Item Class with Computed Property
# =============================================================================
class Item:
"""An item with name, value, and weight.
The value_per_weight property computes the efficiency ratio,
which the greedy algorithm uses to prioritize items.
"""
def __init__(self, name: str, value: float, weight: float):
self.name = name
self.value = value
self.weight = weight
@property
def value_per_weight(self) -> float:
"""Value-to-weight ratio (higher = more efficient)."""
return self.value / self.weight
def __repr__(self):
return (f"Item('{self.name}', value={self.value}, "
f"weight={self.weight}, ratio={self.value_per_weight:.2f})")
# =============================================================================
# Example 2: Greedy Knapsack (0/1 variant)
# =============================================================================
def greedy_knapsack(items: list[Item], capacity: float) -> tuple[list[Item], float]:
"""Select items using greedy strategy: best value/weight ratio first.
This is the 0/1 knapsack variant - items cannot be split.
The greedy approach doesn't guarantee the optimal solution for 0/1
knapsack, but it's fast: O(n log n) for sorting + O(n) for selection.
Args:
items: Available items to choose from.
capacity: Maximum weight the knapsack can hold.
Returns:
Tuple of (selected items, total value).
>>> items = [Item('A', 60, 10), Item('B', 100, 20), Item('C', 120, 30)]
>>> selected, value = greedy_knapsack(items, 50)
>>> value
220.0
"""
# Sort by value-to-weight ratio (highest first)
sorted_items = sorted(items, key=lambda x: x.value_per_weight, reverse=True)
selected = []
total_weight = 0.0
total_value = 0.0
for item in sorted_items:
if total_weight + item.weight <= capacity:
selected.append(item)
total_weight += item.weight
total_value += item.value
return selected, total_value
# =============================================================================
# Example 3: Fractional Knapsack (items can be split)
# =============================================================================
def fractional_knapsack(items: list[Item], capacity: float) -> float:
"""Fractional knapsack: items can be partially taken.
The greedy approach IS optimal for fractional knapsack.
Take items by best ratio; if an item doesn't fully fit,
take the fraction that fits.
>>> items = [Item('A', 60, 10), Item('B', 100, 20), Item('C', 120, 30)]
>>> fractional_knapsack(items, 50)
240.0
"""
sorted_items = sorted(items, key=lambda x: x.value_per_weight, reverse=True)
total_value = 0.0
remaining = capacity
for item in sorted_items:
if remaining <= 0:
break
if item.weight <= remaining:
total_value += item.value
remaining -= item.weight
else:
# Take partial item
fraction = remaining / item.weight
total_value += item.value * fraction
remaining = 0
return total_value
# =============================================================================
# Example 4: Practical Demo
# =============================================================================
def demo():
"""Demonstrate greedy knapsack with sample items."""
items = [
Item('Gold Bar', 500, 25),
Item('Diamond', 300, 5),
Item('Painting', 200, 15),
Item('Laptop', 150, 3),
Item('Sculpture', 100, 20),
Item('Book Set', 50, 10),
]
capacity = 40
print("=== Available Items ===")
print(f"{'Name':<12} {'Value':>6} {'Weight':>6} {'Ratio':>8}")
print("-" * 35)
for item in items:
print(f"{item.name:<12} {item.value:>6.0f} {item.weight:>6.0f} "
f"{item.value_per_weight:>8.2f}")
print(f"\nKnapsack capacity: {capacity}")
# 0/1 Knapsack (greedy)
selected, value = greedy_knapsack(items, capacity)
print(f"\n--- 0/1 Knapsack (Greedy) ---")
total_weight = sum(item.weight for item in selected)
for item in selected:
print(f" Selected: {item.name} (value={item.value}, weight={item.weight})")
print(f" Total value: {value:.0f}")
print(f" Total weight: {total_weight:.0f}/{capacity}")
# Fractional Knapsack (greedy - optimal)
frac_value = fractional_knapsack(items, capacity)
print(f"\n--- Fractional Knapsack (Greedy, Optimal) ---")
print(f" Total value: {frac_value:.0f}")
print("\nNote: Greedy is optimal for fractional knapsack but NOT")
print("guaranteed optimal for 0/1 knapsack. Dynamic programming")
print("is needed for optimal 0/1 solutions.")
if __name__ == '__main__':
demo()