⚙️AlgorithmIntermediate

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 memoizationDP often uses top-down recursion with memoization or bottom-up tables; understanding call structure and caching is essential.
  • Time and space complexityState design requires estimating if the product of dimensions is feasible; Big-O reasoning is crucial.
  • Basic combinatorics and subsetsSubset counting and bitmask representations underlie many DP states and transitions.
  • Modular arithmeticAccumulators 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 definitions

01Overview

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

A DP state is a tuple s in a finite (or bounded) set S encoding all the information required to compute an optimal or aggregated value V(s) using a recurrence that references only states in S. Formally, let G = (S, E) be a directed acyclic dependency graph where () ∈ E if the value at s depends on value(s) at t. A DP is well-formed if for every s ∈ S, V(s) can be computed from base cases and a finite set of transitions T(s) ⊆ S with a recurrence of the form V(s) = F( {V(t) | t ∈ T(s)} ), where F is a combination such as min, max, or sum. State sufficiency requires that for any two partial histories h₁, h₂ yielding the same state s, the set of legal next actions and their costs are identical; thus the optimal continuation from s is history-independent. Minimality aims to reduce |SS| ⋅ A), where A is the average number of transitions per state.

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

The computational complexity of a DP is dominated by the number of distinct states times the number of transitions processed per state. If S is the set of states and each state considers A transitions on average, the time is O( ⋅ A). Space is typically O() for tabulation (arrays) and O(depth of recursion + memo table) for memoized recursion, which still stores one value per reachable state. For knapsack with state (i, w), = O(nW); naive tabulation uses O(nW) space and O(nW) time, but rolling arrays reduce space to O(W) because each row i depends only on row i − 1. For subset (bitmask) DP like assignment or TSP, = O(2^n) and each state may branch to O(n) next choices, yielding O(n 2^n) time and O(2^n) space. For digit DP with position L, modulus M, and a constant number of flags, the number of states is O(LM), and each state tries up to 10 digits, so time is O(10LM) and space O(LM). In multi-dimensional DPs dp[i][j][k], the product of bounds can explode; always check limits (e.g., , , ) to ensure feasibility. When states are sparse, memoization explores only reachable states, often much less than the full table. Finally, micro-optimizations (iteration order, bit operations, branch elimination) matter, but the primary lever is picking the minimal sufficient state.

Code Examples

dp[i]: Max sum with no adjacent elements (House Robber pattern)
1#include <bits/stdc++.h>
2using 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
7long 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
26int 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.

Time: O(n)Space: O(1)
dp[i][w] with rolling array: 0/1 Knapsack
1#include <bits/stdc++.h>
2using 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
8long 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
21int 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.

Time: O(nW)Space: O(W)
dp[mask]: Assignment problem (minimum cost matching workers to jobs)
1#include <bits/stdc++.h>
2using 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
8long 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
28int 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.

Time: O(n 2^n)Space: O(2^n)
dp[pos][sum_mod][tight][started]: Digit DP counting numbers ≤ N with digit sum divisible by m
1#include <bits/stdc++.h>
2using 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
12struct MemoKey {
13 int pos, sum_mod, tight, started;
14};
15
16long 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
43long 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
53int 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).

Time: O(10 ⋅ L ⋅ m)Space: O(L ⋅ m)