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 stack — Top-down memoization is written recursively; understanding base cases and termination is essential.
- →Big-O time and space complexity — DP design and optimization rely on estimating states and transitions to meet constraints.
- →Arrays and indexing — Tabulation uses arrays or vectors; correct indexing prevents off-by-one errors.
- →Mathematical induction or iterative reasoning — Verifying correctness of recurrences and transitions mirrors inductive proofs.
- →Basic combinatorics — Counting DPs (e.g., paths, coin change) benefit from understanding combinations and sums.
- →Greedy vs DP trade-offs — Recognizing 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 definitions01Overview
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
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
Code Examples
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Top-down Fibonacci with memoization 5 long 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 14 long 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 26 int 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).
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Returns pair<maxValue, chosenIndices> 5 pair<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 32 int 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.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // O(n^2) LIS DP with path reconstruction 5 pair<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 31 int 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.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 int 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 28 int 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.