Recursion & Divide-and-Conquer
Recursion Basics
Recursion is a fundamental programming technique where a function calls itself to solve a problem. It's based on the principle of solving a problem by breaking it down into smaller, similar subproblems. Recursion is particularly powerful for problems that have a natural recursive structure, such as tree traversals, mathematical sequences, and divide-and-conquer algorithms.
Essential Components of Recursion
Every recursive function must have two critical components:
- Base Case: A condition that stops the recursion and returns a value without making further recursive calls. Without a base case, recursion would continue infinitely, causing a stack overflow.
- Recursive Case: The function calls itself with a smaller or simpler version of the problem, moving closer to the base case with each call.
Classic Example: Factorial
The factorial of a number n (n!) is the product of all positive integers from 1 to n. This is a natural recursive problem because n! = n × (n-1)!.
def factorial(n):
# Base case: factorial of 0 or 1 is 1
if n <= 1:
return 1
# Recursive case: n! = n * (n-1)!
return n * factorial(n - 1)
# Example: factorial(5)
# factorial(5) = 5 * factorial(4)
# factorial(4) = 4 * factorial(3)
# factorial(3) = 3 * factorial(2)
# factorial(2) = 2 * factorial(1)
# factorial(1) = 1 (base case)
# Result: 5 * 4 * 3 * 2 * 1 = 120
Understanding the Call Stack
When a recursive function executes, each recursive call is added to the call stack. The stack grows until the base case is reached, then unwinds as each function returns its value. Understanding this process is crucial for debugging recursive functions.
def countdown(n):
if n <= 0: # Base case
print("Blastoff!")
return
print(n)
countdown(n - 1) # Recursive call
# Execution trace for countdown(3):
# countdown(3) prints 3, calls countdown(2)
# countdown(2) prints 2, calls countdown(1)
# countdown(1) prints 1, calls countdown(0)
# countdown(0) prints "Blastoff!", returns
# Stack unwinds: countdown(1) returns, countdown(2) returns, countdown(3) returns
More Recursive Examples
Sum of Array Elements
Note the arr[1:] in the version below: slicing copies the remainder of the list
at every level, so this apparently linear function actually runs in O(n²) time and allocates
O(n²) memory in total. It is a trap worth recognising — the slice looks free and is not.
The indexed version that follows is genuinely O(n).
# O(n²) - each slice copies the rest of the list
def sum_array(arr):
# Base case: empty array has sum 0
if len(arr) == 0:
return 0
# Recursive case: sum = first element + sum of rest
return arr[0] + sum_array(arr[1:])
# O(n) - pass an index instead of building a new list
def sum_array_indexed(arr, i=0):
if i == len(arr):
return 0
return arr[i] + sum_array_indexed(arr, i + 1)
# Example: sum_array([1, 2, 3, 4])
# = 1 + sum_array([2, 3, 4])
# = 1 + 2 + sum_array([3, 4])
# = 1 + 2 + 3 + sum_array([4])
# = 1 + 2 + 3 + 4 + sum_array([])
# = 1 + 2 + 3 + 4 + 0 = 10
String Reversal
The same slicing cost applies here, compounded by Python strings being immutable — every
+ builds a new string. This is O(n²); it is shown because the recursive structure is
clear, not because you should write it this way.
def reverse_string(s):
# Base case: empty string or single character
if len(s) <= 1:
return s
# Recursive case: reverse rest + first character
return reverse_string(s[1:]) + s[0]
# Example: reverse_string("hello")
# = reverse_string("ello") + "h"
# = reverse_string("llo") + "e" + "h"
# = reverse_string("lo") + "l" + "e" + "h"
# = reverse_string("o") + "l" + "l" + "e" + "h"
# = "o" + "l" + "l" + "e" + "h" = "olleh"
Power Calculation
def power(base, exponent):
# Base case: any number to the power of 0 is 1
if exponent == 0:
return 1
# Recursive case: base^exponent = base * base^(exponent-1)
return base * power(base, exponent - 1)
# Optimized version using divide-and-conquer (exponentiation by squaring):
def power_optimized(base, exponent):
# Handle negatives first. Without this, a negative exponent alternates
# between the two branches below forever and overflows the stack.
if exponent < 0:
return 1 / power_optimized(base, -exponent)
if exponent == 0:
return 1
if exponent % 2 == 0:
# If exponent is even: base^exp = (base^2)^(exp/2)
return power_optimized(base * base, exponent // 2)
else:
# If exponent is odd: base^exp = base * base^(exp-1)
return base * power_optimized(base, exponent - 1)
The naive version makes n recursive calls: O(n). Squaring halves the exponent at
every second step, so it makes O(log n) calls: O(log n). For
power(2, 1000) that is 1000 multiplications versus about 20. The same trick underlies
modular exponentiation in RSA, where the exponents have hundreds of digits
and the naive version is not merely slow but impossible.
Divide and Conquer
Divide and conquer is a powerful algorithmic paradigm that solves problems by breaking them into smaller, independent subproblems, solving each subproblem recursively, and then combining the solutions to solve the original problem. This approach is particularly effective for problems that can be naturally divided into similar subproblems.
The Three-Step Process
- Divide: Break the problem into smaller subproblems of the same type. The subproblems should be independent and similar in structure to the original problem.
- Conquer: Solve the subproblems recursively. If a subproblem is small enough, solve it directly (base case).
- Combine: Merge the solutions of the subproblems to form the solution to the original problem.
Master Theorem
The Master Theorem gives the time complexity of divide-and-conquer algorithms directly, without unrolling the recursion. For a recurrence of the form:
T(n) = a T(n/b) + f(n)
where a ≥ 1, b > 1, and f(n) is asymptotically positive: a is the number of subproblems, n/b is their size, and f(n) is the cost of splitting and recombining. Everything hinges on comparing f(n) against the watershed function nlogba, which is the total work done at the leaves of the recursion tree.
The three cases
Case 1 — leaves dominate. If f(n) = O(nlogba − ε) for some ε > 0, then:
T(n) = Θ(nlogba)
Case 2 — every level costs the same. If f(n) = Θ(nlogba logkn) for some k ≥ 0, then:
T(n) = Θ(nlogba logk+1n)
Case 3 — the root dominates. If f(n) = Ω(nlogba + ε) for some ε > 0, and the regularity condition a f(n/b) ≤ c f(n) holds for some c < 1 and all sufficiently large n, then:
T(n) = Θ(f(n))
Worked examples
Merge sort: T(n) = 2T(n/2) + Θ(n)
a=2, b=2 → n^(log₂2) = n¹ = n
f(n) = Θ(n) = Θ(n¹ log⁰n) → Case 2 with k=0
T(n) = Θ(n log n)
Binary search: T(n) = 1T(n/2) + Θ(1)
a=1, b=2 → n^(log₂1) = n⁰ = 1
f(n) = Θ(1) = Θ(1 · log⁰n) → Case 2 with k=0
T(n) = Θ(log n)
Strassen: T(n) = 7T(n/2) + Θ(n²)
a=7, b=2 → n^(log₂7) ≈ n^2.807
f(n) = O(n^(2.807-ε)) → Case 1
T(n) = Θ(n^log₂7) ≈ Θ(n^2.807)
Naive matmul: T(n) = 8T(n/2) + Θ(n²)
a=8, b=2 → n^(log₂8) = n³
f(n) = O(n^(3-ε)) → Case 1
T(n) = Θ(n³)
When the theorem does not apply
There are gaps between the cases. If f(n) grows faster than nlogba but not polynomially faster, no case matches — T(n) = 2T(n/2) + n log n is the classic example (the answer is Θ(n log² n), obtainable from the recursion tree). Case 3 also fails if the regularity condition does not hold. The theorem further assumes subproblems are all the same size; for uneven splits such as T(n) = T(n/3) + T(2n/3) + n, use the Akra–Bazzi method instead.
When the Master Theorem cannot be applied, fall back on drawing the recursion tree and summing level by level, or guessing an answer and proving it by substitution (induction).
Classic Example: Merge Sort
Merge Sort is a perfect illustration of divide-and-conquer. It divides the array in half, sorts each half recursively, and then merges the sorted halves.
def merge_sort(arr):
# Base case: array with 0 or 1 element is already sorted
if len(arr) <= 1:
return arr
# Divide: split array into two halves
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # Conquer: sort left half
right = merge_sort(arr[mid:]) # Conquer: sort right half
# Combine: merge the sorted halves
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
# Merge two sorted arrays
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
# Add remaining elements
result.extend(left[i:])
result.extend(right[j:])
return result
# Time Complexity: O(n log n)
# Space Complexity: O(n)
Binary Search
Binary search uses divide-and-conquer to find an element in a sorted array by repeatedly dividing the search space in half.
def binary_search(arr, target, left=0, right=None):
if right is None:
right = len(arr) - 1
# Base case: element not found
if left > right:
return -1
# Divide: find middle element.
# left + (right - left) // 2 rather than (left + right) // 2 - equivalent in
# Python, but the habit matters in fixed-width languages where left + right
# can overflow. This was a real bug in the JDK's binary search for nine years.
mid = left + (right - left) // 2
# Conquer: check if target is at mid, left, or right
if arr[mid] == target:
return mid
elif arr[mid] > target:
# Search left half
return binary_search(arr, target, left, mid - 1)
else:
# Search right half
return binary_search(arr, target, mid + 1, right)
# Time Complexity: O(log n)
# Space Complexity: O(log n) due to recursion
Maximum Subarray Problem (Divide-and-Conquer)
Find the contiguous subarray with the largest sum, by taking the best of three candidates: the maximum subarray entirely in the left half, entirely in the right half, or crossing the midpoint.
Note that this is not Kadane's algorithm, despite the two often being conflated. Kadane's is a single O(n) left-to-right scan and is covered in Dynamic Programming. The divide-and-conquer version below is O(n log n) — strictly worse — but it is a clean illustration of the paradigm, and the contrast between the two is instructive: sometimes recognising the right recurrence beats splitting the problem in half.
def max_subarray(arr, low, high):
# Base case: single element
if low == high:
return arr[low]
# Divide: find middle
mid = (low + high) // 2
# Conquer: find max in left and right halves
left_max = max_subarray(arr, low, mid)
right_max = max_subarray(arr, mid + 1, high)
# Combine: find max crossing the middle
cross_max = max_crossing_subarray(arr, low, mid, high)
return max(left_max, right_max, cross_max)
# Call as: max_subarray(arr, 0, len(arr) - 1) -- requires a non-empty array
def max_crossing_subarray(arr, low, mid, high):
# Find max sum in left half including mid
left_sum = float('-inf')
total = 0
for i in range(mid, low - 1, -1):
total += arr[i]
left_sum = max(left_sum, total)
# Find max sum in right half excluding mid
right_sum = float('-inf')
total = 0
for j in range(mid + 1, high + 1):
total += arr[j]
right_sum = max(right_sum, total)
return left_sum + right_sum
# Time Complexity: O(n log n)
Quick Sort
Quick Sort uses divide-and-conquer by selecting a pivot, partitioning the array around the pivot, and recursively sorting the partitions.
def quick_sort(arr, low=0, high=None):
if high is None:
high = len(arr) - 1
if low < high:
# Divide: partition array and get pivot index
pivot_index = partition(arr, low, high)
# Conquer: sort elements before and after partition
quick_sort(arr, low, pivot_index - 1)
quick_sort(arr, pivot_index + 1, high)
def partition(arr, low, high):
# Choose rightmost element as pivot
pivot = arr[high]
i = low - 1 # Index of smaller element
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
# Average Time Complexity: O(n log n)
# Worst Case: O(n²)
When to Use Divide-and-Conquer
- Problems can be divided into similar subproblems
- Subproblems are independent (solutions don't overlap)
- Combining solutions is straightforward
- Base cases are easy to identify and solve
- Examples: sorting, searching, mathematical computations, tree operations
Memoization
Memoization is an optimization technique that stores the results of expensive function calls and returns the cached result when the same inputs occur again. This dramatically improves performance for recursive functions that solve overlapping subproblems, reducing time complexity from exponential to polynomial in many cases.
Why Memoization Matters
Without memoization, recursive functions like Fibonacci recompute the same values multiple times. For example, computing fibonacci(5) requires computing fibonacci(3) twice, fibonacci(2) three times, and fibonacci(1) five times. Memoization eliminates this redundant computation.
Fibonacci with Memoization
# Naive recursive approach (inefficient)
def fibonacci_naive(n):
if n <= 1:
return n
return fibonacci_naive(n - 1) + fibonacci_naive(n - 2)
# Time Complexity: O(2^n) - exponential!
# Memoized version (efficient)
#
# The cache is a default argument bound to None and created per top-level call,
# NOT a module-level `memo = {}`. A shared global cache keyed on a bare integer
# collides with every other function that also keys on an integer - see the
# warning below.
def fibonacci(n, memo=None):
if memo is None:
memo = {}
# Check if result already computed
if n in memo:
return memo[n]
# Base cases
if n <= 1:
return n
# Compute and store result
memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo)
return memo[n]
# Time Complexity: O(n) - linear!
# Space Complexity: O(n)
Why not a module-level cache? Writing memo = {} at module scope is a
common shortcut, and it breaks as soon as two memoized functions share the module. Both
fibonacci and climb_stairs below key their cache on a plain integer, so a
shared dict means whichever runs first poisons the other:
# With a shared module-level `memo`:
fibonacci(10) → 55 correct
climb_stairs(10) → 55 WRONG - the correct answer is 89
# fibonacci already stored memo[10] = 55, and climb_stairs returns it verbatim.
Always scope the cache to the call (the memo=None idiom above), or use
functools.cache, which handles this correctly and automatically.
Using Python's functools.lru_cache
Python provides a built-in decorator for memoization that handles caching automatically.
from functools import cache # Python 3.9+
@cache
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# On Python < 3.9, the equivalent is:
# from functools import lru_cache
# @lru_cache(maxsize=None)
#
# The cache is keyed on the full argument tuple AND scoped to this specific
# function, so neither of the failure modes described above can occur.
# Use @lru_cache(maxsize=N) instead when you want a bounded cache.
Two caveats: arguments must be hashable, so you cannot memoize a function that takes a list, and the
cache holds strong references to its arguments, so decorating a method keeps every self
it has seen alive for the lifetime of the process.
Memoization Pattern
The general pattern for implementing memoization:
def memoized_function(n, memo=None):
if memo is None:
memo = {}
# Check cache first
if n in memo:
return memo[n]
# Base case
if base_case(n):
result = base_value
else:
# Recursive case - compute result
result = compute(n, memoized_function)
# Store result in cache
memo[n] = result
return result
More Memoization Examples
Climbing Stairs Problem
Count ways to reach the nth step if you can take 1 or 2 steps at a time.
def climb_stairs(n, memo=None):
if memo is None:
memo = {}
if n in memo:
return memo[n]
# Base cases
if n == 0 or n == 1:
return 1
# Ways to reach n = ways to reach (n-1) + ways to reach (n-2)
memo[n] = climb_stairs(n - 1, memo) + climb_stairs(n - 2, memo)
return memo[n]
# Without memoization: O(2^n)
# With memoization: O(n)
Grid Paths (Unique Paths)
Count unique paths from top-left to bottom-right in a grid, moving only right or down.
def unique_paths(m, n, memo=None):
if memo is None:
memo = {}
key = (m, n)
if key in memo:
return memo[key]
# Base case: reached destination
if m == 1 or n == 1:
return 1
# Paths = paths from top + paths from left
memo[key] = unique_paths(m - 1, n, memo) + unique_paths(m, n - 1, memo)
return memo[key]
# Time Complexity: O(m * n) with memoization
# Without memoization: O(2^(m+n))
Longest Common Subsequence
def lcs(s1, s2, i=0, j=0, memo=None):
if memo is None:
memo = {}
key = (i, j) # safe: this cache belongs to THIS (s1, s2) pair
if key in memo:
return memo[key]
# Base case: end of string
if i == len(s1) or j == len(s2):
return 0
# If characters match, include in LCS
if s1[i] == s2[j]:
result = 1 + lcs(s1, s2, i + 1, j + 1, memo)
else:
# Try both possibilities
result = max(lcs(s1, s2, i + 1, j, memo), lcs(s1, s2, i, j + 1, memo))
memo[key] = result
return result
This one deserves particular care. With a module-level cache keyed on (i, j) the strings
themselves are not part of the key, so results leak between completely unrelated inputs:
lcs("ABCDE", "ACE") → 3 correct
lcs("XYZ", "QRS") → 3 WRONG - the correct answer is 0
Scoping the cache per call, as above, fixes it. If you prefer @cache, include the
strings in the signature so they form part of the key.
Memoization vs Tabulation
Memoization (top-down) and tabulation (bottom-up) are both dynamic programming techniques. Memoization is often more intuitive as it closely follows the recursive structure, while tabulation builds solutions iteratively from the base cases.
- Memoization: Top-down approach, cache results as you compute them
- Tabulation: Bottom-up approach, build table from base cases (see Dynamic Programming)
Common Recursive Patterns
Understanding common recursive patterns helps you recognize when recursion is the right approach and how to structure your solution. Here are the most important patterns you'll encounter.
1. Tree/Graph Traversal
Recursion is natural for tree and graph structures. Each node can be processed, and then its children/neighbors are processed recursively.
# Binary Tree Traversal
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def inorder_traversal(root):
result = []
if root:
result.extend(inorder_traversal(root.left)) # Left
result.append(root.val) # Root
result.extend(inorder_traversal(root.right)) # Right
return result
# Depth-First Search (DFS) on Graph
def dfs(graph, node, visited):
visited.add(node)
print(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
Watch the recursion limit. Python's default cap is 1000 frames, so recursive DFS
raises RecursionError on a graph with a path longer than that — and a linked list
or a degenerate tree of 10,000 nodes is not an unusual input. Prefer the iterative stack-based
version for graphs of unknown size. Raising sys.setrecursionlimit is not a real fix:
the C stack can still overflow and segfault the interpreter.
See Graph Algorithms and Trees for detailed coverage.
2. Backtracking
Backtracking uses recursion to explore all possible solutions by trying partial solutions and abandoning them if they can't lead to a valid solution.
# Generate all permutations
#
# Track which POSITIONS are used, not which values. Testing `if num not in current`
# compares by value, so it silently skips genuine permutations whenever the input
# contains duplicates - permute([1, 1, 2]) would return [] instead of 3 results.
# The membership test is also O(n), which would make the whole thing O(n · n!).
def permute(nums):
result = []
used = [False] * len(nums)
def backtrack(current):
# Base case: permutation complete
if len(current) == len(nums):
result.append(current[:])
return
# Try each unused position
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
current.append(nums[i]) # Make choice
backtrack(current) # Recurse
current.pop() # Undo choice (backtrack)
used[i] = False
backtrack([])
return result
# To emit only DISTINCT permutations when the input has duplicates, sort first
# and skip an element whose identical predecessor is unused at this level:
# if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue
# N-Queens Problem
def solve_n_queens(n):
result = []
board = [['.' for _ in range(n)] for _ in range(n)]
def is_safe(row, col):
# Check column
for i in range(row):
if board[i][col] == 'Q':
return False
# Check diagonals
for i, j in zip(range(row-1, -1, -1), range(col-1, -1, -1)):
if board[i][j] == 'Q':
return False
for i, j in zip(range(row-1, -1, -1), range(col+1, n)):
if board[i][j] == 'Q':
return False
return True
def backtrack(row):
if row == n:
result.append([''.join(row) for row in board])
return
for col in range(n):
if is_safe(row, col):
board[row][col] = 'Q' # Place queen
backtrack(row + 1) # Recurse
board[row][col] = '.' # Remove queen (backtrack)
backtrack(0)
return result
3. Dynamic Programming
Many dynamic programming problems have recursive solutions that can be optimized with memoization. The recursive structure helps identify overlapping subproblems.
# Coin Change Problem
def coin_change(coins, amount, memo=None):
if memo is None:
memo = {}
if amount in memo:
return memo[amount]
if amount == 0:
return 0
if amount < 0:
return float('inf')
min_coins = float('inf')
for coin in coins:
result = coin_change(coins, amount - coin, memo)
if result != float('inf'):
min_coins = min(min_coins, 1 + result)
memo[amount] = min_coins
return min_coins
# Returns float('inf') when the amount cannot be made. LeetCode 322 expects -1,
# so wrap it: r = coin_change(coins, amount); return -1 if r == float('inf') else r
See Dynamic Programming for comprehensive coverage.
4. Recursive Data Structures
Many data structures are naturally recursive, making recursion the ideal approach for operations on them.
# Linked List Operations
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def reverse_linked_list(head):
# Base case: empty list or single node
if not head or not head.next:
return head
# Reverse the rest of the list
reversed_rest = reverse_linked_list(head.next)
# Make next node point to current
head.next.next = head
head.next = None
return reversed_rest
# Binary Tree Operations
def max_depth(root):
if not root:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))
5. Mathematical Sequences
Many mathematical problems have natural recursive definitions.
# Pascal's Triangle
# NOTE: no memoization here, so this is O(2^row) - it recomputes the same
# entries exponentially many times, exactly like naive Fibonacci. Add a
# memo=None cache keyed on (row, col) to make it O(row × col).
def pascal_triangle(row, col):
# Base cases
if col == 0 or col == row:
return 1
# Recursive case: sum of two numbers above
return pascal_triangle(row - 1, col - 1) + pascal_triangle(row - 1, col)
# Catalan Numbers
def catalan(n, memo=None):
if memo is None:
memo = {}
if n in memo:
return memo[n]
if n <= 1:
return 1
result = 0
for i in range(n):
result += catalan(i, memo) * catalan(n - 1 - i, memo)
memo[n] = result
return result
6. String/Array Processing
Many string and array problems can be solved recursively by processing one element and recursively handling the rest.
# Generate all subsets
def subsets(nums):
result = []
def backtrack(index, current):
# Add current subset
result.append(current[:])
# Try adding each remaining element
for i in range(index, len(nums)):
current.append(nums[i])
backtrack(i + 1, current)
current.pop()
backtrack(0, [])
return result
# Generate all combinations
def combinations(n, k):
result = []
def backtrack(start, current):
if len(current) == k:
result.append(current[:])
return
for i in range(start, n + 1):
current.append(i)
backtrack(i + 1, current)
current.pop()
backtrack(1, [])
return result
Recursion vs Iteration
Both recursion and iteration can solve many problems. Understanding when to use each is crucial for writing efficient code.
When to Use Recursion
- Problem has natural recursive structure (trees, graphs, divide-and-conquer)
- Solution is more intuitive and readable with recursion
- Problem size is bounded (won't cause stack overflow)
- Backtracking or exploring multiple paths
When to Use Iteration
- Simple loops are more efficient
- Stack overflow is a concern (deep recursion)
- Performance is critical (iteration is usually faster)
- Problem has clear iterative structure
Converting Recursion to Iteration
Many recursive functions can be converted to iterative versions using stacks or by identifying the iterative pattern.
# Recursive factorial
def factorial_recursive(n):
if n <= 1:
return 1
return n * factorial_recursive(n - 1)
# Iterative factorial
def factorial_iterative(n):
result = 1
for i in range(2, n + 1):
result *= i
return result
# Recursive DFS
def dfs_recursive(graph, node, visited):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs_recursive(graph, neighbor, visited)
# Iterative DFS using stack
def dfs_iterative(graph, start):
visited = set()
stack = [start]
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
stack.extend(reversed(graph[node])) # Reverse to maintain order
return visited
Common Pitfalls and Debugging Tips
Common Mistakes
- Missing or Incorrect Base Case: Leads to infinite recursion and stack overflow.
- Not Making Progress: Recursive call must move toward base case (e.g., n-1, not n).
- Modifying Shared State: Be careful with global variables or mutable default arguments.
- Forgetting Return Statement: Recursive calls must return values to combine results.
- Stack Overflow: Deep recursion can exhaust the call stack (typically ~1000-3000 calls).
Debugging Techniques
# Add print statements to trace execution
def factorial_debug(n, depth=0):
indent = " " * depth
print(f"{indent}factorial({n}) called")
if n <= 1:
print(f"{indent}Base case: returning 1")
return 1
result = n * factorial_debug(n - 1, depth + 1)
print(f"{indent}factorial({n}) returning {result}")
return result
# Use a wrapper to track calls
def count_calls(func):
def wrapper(*args, **kwargs):
wrapper.call_count += 1
print(f"Call #{wrapper.call_count}: {func.__name__}{args}")
return func(*args, **kwargs)
wrapper.call_count = 0
return wrapper
@count_calls
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
Tail Recursion
Tail recursion occurs when the recursive call is the last operation. Some languages optimize tail recursion, but Python doesn't. However, understanding tail recursion helps convert to iterative solutions.
# Not tail recursive (multiplication after recursive call)
def factorial_not_tail(n):
if n <= 1:
return 1
return n * factorial_not_tail(n - 1) # Operation after recursion
# Tail recursive version
def factorial_tail(n, accumulator=1):
if n <= 1:
return accumulator
return factorial_tail(n - 1, n * accumulator) # Recursion is last operation
# Can be converted to iteration:
def factorial_iterative(n):
accumulator = 1
while n > 1:
accumulator *= n
n -= 1
return accumulator
Practice Problems
Here are recommended problems to practice recursion and divide-and-conquer:
Easy
- LeetCode 70: Climbing Stairs
- LeetCode 509: Fibonacci Number
- LeetCode 344: Reverse String
- LeetCode 206: Reverse Linked List
- LeetCode 104: Maximum Depth of Binary Tree
Medium
- LeetCode 50: Pow(x, n)
- LeetCode 46: Permutations
- LeetCode 78: Subsets
- LeetCode 22: Generate Parentheses
- LeetCode 53: Maximum Subarray
- LeetCode 240: Search a 2D Matrix II
Hard
- LeetCode 51: N-Queens
- LeetCode 23: Merge k Sorted Lists
- LeetCode 315: Count of Smaller Numbers After Self
- LeetCode 327: Count of Range Sum
Key Concepts to Practice
- Identifying base cases and recursive cases
- Understanding the call stack
- Implementing memoization
- Converting between recursive and iterative solutions
- Debugging recursive functions
Summary
Recursion and divide-and-conquer are fundamental techniques in computer science. Recursion provides an elegant way to solve problems with natural recursive structure, while divide-and-conquer enables efficient solutions by breaking problems into smaller subproblems. Memoization optimizes recursive solutions by eliminating redundant computations.
Key takeaways:
- Every recursive function needs a base case and recursive case
- Divide-and-conquer follows: Divide → Conquer → Combine
- Memoization transforms exponential time complexity to polynomial
- Understanding common patterns helps recognize when to use recursion
- Practice converting between recursive and iterative solutions
These concepts form the foundation for more advanced topics like dynamic programming, graph algorithms, and tree operations covered in later chapters.