Skip to main content
โšก Calmops

Dynamic Programming Patterns: Complete Guide

Introduction

Dynamic Programming (DP) is an optimization technique that solves complex problems by breaking them into simpler subproblems. It’s essential for interview success and real-world optimization problems. This guide covers common DP patterns and when to apply each.

DP Fundamentals

When to Use DP

  1. Optimal Substructure: Solution can be built from optimal solutions of subproblems
  2. Overlapping Subproblems: Same subproblems are solved multiple times

Approaches

# 1. Top-Down (Memoization)
# - Start from problem, recursively solve subproblems
# - Cache results to avoid recomputation

def fib_memo(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

# 2. Bottom-Up (Tabulation)
# - Build solution from smallest subproblems
# - Usually more space-efficient

def fib_tab(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

# 3. Space-Optimized
# - Only keep necessary states

def fib_optimized(n):
    if n <= 1:
        return n
    prev, curr = 0, 1
    for _ in range(2, n + 1):
        prev, curr = curr, prev + curr
    return curr

Common DP Patterns

1. Fibonacci / House Robber

def climb_stairs(n):
    """
    Climbing Stairs - count ways to climb n stairs.
    DP: dp[i] = dp[i-1] + dp[i-2]
    """
    if n <= 2:
        return n
    
    prev1, prev2 = 2, 1
    for i in range(3, n + 1):
        curr = prev1 + prev2
        prev2 = prev1
        prev1 = curr
    
    return prev1


def house_robber(nums):
    """
    House Robber - max money without robbing adjacent houses.
    DP: dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    """
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]
    
    prev1 = max(nums[0], nums[1])
    prev2 = nums[0]
    
    for i in range(2, len(nums)):
        curr = max(prev1, prev2 + nums[i])
        prev2 = prev1
        prev1 = curr
    
    return prev1


def house_robber_circular(nums):
    """House Robber II - houses in a circle."""
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]
    
    def rob(nums):
        prev1 = prev2 = 0
        for num in nums:
            curr = max(prev1, prev2 + num)
            prev2 = prev1
            prev1 = curr
        return prev1
    
    return max(rob(nums[:-1]), rob(nums[1:]))

2. 0/1 Knapsack

def knapsack(weights, values, capacity):
    """
    0/1 Knapsack - maximize value with weight constraint.
    Time: O(n * W), Space: O(W)
    """
    n = len(weights)
    dp = [0] * (capacity + 1)
    
    for i in range(n):
        for w in range(capacity, weights[i] - 1, -1):
            dp[w] = max(dp[w], values[i] + dp[w - weights[i]])
    
    return dp[capacity]


def knapsack_2d(weights, values, capacity):
    """0/1 Knapsack with 2D DP table."""
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    for i in range(1, n + 1):
        for w in range(capacity + 1):
            if weights[i-1] <= w:
                dp[i][w] = max(
                    dp[i-1][w],
                    values[i-1] + dp[i-1][w - weights[i-1]]
                )
            else:
                dp[i][w] = dp[i-1][w]
    
    return dp[n][capacity]


def subset_sum(nums, target):
    """Subset Sum - can we achieve target sum?"""
    dp = {0}
    for num in nums:
        dp = dp | {x + num for x in dp}
    return target in dp


def partition_equal_subset(nums):
    """Partition Equal Subset Sum."""
    total = sum(nums)
    if total % 2:
        return False
    target = total // 2
    
    dp = {0}
    for num in nums:
        dp = dp | {x + num for x in dp}
        if target in dp:
            return True
    return False

3. Longest Common Subsequence (LCS)

def lcs(s1, s2):
    """
    Longest Common Subsequence.
    DP: dp[i][j] = length of LCS of s1[0:i] and s2[0:j]
    """
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    return dp[m][n]


def lcs_with_string(s1, s2):
    """LCS returning the actual string."""
    m, n = len(s1), len(s2)
    dp = [[""] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + s1[i-1]
            else:
                dp[i][j] = dp[i-1][j] if len(dp[i-1][j]) > len(dp[i][j-1]) else dp[i][j-1]
    
    return dp[m][n]


def longest_palindromic_subsequence(s):
    """Longest Palindromic Subsequence."""
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    
    # Single characters are palindromes of length 1
    for i in range(n):
        dp[i][i] = 1
    
    # Fill for substrings of length 2 to n
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i+1][j-1] + 2 if length > 2 else 2
            else:
                dp[i][j] = max(dp[i+1][j], dp[i][j-1])
    
    return dp[0][n-1]

4. Longest Increasing Subsequence (LIS)

def lis(nums):
    """
    Longest Increasing Subsequence.
    Time: O(nยฒ), Space: O(n)
    Alternative: O(n log n) using binary search
    """
    if not nums:
        return 0
    
    dp = [1] * len(nums)
    
    for i in range(1, len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    
    return max(dp)


def lis_binary_search(nums):
    """LIS using binary search - O(n log n)."""
    import bisect
    
    dp = []
    for num in nums:
        pos = bisect.bisect_left(dp, num)
        if pos == len(dp):
            dp.append(num)
        else:
            dp[pos] = num
    
    return len(dp)


def lis_with_path(nums):
    """LIS with actual path reconstruction."""
    if not nums:
        return 0, []
    
    n = len(nums)
    dp = [1] * n
    parent = [-1] * n
    
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i] and dp[j] + 1 > dp[i]:
                dp[i] = dp[j] + 1
                parent[i] = j
    
    max_len = max(dp)
    max_idx = dp.index(max_len)
    
    # Reconstruct path
    path = []
    idx = max_idx
    while idx != -1:
        path.append(nums[idx])
        idx = parent[idx]
    
    return max_len, path[::-1]

5. Edit Distance

def edit_distance(s1, s2):
    """
    Edit Distance (Levenshtein Distance).
    Operations: insert, delete, replace
    Time: O(m * n), Space: O(min(m, n)) possible
    """
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Base cases: transforming empty string
    for i in range(m + 1):
        dp[i][0] = i  # Delete all
    for j in range(n + 1):
        dp[0][j] = j  # Insert all
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = 1 + min(
                    dp[i-1][j],    # delete
                    dp[i][j-1],    # insert
                    dp[i-1][j-1]   # replace
                )
    
    return dp[m][n]


def min_distance_word(s1, s2):
    """Min distance with word operations."""
    m, n = len(s1), len(s2)
    
    # Only allow insert/delete (no replace)
    if abs(m - n) > 1:
        return False
    
    i = j = 0
    diff = 0
    
    while i < m and j < n:
        if s1[i] == s2[j]:
            i += 1
            j += 1
        else:
            diff += 1
            if diff > 1:
                return False
            
            if m > n:
                i += 1  # Delete from s1
            elif m < n:
                j += 1  # Insert in s1
            else:
                i += 1
                j += 1
    
    return diff + (m - i) + (n - j) <= 1

6. Coin Change

def coin_change(coins, amount):
    """
    Coin Change - minimum coins to make amount.
    dp[i] = minimum coins to make amount i
    """
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    
    for coin in coins:
        for x in range(coin, amount + 1):
            dp[x] = min(dp[x], dp[x - coin] + 1)
    
    return dp[amount] if dp[amount] != float('inf') else -1


def coin_change_combinations(coins, amount):
    """Count number of ways to make amount (order matters)."""
    dp = [0] * (amount + 1)
    dp[0] = 1
    
    for coin in coins:
        for x in range(coin, amount + 1):
            dp[x] += dp[x - coin]
    
    return dp[amount]


def coin_change_combinations_unique(coins, amount):
    """Count number of ways (order doesn't matter)."""
    dp = [0] * (amount + 1)
    dp[0] = 1
    
    for coin in coins:
        for x in range(coin, amount + 1):
            dp[x] += dp[x - coin]
    
    return dp[amount]


def coin_change_with_path(coins, amount):
    """Coin change with actual coins used."""
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    parent = [-1] * (amount + 1)
    
    for coin in coins:
        for x in range(coin, amount + 1):
            if dp[x - coin] + 1 < dp[x]:
                dp[x] = dp[x - coin] + 1
                parent[x] = coin
    
    if dp[amount] == float('inf'):
        return -1, []
    
    # Reconstruct
    path = []
    curr = amount
    while curr > 0:
        path.append(parent[curr])
        curr -= parent[curr]
    
    return dp[amount], path

7. Unique Paths

def unique_paths(m, n):
    """Unique paths in m x n grid (right/down moves)."""
    dp = [1] * n
    
    for i in range(1, m):
        for j in range(1, n):
            dp[j] += dp[j - 1]
    
    return dp[n - 1]


def unique_paths_with_obstacles(grid):
    """Unique paths with obstacles (1 = obstacle)."""
    if not grid or not grid[0]:
        return 0
    
    m, n = len(grid), len(grid[0])
    dp = [[0] * n for _ in range(m)]
    dp[0][0] = 1 if grid[0][0] == 0 else 0
    
    for i in range(m):
        for j in range(n):
            if grid[i][j] == 1:
                dp[i][j] = 0
            elif i == 0 and j == 0:
                continue
            elif i == 0:
                dp[i][j] = dp[i][j-1]
            elif j == 0:
                dp[i][j] = dp[i-1][j]
            else:
                dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    return dp[m-1][n-1]


def minimum_path_sum(grid):
    """Minimum path sum in grid."""
    m, n = len(grid), len(grid[0])
    
    for i in range(m):
        for j in range(n):
            if i == 0 and j == 0:
                continue
            elif i == 0:
                grid[i][j] += grid[i][j-1]
            elif j == 0:
                grid[i][j] += grid[i-1][j]
            else:
                grid[i][j] += min(grid[i-1][j], grid[i][j-1])
    
    return grid[m-1][n-1]

8. Subarray Sum Problems

def max_subarray_sum(nums):
    """Kadane's Algorithm - max subarray sum."""
    max_sum = curr_sum = nums[0]
    
    for num in nums[1:]:
        curr_sum = max(num, curr_sum + num)
        max_sum = max(max_sum, curr_sum)
    
    return max_sum


def max_subarray_sum_circular(nums):
    """Maximum sum circular subarray."""
    def kadane(nums):
        max_sum = curr = nums[0]
        for num in nums[1:]:
            curr = max(num, curr + num)
            max_sum = max(max_sum, curr)
        return max_sum
    
    max_normal = kadane(nums)
    
    # If all numbers are negative
    if max_normal < 0:
        return max_normal
    
    # Max circular = total - min subarray
    total = sum(nums)
    min_subarray = kadane([-x for x in nums])
    max_circular = total + min_subarray
    
    return max(max_normal, max_circular)


def subarray_sum_equals_k(nums, k):
    """Count subarrays with sum equals k."""
    count = 0
    prefix_sum = 0
    seen = {0: 1}
    
    for num in nums:
        prefix_sum += num
        count += seen.get(prefix_sum - k, 0)
        seen[prefix_sum] = seen.get(prefix_sum, 0) + 1
    
    return count


def maximum_product_subarray(nums):
    """Maximum product subarray."""
    max_prod = min_prod = result = nums[0]
    
    for num in nums[1:]:
        if num < 0:
            max_prod, min_prod = min_prod, max_prod
        
        max_prod = max(num, max_prod * num)
        min_prod = min(num, min_prod * num)
        result = max(result, max_prod)
    
    return result

9. String DP Patterns

def is_interleaving(s1, s2, s3):
    """Interleaving String - is s3 formed by interleaving s1 and s2?"""
    if len(s1) + len(s2) != len(s3):
        return False
    
    dp = [[False] * (len(s2) + 1) for _ in range(len(s1) + 1)]
    dp[0][0] = True
    
    for i in range(len(s1) + 1):
        for j in range(len(s2) + 1):
            if dp[i][j]:
                if i < len(s1) and s1[i] == s3[i + j]:
                    dp[i + 1][j] = True
                if j < len(s2) and s2[j] == s3[i + j]:
                    dp[i][j + 1] = True
    
    return dp[len(s1)][len(s2)]


def word_break(s, word_dict):
    """Word Break - can string be segmented into dictionary words."""
    word_set = set(word_dict)
    dp = [False] * (len(s) + 1)
    dp[0] = True
    
    for i in range(1, len(s) + 1):
        for j in range(i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break
    
    return dp[len(s)]


def longest_common_substring(s1, s2):
    """Longest Common Substring (contiguous)."""
    m, n = len(s1), len(s2)
    result = 0
    
    dp = [[0] * (n + 1) for _ in range(2)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i % 2][j] = dp[(i-1) % 2][j-1] + 1
                result = max(result, dp[i % 2][j])
            else:
                dp[i % 2][j] = 0
    
    return result

DP Problem-Solving Framework

def solve_dp_problem(problem_type, *args):
    """
    Framework for approaching DP problems:
    
    1. Identify if it's a DP problem
    - Optimal substructure?
    - Overlapping subproblems?
    
    2. Define state
    - What does dp[i] represent?
    
    3. Find recurrence relation
    - How to compute dp[i] from dp[i-1], dp[i-2], etc.?
    
    4. Determine base cases
    - dp[0], dp[1], empty string, etc.
    
    5. Choose approach
    - Top-down (memoization) or Bottom-up (tabulation)
    
    6. Optimize if needed
    - Space optimization
    """
    pass

Summary Table

Problem Type State Definition Recurrence
Fibonacci dp[i] = ith fib dp[i] = dp[i-1] + dp[i-2]
Knapsack dp[w] = max value dp[w] = max(dp[w], dp[w-weight] + value)
LIS dp[i] = LIS ending at i dp[i] = max(dp[j] + 1) for j < i
LCS dp[i][j] = LCS of s1[:i], s2[:j] dp[i][j] = dp[i-1][j-1] + 1 or max(dp[i-1][j], dp[i][j-1])
Edit Distance dp[i][j] = distance s1[:i], s2[:j] dp[i][j] = dp[i-1][j-1] or 1 + min(dp[i-1][j], dp[i][j-1])
Coin Change dp[x] = min coins for amount x dp[x] = min(dp[x], dp[x-coin] + 1)

Conclusion

Dynamic Programming is a powerful technique that requires practice to master. Start with simple problems like Fibonacci and House Robber, then progress to more complex patterns. Remember to:

  1. First try recursive with memoization
  2. Convert to bottom-up for space efficiency
  3. Optimize space when possible
  4. Practice recognizing problem patterns

Comments