Digit DP - Advanced States
Key Points
- •Digit DP counts integers in a range by scanning digits from most significant to least while maintaining compact state information.
- •Advanced states let you enforce multiple constraints simultaneously, such as sum of digits modulo m, remainder modulo k, digit frequency, and pattern occurrence.
- •The core idea is to track the 'tight' flag, which ensures you never exceed the prefix of the upper bound while exploring digits.
- •Memory stays manageable because we track only what is necessary, like sum modulo m instead of the full sum, or parity masks instead of full counts.
- •Automata such as KMP can be embedded into Digit DP states to detect whether a specific digit pattern appears in the number.
- •The complexity is roughly O(D × 10 × produc_stat), where D is the number of digits, so careful state design is crucial.
- •To count in [L, R], compute solve(R) − solve(L − 1) with the same DP machinery.
- •Common pitfalls include mishandling leading zeros, forgetting to update states consistently, and over-dimensioning states that can be compressed.
Prerequisites
- →Dynamic Programming Basics — Digit DP is a specialized DP; you need to understand states, transitions, and memoization.
- →Modular Arithmetic — Constraints often involve residues modulo m or k; updates like (10·rem + d) % k are fundamental.
- →String Algorithms (KMP) — Embedding an automaton for pattern presence uses KMP or DFA construction over digits.
- →Bitmasking — Digit frequency parities and subsets are naturally represented with bitmasks.
- →Time and Space Complexity Analysis — Designing compact states requires reasoning about the growth of state dimensions.
Detailed Explanation
Tap terms for definitions01Overview
Digit DP is a dynamic programming technique specialized for problems about decimal representations of numbers in a range, typically [0, N] or [L, R]. Instead of enumerating each number, we enumerate digit choices position by position (most significant to least). At each position, we keep a small state capturing exactly what we need about the prefix built so far—like whether we are already smaller than the prefix of N (the tight flag), whether we have started placing non-leading digits, and various constraints such as sums, remainders, or pattern matches. By branching over the next digit (0–9), we count all possible completions that satisfy conditions. The key to advanced problems (1900–2300 CF) is designing compact yet sufficient state descriptions that encode multiple constraints together efficiently. For example, we might maintain both the sum of digits modulo m and the entire number modulo k, track the parity of digit frequencies in a 10-bit mask, or embed the current state of a string-matching automaton. The DP value at a state is the number of valid completions from that state to the end. We memoize these values to avoid recomputation, achieving polynomial time in the number of digits and state sizes. To handle ranges [L, R], we compute the DP up to R and subtract the DP up to L − 1.
02Intuition & Analogies
Imagine building a number like assembling a license plate one character at a time, but with strict rules: maybe the sum of digits must be divisible by 3, the number must be congruent to 7 modulo 11, it must not contain '13' as a substring, and it cannot have identical adjacent digits. If you tried to list all numbers and check them, you would drown in possibilities. Instead, take a carefully constrained walk. Start at the leftmost position. You know the maximum allowed digit there because you must not exceed the prefix of N; this is the 'tight' leash. If you pick a smaller digit, the leash comes off for the remaining positions—now any digit 0–9 is allowed. As you pick each digit, you update a small notepad of facts about the prefix: the sum so far (or its remainder mod m), the current remainder of the whole number mod k, whether you've seen the forbidden or required pattern yet, and the last digit you placed. Crucially, these notes are enough to deduce how any future digits will behave relative to your constraints. If two different partial paths land you in the same notes at the same position with the same leash state, the number of valid completions from there is identical—so you store it and reuse it. That is the memoization heart of Digit DP. To save space, you only jot down what's necessary: track sum modulo m rather than the full sum, remember only whether a pattern has been seen rather than the entire history, and record parity of digit counts using a bitmask rather than exact counts when only parity matters.
03Formal Definition
04When to Use
Use Digit DP when constraints are digit-local or can be updated as you append a digit from left to right. Classic applications include: counting numbers with digit sums or products satisfying a property (e.g., sum \equiv a (mod m), product \equiv p (mod m)), computing frequency properties (e.g., exactly k occurrences of digit x, or parity masks for Palindromic XOR tricks), constraining the entire number modulo k (track remainder as you extend), and enforcing adjacency relations (e.g., no two equal adjacent digits) by remembering the last digit. Advanced applications include embedding a finite automaton into the state to detect presence/absence of a pattern substring in the decimal form. This is powerful because any regular property over the digit alphabet can be recognized by a DFA and thus integrated with Digit DP. Use Digit DP when N is large (up to 10^{18} or more) but the number of digits D is small (\approx 19 for 64-bit) and the combined state space remains tractable. Avoid it when constraints depend on global comparisons that are hard to encode locally (e.g., the number of inversions of digits) or when required state dimensions explode beyond feasible limits.
⚠️Common Mistakes
- Leading zeros confusion: Updating sum or automaton while still in leading zeros can produce wrong counts. Usually, do not update problem-specific states until 'started' becomes true; handle the all-zero number as a special terminal case. 2) Forgetting tight logic: The allowed next digit is 0..(tight ? digit[i] : 9), and next tight becomes tight && (d == digit[i]). Off-by-one or using the wrong upper bound breaks correctness. 3) Overlarge states: Tracking full sums or full frequency vectors can explode memory; often only residues (mod m), parity masks, or capped counts are needed. 4) Inconsistent terminal checks: At position D, verify all needed conditions (e.g., if tracking a pattern, check 'seen'; if started is false, decide whether zero should count). 5) Double-counting ranges: Always compute solve(R) − solve(L − 1); forgetting to handle L=0 safely is a common bug. 6) Mixing base-10 semantics: Remember that remainder update is rem' = (10·rem + d) mod k; forgetting the base multiplier is a subtle but frequent error. 7) Automaton misuse: When the automaton reaches a full match, many problems require you to set a 'seen' flag and continue from the border (pi[len−1]) state; staying in the terminal state may overcount overlapping patterns. 8) Memory overuse: Memoizing states with tight=1 sometimes limits reuse; acceptable, but when memory is tight, memoize only for tight=0 or use rolling arrays for bottom-up DPs when possible.
Key Formulas
Digit DP Recurrence
Explanation: The number of valid completions from position i and state s equals the sum over all allowed next digits. U depends on the tight flag in s, and s' is s updated with digit d.
Tight Upper Bound
Explanation: If we are tight, the next digit cannot exceed the corresponding digit of N. Otherwise, any digit 0–9 is allowed.
Sum Modulo Update
Explanation: When you append digit d, the sum of digits modulo m updates by adding d modulo m. If you have not started, you can skip this update.
Remainder Modulo Update
Explanation: Appending digit d in base 10 multiplies the previous remainder by 10 and adds d, all modulo k. This maintains the number's modulus as you build it.
Time Complexity (General)
Explanation: The DP explores at most 10 digit choices per position across D positions, multiplied by the product of the sizes of all state dimensions (sum modulo, remainder, automaton states, etc.).
Space Complexity (Memoized States)
Explanation: Space is proportional to the number of unique states stored (excluding the digit position when using rolling arrays). Often multiplied by D when memoizing position as well.
Range Reduction
Explanation: To count valid numbers in [L, R], subtract the count up to L − 1 from the count up to R using the same DP.
Parity Mask Update
Explanation: To toggle the parity of digit d's frequency, XOR the mask with a bit at position d. This tracks even/odd counts compactly.
Automaton Transition
Explanation: When integrating a DFA (e.g., KMP), the automaton transitions to a next state based on the current state and digit d. If a terminal match occurs, set a 'seen' flag and continue from a border state.
Product Modulo Update
Explanation: Multiplying the product of digits modulo m by the new digit d updates the product constraint efficiently. If d=0, the product becomes 0.
Complexity Analysis
Code Examples
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Count numbers in [0, N] such that 5 // (sum of digits) % m == a AND number % k == b 6 // Classic multi-constraint Digit DP with states: pos, sumMod, rem, started, tight 7 8 struct SumRemainderDP { 9 vector<int> digs; // digits of N from most significant to least 10 int m, a, k, b; 11 12 // dp[pos][sumMod][rem][started][tight] => -1 means uncomputed 13 vector<vector<vector<array<array<long long,2>,2>>>> dp; 14 15 long long dfs(int pos, int sumMod, int rem, int started, int tight) { 16 if (pos == (int)digs.size()) { 17 // Terminal condition: check constraints 18 // If started==0, the number is 0 (all leading zeros). Its sum is 0 and remainder is 0. 19 int finalSum = sumMod; 20 int finalRem = rem; 21 // Note: when started==0, sumMod and rem are both 0 from initialization. 22 return (finalSum % m == a % m) && (finalRem % k == b % k) ? 1LL : 0LL; 23 } 24 long long &memo = dp[pos][sumMod][rem][started][tight]; 25 if (memo != -1) return memo; 26 long long ans = 0; 27 int up = tight ? digs[pos] : 9; 28 for (int d = 0; d <= up; ++d) { 29 int ntight = tight && (d == up); 30 int nstarted = started || (d != 0); 31 int nsum = sumMod; 32 int nrem = rem; 33 if (nstarted) { 34 nsum = (sumMod + d) % m; 35 nrem = ( (rem * 10) + d ) % k; 36 } else { 37 // still leading zeros: keep sum=0 and rem=0 38 nsum = 0; 39 nrem = 0; 40 } 41 ans += dfs(pos + 1, nsum, nrem, nstarted, ntight); 42 } 43 memo = ans; 44 return ans; 45 } 46 47 long long solve(long long N, int m_, int a_, int k_, int b_) { 48 m = m_; a = ((a_%m)+m)%m; k = k_; b = ((b_%k)+k)%k; 49 if (N < 0) return 0; // empty set 50 // Extract digits 51 digs.clear(); 52 { 53 if (N == 0) digs = {0}; 54 else { 55 vector<int> tmp; 56 while (N > 0) { tmp.push_back((int)(N % 10)); N /= 10; } 57 reverse(tmp.begin(), tmp.end()); 58 digs = tmp; 59 } 60 } 61 // Initialize DP with -1 62 dp.assign(digs.size(), {}); 63 dp.resize(digs.size(), vector<vector<array<array<long long,2>,2>>>(m, vector<array<array<long long,2>,2>>(k))); 64 for (int i = 0; i < (int)digs.size(); ++i) 65 for (int sm = 0; sm < m; ++sm) 66 for (int r = 0; r < k; ++r) 67 for (int st = 0; st < 2; ++st) 68 for (int ti = 0; ti < 2; ++ti) 69 dp[i][sm][r][st][ti] = -1; 70 return dfs(0, 0, 0, 0, 1); 71 } 72 }; 73 74 int main() { 75 ios::sync_with_stdio(false); 76 cin.tie(nullptr); 77 78 long long L = 1, R = 1000000000000LL; // 1e12 example 79 int m = 7, a = 3; // sum of digits ≡ 3 (mod 7) 80 int k = 11, b = 7; // number ≡ 7 (mod 11) 81 82 SumRemainderDP solver; 83 long long ansR = solver.solve(R, m, a, k, b); 84 long long ansL = solver.solve(L - 1, m, a, k, b); 85 cout << (ansR - ansL) << "\n"; 86 return 0; 87 } 88
We count numbers in [L, R] whose digit sum is congruent to a modulo m and whose numerical value is congruent to b modulo k. The DP state tracks position, current sum modulo m, remainder modulo k, whether we've started (non-leading zero), and tightness to the bound. For leading zeros, we keep sum and remainder at 0 and postpone updates until started=1. The range answer is solve(R) − solve(L − 1).
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Count numbers in [0, N] that: 5 // - contain a given digit pattern P at least once (as a substring), and 6 // - have no two adjacent equal digits. 7 // Advanced state: KMP automaton state + seen flag + last digit + started + tight. 8 9 struct KMPDigitDP { 10 vector<int> digs; // digits of N 11 string P; // pattern over digits '0'..'9' 12 vector<int> pi; // prefix function 13 vector<array<int,10>> nxt; // automaton transitions for states 0..|P| 14 15 // Build prefix function 16 vector<int> build_pi(const string &s) { 17 int n = (int)s.size(); 18 vector<int> p(n, 0); 19 for (int i = 1; i < n; ++i) { 20 int j = p[i-1]; 21 while (j > 0 && s[i] != s[j]) j = p[j-1]; 22 if (s[i] == s[j]) ++j; 23 p[i] = j; 24 } 25 return p; 26 } 27 28 void build_automaton() { 29 int A = (int)P.size(); 30 pi = build_pi(P); 31 nxt.assign(A + 1, {}); 32 for (int s = 0; s <= A; ++s) { 33 for (int d = 0; d < 10; ++d) { 34 char c = char('0' + d); 35 int j = s; 36 // Move using KMP failure links 37 while (j > 0 && (j == A || P[j] != c)) j = pi[j-1]; 38 if (j < A && P[j] == c) ++j; 39 // j in [0..A]; if j==A, we matched the pattern just now 40 nxt[s][d] = j; 41 } 42 } 43 } 44 45 // We'll memoize using a hash map with packed key to keep code compact. 46 unordered_map<unsigned long long, long long> memo; 47 48 // Pack state into 64 bits: pos(6) | kmp(6) | last(4) | seen(1) | started(1) | tight(1) 49 // pos up to 19 -> 5 bits ok; use 6 for safety. kmp up to |P| (<= 50 typical) -> 6-8 bits ok. 50 unsigned long long pack(int pos, int kmp, int last, int seen, int started, int tight) { 51 unsigned long long key = 0; 52 key |= (unsigned long long)pos; 53 key |= (unsigned long long)kmp << 6; 54 key |= (unsigned long long)last << 12; 55 key |= (unsigned long long)seen << 16; 56 key |= (unsigned long long)started << 17; 57 key |= (unsigned long long)tight << 18; 58 return key; 59 } 60 61 long long dfs(int pos, int kmp, int last, int seen, int started, int tight) { 62 if (pos == (int)digs.size()) { 63 return seen ? 1LL : 0LL; 64 } 65 auto key = pack(pos, kmp, last, seen, started, tight); 66 auto it = memo.find(key); 67 if (it != memo.end()) return it->second; 68 long long ans = 0; 69 int up = tight ? digs[pos] : 9; 70 for (int d = 0; d <= up; ++d) { 71 int ntight = tight && (d == up); 72 int nstarted = started || (d != 0); 73 int nlast = last; 74 int nkmp = kmp; 75 int nseen = seen; 76 77 if (!nstarted) { 78 // still leading zeros: do not update last or automaton 79 nlast = 10; // sentinel for 'no last digit' 80 nkmp = 0; 81 } else { 82 // no adjacent equal digits constraint 83 if (last != 10 && d == last) continue; 84 // KMP automaton transition on digit d 85 int j = nxt[kmp][d]; 86 if (j == (int)P.size()) { 87 nseen = 1; 88 // Continue from border state after a match 89 j = (P.size() > 0 ? pi[P.size()-1] : 0); 90 } 91 nkmp = j; 92 nlast = d; 93 } 94 ans += dfs(pos + 1, nkmp, nlast, nseen, nstarted, ntight); 95 } 96 memo[key] = ans; 97 return ans; 98 } 99 100 long long solve(long long N, const string &pattern) { 101 if (N < 0) return 0; 102 P = pattern; 103 build_automaton(); 104 // Extract digits of N 105 digs.clear(); 106 if (N == 0) digs = {0}; 107 else { 108 vector<int> tmp; 109 while (N > 0) { tmp.push_back((int)(N % 10)); N /= 10; } 110 reverse(tmp.begin(), tmp.end()); 111 digs = tmp; 112 } 113 memo.clear(); 114 return dfs(0, 0, 10, 0, 0, 1); 115 } 116 }; 117 118 int main() { 119 ios::sync_with_stdio(false); 120 cin.tie(nullptr); 121 122 long long L = 1, R = 1000000; // up to 1e6 for demo 123 string pattern = "13"; // require substring "13" 124 125 KMPDigitDP solver; 126 long long ansR = solver.solve(R, pattern); 127 long long ansL = solver.solve(L - 1, pattern); 128 cout << (ansR - ansL) << "\n"; 129 return 0; 130 } 131
We embed a KMP automaton over digits to detect whether pattern P occurs at least once. The DP state also tracks the last digit to forbid adjacent equal digits. When the automaton reaches a full match, we set seen=1 and continue from the border (pi[len−1]) to allow overlapping matches. Leading zeros do not affect the automaton or adjacency constraints until started=1.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Count numbers in [0, N] such that 5 // (product of digits) % m == p AND (count of digit 0) <= c 6 // States: pos, prodMod, zeroCount (capped), started, tight 7 8 struct ProductZeroCapDP { 9 vector<int> digs; 10 int m, p, c; 11 // dp[pos][prod][z][started][tight] 12 vector<vector<vector<array<array<long long,2>,2>>>> dp; 13 14 long long dfs(int pos, int prod, int z, int started, int tight) { 15 if (pos == (int)digs.size()) { 16 // Special handling for the number 0 (started==0): interpret as single digit 0 17 int finalProd = prod; 18 int finalZ = z; 19 if (!started) { finalProd = 0 % m; finalZ = 1; } 20 return (finalProd % m == p % m) && (finalZ <= c) ? 1LL : 0LL; 21 } 22 long long &memo = dp[pos][prod][z][started][tight]; 23 if (memo != -1) return memo; 24 long long ans = 0; 25 int up = tight ? digs[pos] : 9; 26 for (int d = 0; d <= up; ++d) { 27 int ntight = tight && (d == up); 28 int nstarted = started || (d != 0); 29 int nprod = prod; 30 int nz = z; 31 if (!nstarted) { 32 // still leading: neutral product 1, zero count 0 33 nprod = 1 % m; 34 nz = 0; 35 } else { 36 if (d == 0) { 37 nprod = 0; // once zero appears, product is 0 modulo m 38 if (nz < c) nz = nz + 1; else { // prune if exceeding cap 39 // even though we'll check at the end, pruning early saves work 40 // but be careful not to skip other digits at this position 41 } 42 } else { 43 nprod = (int)((1LL * prod * d) % m); 44 } 45 } 46 if (nz <= c) ans += dfs(pos + 1, nprod, nz, nstarted, ntight); 47 } 48 memo = ans; 49 return ans; 50 } 51 52 long long solve(long long N, int m_, int p_, int c_) { 53 if (N < 0) return 0; 54 m = m_; p = ((p_%m)+m)%m; c = c_; 55 // Extract digits 56 digs.clear(); 57 if (N == 0) digs = {0}; 58 else { 59 vector<int> tmp; 60 while (N > 0) { tmp.push_back((int)(N % 10)); N /= 10; } 61 reverse(tmp.begin(), tmp.end()); 62 digs = tmp; 63 } 64 // Initialize DP: prod in [0..m-1], z in [0..c] 65 dp.assign(digs.size(), vector<vector<array<array<long long,2>,2>>>(m, vector<array<array<long long,2>,2>>(c + 1))); 66 for (int i = 0; i < (int)digs.size(); ++i) 67 for (int pr = 0; pr < m; ++pr) 68 for (int z = 0; z <= c; ++z) 69 for (int st = 0; st < 2; ++st) 70 for (int ti = 0; ti < 2; ++ti) 71 dp[i][pr][z][st][ti] = -1; 72 // Start with neutral product=1 (mod m), zero count=0, not started, tight=1 73 return dfs(0, 1 % m, 0, 0, 1); 74 } 75 }; 76 77 int main() { 78 ios::sync_with_stdio(false); 79 cin.tie(nullptr); 80 81 long long L = 1, R = 5000000; // demo range 82 int m = 9, p = 0; // product of digits ≡ 0 (mod 9) 83 int c = 2; // at most two zeros allowed 84 85 ProductZeroCapDP solver; 86 long long ansR = solver.solve(R, m, p, c); 87 long long ansL = solver.solve(L - 1, m, p, c); 88 cout << (ansR - ansL) << "\n"; 89 return 0; 90 } 91
We track product modulo m and the count of zero digits (capped by c). Leading zeros are ignored; for the all-zero number, we define product=0 and zeroCount=1 at the end. If a zero appears, the product becomes 0 modulo m thereafter. The state is compact because we keep only residues and a capped zero count.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Count numbers in [0, N] where each digit appears an even number of times 5 // Uses an iterative DP with rolling arrays to demonstrate memory optimization. 6 // State: position, mask(10 bits parity), started, tight. 7 8 long long solve_even_parities(long long N) { 9 if (N < 0) return 0; 10 vector<int> digs; 11 if (N == 0) digs = {0}; 12 else { vector<int> t; while (N) { t.push_back(N % 10); N /= 10; } reverse(t.begin(), t.end()); digs = t; } 13 int D = (int)digs.size(); 14 15 // dp[tight][started][mask] 16 const int MASKS = 1 << 10; 17 vector<vector<long long>> cur(2, vector<long long>(MASKS*2, 0)); 18 vector<vector<long long>> nxt(2, vector<long long>(MASKS*2, 0)); 19 auto idx = [&](int started, int mask){ return started*MASKS + mask; }; 20 21 // Initialize at position 0 22 cur[1][idx(0, 0)] = 1; // tight=1, started=0, mask=0 23 24 for (int pos = 0; pos < D; ++pos) { 25 // clear next 26 for (int t = 0; t < 2; ++t) fill(nxt[t].begin(), nxt[t].end(), 0LL); 27 for (int tight = 0; tight < 2; ++tight) { 28 int up = tight ? digs[pos] : 9; 29 for (int started = 0; started < 2; ++started) { 30 for (int mask = 0; mask < MASKS; ++mask) { 31 long long ways = cur[tight][idx(started, mask)]; 32 if (!ways) continue; 33 for (int d = 0; d <= up; ++d) { 34 int ntight = tight && (d == up); 35 int nstarted = started || (d != 0); 36 int nmask = mask; 37 if (nstarted) { 38 nmask ^= (1 << d); // toggle parity of digit d 39 } 40 nxt[ntight][idx(nstarted, nmask)] += ways; 41 } 42 } 43 } 44 } 45 swap(cur, nxt); 46 } 47 48 long long ans = 0; 49 // Sum counts where mask==0 (all digits even). Include started=0 (the number 0) as even. 50 for (int tight = 0; tight < 2; ++tight) { 51 ans += cur[tight][idx(0, 0)]; 52 ans += cur[tight][idx(1, 0)]; 53 } 54 return ans; 55 } 56 57 int main(){ 58 ios::sync_with_stdio(false); 59 cin.tie(nullptr); 60 61 long long L = 0, R = 1000000; // demo 62 long long ans = solve_even_parities(R) - solve_even_parities(L - 1); 63 cout << ans << "\n"; 64 return 0; 65 } 66
We track the parity of each digit’s frequency using a 10-bit mask. The DP is iterative over positions with rolling arrays, storing only current and next layers for each tight value, which reduces memory to O(2 × 2 × 2^{10}). Leading zeros do not affect the mask until started=1; the all-zero number contributes with mask=0 as well.