DP State Design
Key Points
- •Dynamic Programming (DP) state design is the art of choosing what information to remember so that optimal substructure can be reused efficiently.
- •Typical state components include position or index, constraint flags (like tight or parity), accumulated values (like sum or count), and the last element taken.
- •Smaller states are faster and use less memory, but may miss necessary information; larger states are more expressive but can be exponentially slower.
- •Common patterns are one-dimensional dp[i], two-dimensional dp[i][j], bitmask dp[mask], and three-plus dimensional dp[i][j][k] with flags.
- •The total time is roughly number of states times transitions per state; design the smallest correct state before optimizing transitions.
- •Use rolling arrays to reduce space when the next layer depends only on the previous layer.
- •Bitmask DP is powerful for small n (typically ) when tracking subsets is essential.
- •Digit DP uses position and flags (tight, started) to count numbers with digit-based constraints up to a limit.
Prerequisites
- →Recursion and memoization — DP often uses top-down recursion with memoization or bottom-up tables; understanding call structure and caching is essential.
- →Time and space complexity — State design requires estimating if the product of dimensions is feasible; Big-O reasoning is crucial.
- →Basic combinatorics and subsets — Subset counting and bitmask representations underlie many DP states and transitions.
- →Modular arithmetic — Accumulators like sums modulo m are common in digit DP and counting problems.
- →Directed acyclic graphs (DAGs) — DP recurrences form DAGs of dependencies; understanding acyclicity ensures valid orderings.
Detailed Explanation
Tap terms for definitions01Overview
DP state design answers the question: what minimal information should each subproblem remember so that we can build the final answer without recomputing? In dynamic programming, we decompose a problem into overlapping subproblems and store their answers. A DP state is a coordinate in this decomposition—often a tuple like (index, capacity) or (mask, last)—whose value records the best or total result for that subproblem. Good state design captures all necessary context to ensure correctness (so transitions are valid) while avoiding redundant or irrelevant details that bloat complexity. Typical ingredients include a position in the input (i), a constraint or flag that narrows allowed moves (e.g., tight in digit DP), an accumulated measure (e.g., sum modulo m), or the identity of the last chosen item to enforce adjacency or ordering rules. Once states are fixed, we define transitions—how states move to other states—and base cases. Complexity then follows from the number of states times the branching factor of transitions. Effective state design frequently turns an intractable exponential search into a polynomial-time DP, or keeps an exponential DP feasible by shaving dimensions.
02Intuition & Analogies
Imagine packing a suitcase while planning outfits for a trip. If you remember only "day number" (position), you might still make mistakes because you forgot whether your jacket is already used or what the temperature constraint is. If you remember everything—every past outfit—it’s too much. The sweet spot is to remember just enough: the day, the weather constraint for that day, and the last worn item if repeating is disallowed. That bundle is your DP state. Similarly, in a video game level, your next move depends on where you are, whether you’ve collected a key (a flag), how many coins you’ve picked up (an accumulated value), and possibly which door you exited last (last element). If you forget the key flag, you might try to open a door you shouldn’t; if you track your entire path history, you’ll be too slow. DP state design is this balance: keep the minimal checklist needed to make every future choice valid and optimal, and forget the rest. In scheduling problems, you may only need which subset of jobs you’ve completed (a bitmask) and how many are done (popcount), not their exact order, to decide the next job. For counting numbers up to N with a digit rule, you remember your digit position and whether you’re tight to N so you don’t exceed it. Intuitively, ask: what facts change the set of legal next moves or the evaluation of the final score? Those facts belong in the state.
03Formal Definition
04When to Use
Use DP state design when your problem has overlapping subproblems and optimal substructure, but naive recursion or greedy methods fail because future choices depend on limited aspects of the past. Examples include: sequence problems with local constraints (no two adjacent picks, longest subsequence with properties); knapsack-like resource allocation where you track an index and remaining capacity; grid or path problems where your position and past direction or remaining turns matter; subset assignment and matching for small n using bitmask DP; and digit DP for counting integers up to a bound that satisfy digit-based constraints (sum, remainder, forbidden patterns). If your decisions only depend on a small, bounded summary of history—like remainder modulo m or whether a threshold has been reached—encode that summary as a state dimension. Choose DP when brute force grows exponentially but a compact state reduces possibilities to polynomial or small-exponential. Avoid DP if the necessary summary is essentially the entire history (no compact state), or if constraints are dynamic and unbounded in a way that explodes state space beyond feasible limits.
⚠️Common Mistakes
Common pitfalls include under-specifying the state, leading to incorrect transitions. For instance, in digit DP if you omit the tight flag, you may overcount numbers exceeding the limit. Similarly, forgetting to encode the last chosen item when transitions depend on adjacency leads to invalid paths. Another mistake is over-specifying the state: including full histories, absolute sums instead of modulo when only modulo matters, or duplicating symmetric dimensions, all of which balloon |S| and time. Mixing roles of dimensions (e.g., swapping index and capacity semantics) causes bugs and hard-to-debug off-by-ones. Neglecting base cases and invalid sentinel states can create cycles or access uninitialized values. Implementation-wise, iterating 0/1 knapsack capacity in increasing order with a 1D array incorrectly allows multiple picks of the same item; the correct order is decreasing. In bitmask DP, using int for masks when n ≥ 32 overflows. In digit DP, not separating the started flag from leading zeros miscounts numbers like 0 or prefixes of all zeros. Finally, failing to analyze bounds (n, W, 2^n) can pick a DP whose state space is too large; check constraints and prune or compress (rolling arrays, coordinate compression, meet-in-the-middle) as needed.
Key Formulas
State-Transition Complexity
Explanation: Total time is approximately the number of states times the average number of transitions per state. Keeping the state space small and transitions efficient controls runtime.
Knapsack State Count
Explanation: A knapsack DP with index i and capacity w has about n times W states. This guides whether W is small enough for DP to be feasible.
Bitmask DP Complexity
Explanation: Bitmask DP often has a state per subset and sometimes multiplies by n (like fixing the last element). It is practical for n up to about 20–22.
Generic Optimization Recurrence
Explanation: For optimization DPs, each state chooses an action a that leads to a next state with an added cost. The minimum over all actions yields the optimal value.
Generic Counting Recurrence
Explanation: For counting DPs, the number of solutions from a state is the sum of the counts from all valid next states. This is common in combinatorial counting.
Digit DP State Count
Explanation: Digit DP states are often indexed by position L, a modulus M (e.g., sum modulo m), and a small flag product F (tight, started). This shows why digit DP is efficient.
Subset Count
Explanation: The number of subsets of an n-element set is 2^n, matching the state count scale for bitmask DPs. This is the root of their exponential complexity.
Rolling Array Space
Explanation: If each state depends only on the previous layer, we can keep a single layer (or two) and overwrite, reducing memory from full table to one layer.
Complexity Analysis
Code Examples
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Problem: Given an array of non-negative integers, pick a subset with no two adjacent 5 // elements to maximize the sum. Demonstrates 1D DP state: dp[i] = best using first i items. 6 7 long long maxNonAdjacentSum(const vector<int>& a) { 8 int n = (int)a.size(); 9 if (n == 0) return 0; 10 11 // dp[i] = best answer considering first i elements (a[0..i-1]) 12 // Transition: dp[i] = max(dp[i-1], dp[i-2] + a[i-1]) 13 long long prev2 = 0; // dp[0] 14 long long prev1 = a[0]; // dp[1] 15 16 for (int i = 2; i <= n; ++i) { 17 long long take = prev2 + a[i-1]; 18 long long skip = prev1; 19 long long cur = max(take, skip); 20 prev2 = prev1; 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; cin >> n; // e.g., 6 31 vector<int> a(n); 32 for (int i = 0; i < n; ++i) cin >> a[i]; 33 cout << maxNonAdjacentSum(a) << "\n"; 34 return 0; 35 } 36
State dp[i] summarizes all relevant past info: using first i elements while enforcing the no-adjacent rule. We only need to know dp[i-1] (skip current) and dp[i-2] (take current), so the last element's inclusion is encoded implicitly by using i. This yields O(1) space via two variables.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // 0/1 Knapsack: maximize value with capacity W, each item can be taken at most once. 5 // Logical state: dp[i][w] = best value using first i items with capacity w. 6 // Implementation: 1D rolling array dp[w], iterating w in decreasing order to prevent reuse. 7 8 long long knapsack01(const vector<int>& wt, const vector<int>& val, int W) { 9 int n = (int)wt.size(); 10 vector<long long> dp(W + 1, 0); // dp[w] == dp[i][w] for current i after processing 11 12 for (int i = 0; i < n; ++i) { 13 // Decreasing w ensures each item i is used at most once 14 for (int w = W; w >= wt[i]; --w) { 15 dp[w] = max(dp[w], dp[w - wt[i]] + val[i]); 16 } 17 } 18 return dp[W]; 19 } 20 21 int main() { 22 ios::sync_with_stdio(false); 23 cin.tie(nullptr); 24 25 int n, W; cin >> n >> W; // e.g., n=4, W=7 26 vector<int> wt(n), val(n); 27 for (int i = 0; i < n; ++i) cin >> wt[i] >> val[i]; 28 cout << knapsack01(wt, val, W) << "\n"; 29 return 0; 30 } 31
The conceptual state is dp[i][w], but because dp[i] depends only on dp[i−1], we compress to 1D. The key design is to include both index i and remaining capacity w; any smaller state (e.g., only w) would mix different item sets and be incorrect. Decreasing w avoids reusing the same item within the same layer.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Given an n x n cost matrix cost[i][j], assign each worker i to a unique job j minimizing total cost. 5 // State: dp[mask] = minimum cost to assign the first popcount(mask) workers to the set of jobs indicated by 'mask'. 6 // Transition: try assigning the next worker to any unassigned job. 7 8 long long assignmentDP(const vector<vector<int>>& cost) { 9 int n = (int)cost.size(); 10 int N = 1 << n; 11 const long long INF = (1LL<<60); 12 vector<long long> dp(N, INF); 13 dp[0] = 0; // No workers assigned, no jobs used 14 15 for (int mask = 0; mask < N; ++mask) { 16 int i = __builtin_popcount((unsigned)mask); // next worker index 17 if (i >= n) continue; 18 for (int j = 0; j < n; ++j) { 19 if (!(mask & (1 << j))) { 20 int nmask = mask | (1 << j); 21 dp[nmask] = min(dp[nmask], dp[mask] + cost[i][j]); 22 } 23 } 24 } 25 return dp[N - 1]; 26 } 27 28 int main() { 29 ios::sync_with_stdio(false); 30 cin.tie(nullptr); 31 32 int n; cin >> n; // e.g., n <= 20 for feasibility 33 vector<vector<int>> cost(n, vector<int>(n)); 34 for (int i = 0; i < n; ++i) 35 for (int j = 0; j < n; ++j) 36 cin >> cost[i][j]; 37 38 cout << assignmentDP(cost) << "\n"; 39 return 0; 40 } 41
The state tracks exactly which jobs are taken (mask) and, implicitly, how many workers have been assigned (popcount). We do not need to store the entire sequence of job choices—only the subset—because future options depend solely on which jobs remain. This is a classic example of subset/bitmask DP state design.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Count integers x with 0 <= x <= N such that sum_of_digits(x) % m == 0. 5 // State components: 6 // - pos: current index in the digit string (0..L) 7 // - sum_mod: current digit sum modulo m (0..m-1) 8 // - tight: are we restricted to digits <= N[pos]? (0/1) 9 // - started: have we placed any non-leading-zero yet? (0/1), to treat leading zeros correctly 10 // Recurrence tries digits 0..limit and updates sum_mod only after we've started. 11 12 struct MemoKey { 13 int pos, sum_mod, tight, started; 14 }; 15 16 long long dfs(int pos, int sum_mod, int tight, int started, 17 const string &S, int m, 18 vector<vector<vector<vector<long long>>>> &dp) { 19 if (pos == (int)S.size()) { 20 // At the end: valid if started (we counted a number, possibly 0) and sum_mod == 0 21 return (sum_mod % m == 0) ? 1LL : 0LL; 22 } 23 long long &res = dp[pos][sum_mod][tight][started]; 24 if (res != -1) return res; 25 res = 0; 26 27 int limit = tight ? (S[pos] - '0') : 9; 28 for (int dig = 0; dig <= limit; ++dig) { 29 int ntight = tight && (dig == limit); 30 int nstarted = started || (dig != 0); 31 int nsum = sum_mod; 32 if (nstarted) { 33 nsum = (sum_mod + dig) % m; 34 } else { 35 // Leading zeros do not contribute to sum 36 // Keep nsum = sum_mod 37 } 38 res += dfs(pos + 1, nsum, ntight, nstarted, S, m, dp); 39 } 40 return res; 41 } 42 43 long long countSumDivisible(string N, int m) { 44 // Pad N with no changes; we use its digits as the tight bound 45 int L = (int)N.size(); 46 // dp dimensions: L x m x 2 x 2, initialized to -1 47 vector<vector<vector<vector<long long>>>> dp( 48 L, vector<vector<vector<long long>>>( 49 m, vector<vector<long long>>(2, vector<long long>(2, -1)))); 50 return dfs(0, 0, 1, 0, N, m, dp); 51 } 52 53 int main() { 54 ios::sync_with_stdio(false); 55 cin.tie(nullptr); 56 57 string N; int m; // Example: N = "1000", m = 3 58 cin >> N >> m; 59 cout << countSumDivisible(N, m) << "\n"; 60 return 0; 61 } 62
This showcases multi-dimensional state with constraint flags. We include pos (where we are), sum_mod (accumulated value), tight (to avoid exceeding N), and started (to handle leading zeros correctly). Each component is necessary: omitting tight breaks the bound, and omitting started miscounts numbers with leading zeros. The number of states is O(L ⋅ m ⋅ 2 ⋅ 2).