Dynamic Programming

Dynamic Programming (DP) is a powerful algorithmic paradigm used to solve complex problems by breaking them down into simpler sub‑problems. It is especially valuable when a problem exhibits optimal substructure and overlapping subproblems. This tutorial walks you through the theory, design patterns, and practical implementations of DP in several popular programming languages.

What Is Dynamic Programming?

The term was coined by Richard Bellman in the 1950s. DP does not refer to a specific data structure or language feature; rather, it is a methodology that combines recursion with memoization (caching) or tabulation (bottom‑up iteration) to avoid redundant computation.

📝 Note: DP is not a silver bullet. It works best when the problem can be expressed as a recurrence relation and when the number of distinct sub‑states is polynomially bounded.

Core Principles

  • Optimal Substructure – an optimal solution can be constructed from optimal solutions of its subproblems.
  • Overlapping Subproblems – the same subproblems are solved multiple times in a naive recursive solution.
  • State Definition – a concise representation of a subproblem (often as a tuple of indices or counters).
  • Transition – how to move from one state to another, usually expressed as a recurrence.
  • Base Cases – trivial subproblems with known solutions.

Designing a DP Solution

Step‑by‑Step Process

  1. Identify if the problem has optimal substructure and overlapping subproblems.
  2. Define the DP state(s) and decide on their dimensionality.
  3. Derive the recurrence relation (transition).
  4. Choose between top‑down (memoization) and bottom‑up (tabulation).
  5. Implement the solution, handling base cases and boundary conditions.
  6. Optimize space if possible (e.g., reduce a 2‑D table to 1‑D).

Classic Example: Fibonacci Numbers

The Fibonacci sequence is the canonical example for illustrating DP. The naive recursive solution has exponential time complexity, while DP reduces it to linear.

Top‑Down (Memoization) – Python

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

# Example usage
print(fib_memo(10))  # Output: 55

Bottom‑Up (Tabulation) – C++

#include <iostream>
#include <vector>

int fib_tab(int n) {
    if (n <= 1) return n;
    std::vector<int> dp(n+1);
    dp[0] = 0;
    dp[1] = 1;
    for (int i = 2; i <= n; ++i) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

int main() {
    std::cout << fib_tab(10) << std::endl; // 55
    return 0;
}

Space‑Optimized Bottom‑Up – Java

public class Fibonacci {
    public static int fib(int n) {
        if (n <= 1) return n;
        int a = 0, b = 1, c = 0;
        for (int i = 2; i <= n; i++) {
            c = a + b;
            a = b;
            b = c;
        }
        return c;
    }
    public static void main(String[] args) {
        System.out.println(fib(10)); // 55
    }
}
⚠ Warning: When using recursion with memoization, ensure the memo table (e.g., a dictionary or array) is properly scoped to avoid unintended shared state across multiple calls.

Comparing Top‑Down vs Bottom‑Up Approaches

AspectTop‑Down (Memoization)Bottom‑Up (Tabulation)
Implementation StyleRecursive with cacheIterative with table
Memory OverheadStack + cacheTable only
Ease of UnderstandingOften more intuitiveRequires explicit ordering
PerformanceSimilar asymptotic, slight overhead for recursionUsually faster due to no call overhead
When to UseWhen recursion mirrors problem definitionWhen you need guaranteed O(1) space or want to avoid recursion depth limits

Advanced DP Patterns

1. DP on Subsets (Bitmask DP)

Useful for problems where the state is a subset of items, e.g., the Traveling Salesperson Problem (TSP) with O(2^n * n) complexity.

TSP Example – C++ (Bitmask DP)

#include <bits/stdc++.h>
using namespace std;

const int INF = 1e9;
int n; // number of cities
vector<vector<int>> dist;
vector<vector<int>> dp;

int tsp(int mask, int pos) {
    if (mask == (1<<n)-1) return dist[pos][0]; // return to start
    int &ans = dp[mask][pos];
    if (ans != -1) return ans;
    ans = INF;
    for (int city = 0; city < n; ++city) {
        if (!(mask & (1<<city))) {
            ans = min(ans, dist[pos][city] + tsp(mask | (1<<city), city));
        }
    }
    return ans;
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    dist.assign(n, vector<int>(n));
    for(int i=0;i<n;i++)
        for(int j=0;j<n;j++)
            cin >> dist[i][j];
    dp.assign(1<<n, vector<int>(n, -1));
    cout << tsp(1, 0) << "\n"; // start from city 0, mask with city 0 visited
    return 0;
}

2. DP on Trees

Tree DP leverages the hierarchical structure of trees, often using depth‑first search (DFS) to compute values for each subtree.

Maximum Independent Set on a Tree – Python

def dfs(u, parent, adj, dp):
    # dp[u][0] -> max size if u is NOT taken
    # dp[u][1] -> max size if u IS taken
    dp[u][0] = 0
    dp[u][1] = 1
    for v in adj[u]:
        if v == parent:
            continue
        dfs(v, u, adj, dp)
        dp[u][0] += max(dp[v][0], dp[v][1])
        dp[u][1] += dp[v][0]

# Example usage
n = int(input())
adj = [[] for _ in range(n)]
for _ in range(n-1):
    u, v = map(int, input().split())
    adj[u].append(v)
    adj[v].append(u)

dp = [[0,0] for _ in range(n)]
dfs(0, -1, adj, dp)
print(max(dp[0][0], dp[0][1]))  # size of maximum independent set
💡 Tip: When designing tree DP, root the tree arbitrarily (often at node 0) and pass the parent node to avoid revisiting edges.

Common Pitfalls and How to Avoid Them

  • Forgetting to initialize base cases leads to undefined behavior.
  • Using mutable default arguments in Python functions for memo tables (use None and assign inside).
  • Exceeding recursion depth – switch to bottom‑up or increase the recursion limit with caution.
  • Incorrect state dimension – extra dimensions increase both time and memory exponentially.
📘 Summary: Dynamic Programming transforms exponential‑time recursive solutions into polynomial‑time algorithms by caching intermediate results. Mastery of DP involves correctly defining states, deriving recurrences, and choosing an appropriate implementation style (top‑down vs bottom‑up). With practice, DP becomes a versatile tool for a wide range of problems, from simple sequences to complex combinatorial optimization on graphs and trees.

Q: When should I prefer memoization over tabulation?
A: Memoization is ideal when the recursion tree is sparse or when you want to keep the code close to the mathematical recurrence. Tabulation is preferred when you need guaranteed O(1) access time and want to avoid recursion depth limits.


Q: Can DP be applied to problems without obvious sub‑problems?
A: Yes. Sometimes the sub‑problem definition is non‑trivial and requires creativity (e.g., DP on bitmasks, DP with state compression). The key is to find a representation that captures all necessary information to transition.


Q: How can I reduce the memory usage of a DP solution?
A: If the recurrence only depends on the previous k states, you can keep a sliding window of size k instead of the full table. For 2‑D DP, sometimes you can overwrite rows when the next row no longer needs the old one.


Q. Which of the following is NOT a characteristic of a problem suitable for DP?
  • Optimal substructure
  • Overlapping subproblems
  • Greedy choice property
  • Polynomial number of distinct sub‑states

Answer: Greedy choice property
The greedy choice property belongs to greedy algorithms, not DP. DP requires optimal substructure and overlapping subproblems.

Q. In the Fibonacci bottom‑up implementation, what is the time complexity?
  • O(2^n)
  • O(n)
  • O(n log n)
  • O(1)

Answer: O(n)
Each value from 2 to n is computed exactly once, yielding linear time.

References
🎥 Video