⚙️AlgorithmIntermediate

Dynamic Programming Fundamentals

Key Points

  • Dynamic programming (DP) solves complex problems by breaking them into overlapping subproblems and using their optimal substructure.
  • Top-down with memoization caches results of recursive calls, while bottom-up tabulation fills a table iteratively from base cases.
  • Designing a DP requires choosing a good state that captures exactly the information needed to answer the question.
  • Transitions describe how to combine solutions of smaller states to build the solution of a larger state.
  • The total time is roughly the number of states times the work per transition, often yielding O(), O(nm), or O(n log n) depending on the problem.
  • Space can often be reduced by keeping only the last few relevant rows or columns (rolling arrays).
  • DP solutions are deterministic and avoid recomputation, turning exponential brute-force searches into polynomial time.
  • Common mistakes include wrong base cases, missing transitions, and states that are too large or too small to uniquely determine answers.

Prerequisites

  • Recursion and call stackTop-down memoization is written recursively; understanding base cases and termination is essential.
  • Big-O time and space complexityDP design and optimization rely on estimating states and transitions to meet constraints.
  • Arrays and indexingTabulation uses arrays or vectors; correct indexing prevents off-by-one errors.
  • Mathematical induction or iterative reasoningVerifying correctness of recurrences and transitions mirrors inductive proofs.
  • Basic combinatoricsCounting DPs (e.g., paths, coin change) benefit from understanding combinations and sums.
  • Greedy vs DP trade-offsRecognizing when a greedy shortcut fails helps choose DP appropriately.
  • Graph basics (DAGs and topological order)Bottom-up DP respects a DAG of dependencies; order matters for correctness.
  • Modular arithmetic (optional)Counting problems often require results modulo a large prime to avoid overflow.

Detailed Explanation

Tap terms for definitions

01Overview

Dynamic programming (DP) is a problem-solving technique that transforms an expensive recursive search into an efficient computation by reusing previous results. Many problems can be decomposed into smaller subproblems that recur in multiple places; DP stores the answers to those subproblems so each is solved only once. The hallmark features are optimal substructure—the best solution to the whole problem is composed of best solutions to parts—and overlapping subproblems—the same subproblem appears repeatedly in a naive recursion. There are two main implementation styles: top-down (memoization), where we write a natural recursion and cache results, and bottom-up (tabulation), where we compute answers for all subproblems in an order that ensures prerequisites are already computed. Central to any DP is the notion of a state, which captures the minimal information needed to uniquely determine the answer for a subproblem, and a transition, which defines how to build larger answers from smaller ones. With careful state design, DP often reduces exponential-time brute force to polynomial time, making it a go-to technique in algorithmic competitions and practical applications like sequence alignment, resource allocation, and route planning.

02Intuition & Analogies

Imagine assembling a large jigsaw puzzle. You don’t try every possible placement of every piece; instead, you complete small recognizable sections (like the sky or the border) and reuse those completed sections as building blocks. If a friend asks you later to build the same sky section, you don’t start over—you reuse what you already built. That’s dynamic programming in spirit: solve smaller sections once, remember the result, and plug them together to solve the entire puzzle. Another analogy is cooking with prep work. If multiple recipes need chopped onions, you chop once, store them, and reuse them across dishes instead of chopping anew each time. Memoization is like labeling and storing the chopped onions for later; tabulation is like following a prep schedule that chops all veggies in the order you’ll need them. The DP state is the label on each container—what food it is, how it’s cut, and how much—so that you can retrieve exactly what you need. The transition is the recipe step that says how to combine prepped ingredients into a dish. If your labels are too vague (e.g., just “vegetables”), you won’t know which container to grab; if they’re too specific (e.g., “three onion slivers cut at a 32-degree angle”), you’ll waste time and storage managing unnecessary variations. Good DP is about crafting just-right labels and following a preparation order so that, when it’s time to cook, everything you need is already ready to go.

03Formal Definition

Dynamic programming applies to optimization and counting problems that exhibit both optimal substructure and overlapping subproblems. Formally, suppose a problem’s solution can be expressed via a recurrence of the form S(x) = (S(), S(), ) where each is a sub-argument derived from x, and the subproblems repeat across the recursion tree. A DP defines a finite set of states X and a function f: X R such that f(s) is the value (e.g., optimum, count, feasibility) for state s. The problem’s answer is f() for some initial state . A transition relation T specifies how to compute f(s) from values f(s') of predecessor states s' in P(s). Base cases B X have values specified directly. A bottom-up tabulation selects a topological order over the DAG of states so that when computing f(s), all f(s') with s' P(s) are already known. A top-down memoized recursion computes f(s) on demand and caches results, ensuring each state is evaluated at most once. The total time is O( ), where is the number of states and is the maximum cost per transition; space is O() unless reduced by space-optimization techniques (e.g., rolling arrays) when dependencies are local.

04When to Use

Use dynamic programming when naive recursion explores exponentially many overlapping cases. Classic signals include: (1) you can define a recurrence that builds larger answers from smaller ones, (2) choosing among a small set of options at each step (e.g., pick/skip, match/substitute/insert/delete), (3) constraints that are local in some ordering (e.g., prefixes of strings, first i items, last taken value), and (4) the search space forms an acyclic dependency graph. Typical use cases include knapsack and resource allocation (optimize value under capacity), sequence alignment and edit distance (transform one string into another), counting paths or ways (grid paths, coin change), subsequence problems (longest increasing subsequence, LCS), and shortest paths on DAGs (DP over topological order). In competitive programming (CF 1200–1600), expect 1D DP over arrays or values (coin change, LIS), 2D DP over indices (LCS, edit distance), bitset or rolling-array optimizations for memory, and reconstruction of one optimal solution. If dependencies are cyclic without a natural layering, consider graph algorithms (e.g., Dijkstra). If subproblems don’t overlap or structure is greedy-exploitable, DP might be overkill or inferior.

⚠️Common Mistakes

Common DP mistakes include: (1) Poor state design—omitting necessary information (leading to incorrect reuse across incompatible contexts) or including unnecessary dimensions (blowing up time/space). To avoid this, prove that your state uniquely determines the answer and cannot be further compressed. (2) Wrong base cases—forgetting empty prefixes, zero capacity, or off-by-one boundaries. Always write and test the smallest inputs first and include sentinel rows/columns when tabulating. (3) Incorrect transition order—computing a state before its prerequisites. Fix by deriving a dependency DAG and ordering loops accordingly; for knapsack 0/1, iterate weight backwards; for unbounded, iterate forwards. (4) Mixing 0/1 and unbounded transitions—accidentally allowing multiple picks when only one is permitted. (5) Memory misuse—creating huge tables (e.g., O(n^3)) when a 1D or rolling array suffices; know your constraints and optimize. (6) Ignoring reconstruction—failing to track choices when the problem asks for the solution itself, not just the value. Store parent pointers or recompute decisions using the table. (7) Over-recursing—top-down without memoization or with a hashmap in tight loops can cause TLE; prefer arrays for speed and consider bottom-up. (8) Integer overflow—values can exceed 32-bit; use long long or modular arithmetic as required.

Key Formulas

Fibonacci Recurrence

Explanation: Each Fibonacci number equals the sum of the previous two. This classic recurrence produces overlapping subproblems, making it ideal for memoization or tabulation.

0/1 Knapsack Transition

Explanation: The best value using first i items and capacity w is either skipping item i or taking it (if it fits). This captures optimal substructure with O(nW) complexity.

LIS O(n^2) Recurrence

Explanation: The LIS ending at index i extends the best LIS ending at a smaller index j with a smaller value. If none exists, the LIS length at i is 1.

Edit Distance (Levenshtein)

Explanation: To convert prefixes s[1..i] to t[1..j], either delete, insert, or substitute. The indicator [ ] is 0 if characters match, else 1.

Grid Paths without Obstacles

Explanation: The number of ways to reach cell (i, j) is the sum of ways from the top and from the left, assuming only right and down moves are allowed.

Coin Change (Min Coins)

Explanation: The minimum coins to form amount x is one coin plus the best way to form the remaining amount. This leads to O(n X) time for n coin types and target X.

DP Time Complexity Template

Explanation: Total runtime equals the number of distinct states times the cost to compute each. Analyze both factors to estimate complexity and guide optimizations.

General Optimization DP

Explanation: Many DPs choose among a small option set K for each state, taking the maximum (or minimum) over candidate transitions defined by f.

Complexity Analysis

For dynamic programming, runtime is typically the product of the number of states and the per-state transition cost. If a DP table has dimensions n and m (e.g., two string lengths), the number of states is O(nm). When each state examines a constant number of options, total time is O(nm). For 1D DPs like coin change or Fibonacci, states are O(n), producing O(n) time with O(n) or O(1) space (after optimization). In knapsack with n items and capacity W, there are (n+1)×(W+1) states and O(1) transitions, yielding O(nW) time and O(nW) space; space can be reduced to O(W) by rolling arrays if reconstruction is not required. In LIS, a straightforward DP checks all previous elements for each i, costing O() time and O(n) space; with binary search optimization, time drops to O(n log n) while keeping O(n) space. Edit distance over strings of length n and m runs in O(nm) time and O(nm) space; a rolling array reduces space to O(min(n, m)) since each row depends only on the previous row. Top-down memoization solves only reachable states, which can be advantageous if many states are irrelevant; however, recursion overhead and hashmap lookups can add constants. Bottom-up tabulation has predictable memory access patterns and usually outperforms top-down in tight time limits. Always verify base cases and iteration orders to respect dependencies; otherwise, complexity claims may not match actual behavior due to recomputation or incorrect transitions.

Code Examples

Fibonacci: Top-Down Memoization vs Bottom-Up Tabulation
1#include <bits/stdc++.h>
2using namespace std;
3
4// Top-down Fibonacci with memoization
5long long fibMemo(int n, vector<long long>& memo) {
6 if (n <= 1) return n; // base cases: F(0)=0, F(1)=1
7 if (memo[n] != -1) return memo[n]; // reuse cached result
8 // Recurrence: F(n) = F(n-1) + F(n-2)
9 memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
10 return memo[n];
11}
12
13// Bottom-up Fibonacci with tabulation
14long long fibTab(int n) {
15 if (n <= 1) return n;
16 long long prev2 = 0; // F(0)
17 long long prev1 = 1; // F(1)
18 for (int i = 2; i <= n; ++i) {
19 long long cur = prev1 + prev2; // transition
20 prev2 = prev1; // shift window
21 prev1 = cur;
22 }
23 return prev1;
24}
25
26int main() {
27 ios::sync_with_stdio(false);
28 cin.tie(nullptr);
29
30 int n = 45; // Example input; large enough to see benefits over naive recursion
31
32 vector<long long> memo(n + 1, -1);
33 cout << "Top-Down (memo) F(" << n << ") = " << fibMemo(n, memo) << "\n";
34
35 cout << "Bottom-Up (tab) F(" << n << ") = " << fibTab(n) << "\n";
36
37 return 0;
38}
39

This program demonstrates the two main DP styles on the Fibonacci recurrence. The memoized version caches recursive results so each F(k) is computed once. The tabulated version iteratively builds from base cases and uses O(1) space via two variables (rolling window).

Time: O(n)Space: Top-down: O(n) for memo + O(n) recursion stack; Bottom-up: O(1)
0/1 Knapsack: Bottom-Up with Reconstruction of Chosen Items
1#include <bits/stdc++.h>
2using namespace std;
3
4// Returns pair<maxValue, chosenIndices>
5pair<int, vector<int>> knapsack01(const vector<int>& w, const vector<int>& v, int W) {
6 int n = (int)w.size();
7 vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));
8
9 // Build table: dp[i][cap] = best value using first i items with capacity cap
10 for (int i = 1; i <= n; ++i) {
11 for (int cap = 0; cap <= W; ++cap) {
12 dp[i][cap] = dp[i - 1][cap]; // skip item i-1 by default
13 if (w[i - 1] <= cap) {
14 dp[i][cap] = max(dp[i][cap], dp[i - 1][cap - w[i - 1]] + v[i - 1]);
15 }
16 }
17 }
18
19 // Reconstruction: walk backwards to find which items were taken
20 vector<int> chosen;
21 int cap = W;
22 for (int i = n; i >= 1; --i) {
23 if (dp[i][cap] != dp[i - 1][cap]) { // value improved by taking item i-1
24 chosen.push_back(i - 1);
25 cap -= w[i - 1]; // reduce capacity by item's weight
26 }
27 }
28 reverse(chosen.begin(), chosen.end());
29 return {dp[n][W], chosen};
30}
31
32int main() {
33 ios::sync_with_stdio(false);
34 cin.tie(nullptr);
35
36 // Example: 4 items
37 vector<int> w = {3, 4, 7, 8};
38 vector<int> v = {4, 5, 10, 11};
39 int W = 10;
40
41 auto [best, items] = knapsack01(w, v, W);
42 cout << "Best value = " << best << "\nItems taken (0-indexed): ";
43 for (int idx : items) cout << idx << ' ';
44 cout << "\n";
45 return 0;
46}
47

Classic 0/1 knapsack tabulation fills a (n+1)×(W+1) table and then reconstructs which items were chosen by walking backwards from (n, W). The check dp[i][cap] != dp[i-1][cap] indicates item i-1 was taken.

Time: O(nW)Space: O(nW) (can be reduced to O(W) if reconstruction is not required)
Longest Increasing Subsequence (LIS) with O(n^2) DP and Reconstruction
1#include <bits/stdc++.h>
2using namespace std;
3
4// O(n^2) LIS DP with path reconstruction
5pair<int, vector<int>> LIS(const vector<int>& a) {
6 int n = (int)a.size();
7 vector<int> dp(n, 1); // dp[i] = LIS length ending at i
8 vector<int> parent(n, -1); // parent[i] = previous index in LIS ending at i
9
10 int bestLen = 0, bestEnd = -1;
11 for (int i = 0; i < n; ++i) {
12 for (int j = 0; j < i; ++j) {
13 if (a[j] < a[i] && dp[j] + 1 > dp[i]) {
14 dp[i] = dp[j] + 1;
15 parent[i] = j;
16 }
17 }
18 if (dp[i] > bestLen) {
19 bestLen = dp[i];
20 bestEnd = i;
21 }
22 }
23
24 // Reconstruct sequence by following parent pointers
25 vector<int> seq;
26 for (int cur = bestEnd; cur != -1; cur = parent[cur]) seq.push_back(a[cur]);
27 reverse(seq.begin(), seq.end());
28 return {bestLen, seq};
29}
30
31int main() {
32 ios::sync_with_stdio(false);
33 cin.tie(nullptr);
34
35 vector<int> a = {10, 9, 2, 5, 3, 7, 101, 18};
36 auto [len, seq] = LIS(a);
37 cout << "LIS length = " << len << "\nSequence: ";
38 for (int x : seq) cout << x << ' ';
39 cout << "\n";
40 return 0;
41}
42

For each index i, we look back at all j < i with a[j] < a[i] to extend the best subsequence found so far. Parent pointers let us reconstruct one valid LIS. While O(n^2), this approach is simple and reliable in 1200–1600 difficulty.

Time: O(n^2)Space: O(n)
Edit Distance (Levenshtein) with Rolling Array Space Optimization
1#include <bits/stdc++.h>
2using namespace std;
3
4int editDistance(const string& s, const string& t) {
5 int n = (int)s.size();
6 int m = (int)t.size();
7 // Use two rows to reduce space from O(nm) to O(m)
8 vector<int> prev(m + 1), cur(m + 1);
9
10 // Base: dp[0][j] = j (insert j chars), dp[i][0] = i (delete i chars)
11 iota(prev.begin(), prev.end(), 0); // prev[j] = j for i = 0
12
13 for (int i = 1; i <= n; ++i) {
14 cur[0] = i; // converting s[0..i-1] to empty requires i deletions
15 for (int j = 1; j <= m; ++j) {
16 int cost = (s[i - 1] == t[j - 1]) ? 0 : 1; // substitution cost
17 cur[j] = min({
18 prev[j] + 1, // delete s[i-1]
19 cur[j - 1] + 1, // insert t[j-1]
20 prev[j - 1] + cost // match/substitute
21 });
22 }
23 swap(prev, cur);
24 }
25 return prev[m];
26}
27
28int main() {
29 ios::sync_with_stdio(false);
30 cin.tie(nullptr);
31
32 string s = "kitten", t = "sitting";
33 cout << "Edit distance between '" << s << "' and '" << t << "' = " << editDistance(s, t) << "\n";
34 return 0;
35}
36

The DP compares prefixes of the two strings and considers delete, insert, and substitute transitions. Only the previous row is needed to compute the current row, enabling O(m) space via rolling arrays.

Time: O(nm)Space: O(m)