Digit DP (Counting Numbers in a Range with Digit Constraints) — Middle Level¶
Focus: Why leading-zero handling is mandatory for most constraints, how
[L, R]decomposes intof(R) − f(L−1)even whenLandRare 100-digit strings, how to swap accumulators (remainder modK, masks, last digit) into the same skeleton, the exact reason tight states must stay out of the cache, and how Digit DP compares with the alternatives.
Table of Contents¶
- Introduction
- Deeper Concepts
- Comparison with Alternatives
- Advanced Patterns
- Multiple Accumulators
- Range Decomposition and Big-N Subtraction
- Code Examples
- Error Handling
- Performance Analysis
- Best Practices
- Visual Animation
- Summary
Introduction¶
At junior level the message was a single skeleton: recurse over digit positions carrying (pos, tight, started, accumulator), memoize the free states, and read off f(N). At middle level you start asking the engineering questions that decide whether the solution is correct on the awkward inputs and general across the problem family:
- When exactly does the leading-zero (
started) flag change the answer, and what breaks if you omit it? - How do you turn
[L, R]into twof(·)calls whenLandRare too big for any integer type — and how do you computeL − 1on a digit string? - How do you fit different accumulators — remainder mod
K, a bitmask of used digits, the last digit — into the same recursion without rewriting it? - Why, precisely, must the tight state stay out of the cache, and what is the cost of getting that wrong?
- When is Digit DP the right tool versus a closed-form combinatorial formula or a plain loop?
These questions separate "I copied a digit-sum template" from "I can model any digit-constraint counting problem and trust the edges."
Deeper Concepts¶
Restating the recurrence with all four state fields¶
Let solve(pos, tight, started, acc) return the number of valid completions of the number from position pos onward, given:
pos— the next digit index (0= most significant).tight— whether the prefix so far equalsN's prefix (so this digit is capped atN[pos]).started— whether a non-zero digit has been placed yet (whether the "real" number has begun).acc— the problem-specific accumulator.
solve(pos, tight, started, acc):
if pos == D:
return isValid(acc, started) # 1 or 0
limit = tight ? N[pos] : base-1
total = 0
for dig in 0..limit:
nstarted = started || (dig != 0)
ntight = tight && (dig == limit)
nacc = update(acc, dig, started) # update depends on started for some problems
total += solve(pos+1, ntight, nstarted, nacc)
if not tight: memo[pos][started][acc] = total # FREE states only
return total
The accumulator update sometimes depends on started (e.g., for "non-decreasing digits" you ignore leading zeros when deciding the last digit). That coupling is exactly why started is part of the state, not a side variable.
The leading-zero flag, precisely¶
A number written to width D is left-padded with zeros: 7 inside a 3-digit frame is 007. Those padding zeros are not real digits. The started flag is false while we are still in the padding and flips to true on the first non-zero digit. Three classes of constraint need it:
| Constraint | Why leading zeros corrupt the naive version |
|---|---|
| No two equal adjacent digits | 0070 would treat the two padding 0s as "adjacent equal" and wrongly reject. |
| Digits non-decreasing | Padding 0s are ≤ everything, so they pass silently, but the first real digit must be compared against "nothing", not against a fake 0. |
Count occurrences of digit 0 | Padding zeros would be counted as real 0 digits, inflating the count for 0. |
| Count of distinct digits / bitmask | Padding zeros would set bit 0 in the mask before the number even starts. |
For the plain digit sum, leading zeros add 0 and are harmless — which is why the junior file could skip started. Treat that as the exception, not the rule.
Convention: the number
0itself. Withstarted,0is the unique value wherestartedstaysfalsefor every position. Decide explicitly whether0is "valid" for your predicate (often it is, sometimes the problem excludes it), and encode that inisValid(acc, started).
Why tight states must not be cached — the precise argument¶
Fix the bound N. At a given position pos, there is exactly one tight prefix: the prefix equal to N[0..pos-1]. Every other prefix is free. So:
- No reuse to gain. A tight
(pos, ...)is visited at most once during a singlef(N)computation. Caching something visited once buys nothing. - Active harm if keyed without
tight. If your cache key omitstight(a common space optimization), then a tight visit and a free visit to the same(pos, acc)would share a slot. They have different completion counts (tight is capped atN[pos], free allows allbasedigits). Whichever writes first poisons the other. The result is silently wrong.
There are two clean ways to be correct:
- Key on
tight(include it in the memo dimensions) — then tight and free never collide, but you waste space on tight entries that are never reused. - Skip caching when tight (the idiomatic choice) —
if not tightguards both the read and the write. This is the smallest correct cache.
Both appear in the code below. The skip-when-tight version is standard.
The state-count bound (where the speed comes from)¶
The number of distinct free states is D × 2 (started) × M (accumulator values). Each is computed once and loops over base digits. Hence O(D · M · base). The tight states add only O(D · base) more (one tight prefix per position). For digit sum, M = O(9D); for "mod K", M = K; for a bitmask over 10 digits, M = 2^10. The accumulator's range is the whole performance story — keep it small.
Comparison with Alternatives¶
Digit DP vs brute-force loop vs closed-form¶
| Approach | Time for [0, N] | Good when |
|---|---|---|
| Brute-force loop over all numbers | O(N · D) | N ≤ ~10^7; trivially correct, used as the oracle |
| Digit DP | O(D · M · base) | N huge (up to 10^18 or string N), property has a small accumulator |
| Closed-form combinatorics | O(D) or O(1) | The count has a clean formula (e.g., "numbers with all distinct digits") |
| Inclusion-exclusion / stars-and-bars | varies | Digit-sum counts can sometimes be done with binomials, but Digit DP is more general |
Concrete table (base = 10):
N | Brute-force ops (N) | Digit DP ops (D · M · 10, digit-sum M≈9D) | Winner |
|---|---|---|---|
| 100 | 100 | ~2 × 18 × 10 ≈ 360 | brute force (simpler) |
| 10⁴ | 10⁴ | ~4 × 36 × 10 ≈ 1 440 | tie |
| 10⁹ | 10⁹ | ~9 × 81 × 10 ≈ 7 290 | Digit DP |
| 10¹⁸ | 10¹⁸ (impossible) | ~18 × 162 × 10 ≈ 29 160 | Digit DP |
The lesson: for small N, just loop — it is simpler and the oracle anyway. For large N, Digit DP wins by an astronomical margin, and the cost is essentially independent of N (only of its digit count).
Digit DP vs a closed-form formula¶
Some digit-constraint counts have neat formulas: the number of D-digit strings with strictly increasing digits is C(9, D)-ish; the number with all distinct digits is a falling-factorial product. When a formula exists it is O(1)–O(D) and beats Digit DP. But formulas are brittle: change "increasing" to "increasing with digit sum ≤ S and not containing 4" and the formula evaporates while Digit DP just adds accumulator fields. Digit DP is the general hammer; reach for a formula only when the problem is clean and you are sure.
Advanced Patterns¶
Pattern: No two equal adjacent digits (needs started + last digit)¶
The accumulator is the last placed digit (or a sentinel meaning "none yet"). We forbid dig == last once started.
state: (pos, tight, started, last) last ∈ {0..9} or -1 (none)
at each dig: if started and dig == last: skip
nstarted = started || dig != 0
nlast = (nstarted ? dig : -1)
Leading zeros must not be treated as a "last digit" of 0, or you would wrongly forbid the first real digit from being 0. That is exactly why started gates last.
Pattern: Numbers without forbidden digits (no accumulator at all)¶
"Count numbers in [0, N] that never use the digit 4" needs no accumulator — you simply skip dig == 4 in the digit loop. The state is just (pos, tight, started). This is the simplest Digit DP and a good sanity exercise.
Pattern: Digits in non-decreasing order (last digit, gated by started)¶
Allow dig only if not started or dig >= last. The accumulator is last; started ensures the first real digit has no lower bound.
Pattern: Count of a given digit (accumulator = running count)¶
To count, across all valid numbers, how many times digit 7 appears (a sum, not a count of numbers), carry cnt7 and at the base case return cnt7 rather than 1. The recursion then sums digit-7 occurrences over all numbers ≤ N. This is the subtle "sum of a quantity over a range" variant — the base case returns a value other than 0/1.
Multiple Accumulators¶
The accumulator does not have to be a single integer. You can carry several fields at once; the state space is their product, so keep each small.
Remainder mod K (divisibility)¶
To count numbers in [0, N] divisible by K, carry the running value mod K. When you append digit dig, the value updates as value = value * base + dig, so:
At the base case, valid iff rem == 0 (and, if the problem excludes 0, gate on started). The accumulator range is 0..K-1, so M = K and the cost is O(D · K · base). This is the cleanest demonstration that the accumulator is just "whatever finite state the predicate needs."
Bitmask of used digits¶
For constraints like "count numbers whose digits are all distinct" or "uses an even number of distinct digits", carry a base-bit mask of which digits have appeared. Appending dig sets bit dig. With started you avoid setting bit 0 from padding. M = 2^base (1024 for decimal), so this is heavier — viable but watch the size.
Combining accumulators¶
You can carry, say, (rem mod K, digitSum) together: the state becomes (pos, tight, started, rem, sum) and M = K × (9D+1). Multiplying accumulator ranges multiplies the table — so combine only what the predicate truly needs, and prune aggressively.
Range Decomposition and Big-N Subtraction¶
The decomposition¶
where f(X) counts valid numbers in [0, X]. This is prefix subtraction on counts. Two practical wrinkles:
L = 0→L − 1 = −1; definef(−1) = 0(no numbers below zero) and skip the call.Lis a huge string → you cannot doL - 1in anint. Subtract one on the digit string.
Decrementing a digit string by one¶
To compute L − 1 when L is a string of digits:
def dec_string(s): # s is a non-negative integer as a string, s != "0"
digits = list(s)
i = len(digits) - 1
while i >= 0 and digits[i] == '0':
digits[i] = '9' # borrow
i -= 1
digits[i] = str(int(digits[i]) - 1)
result = ''.join(digits).lstrip('0')
return result or '0' # avoid empty string
This is the only "big number" arithmetic Digit DP needs — a single decrement. (R is used as-is; you never add to it.)
Inclusive vs exclusive endpoints¶
Digit DP's f(X) is inclusive of X. So [L, R] (both inclusive) is f(R) − f(L−1). If a problem says "strictly less than R", use f(R−1). Pin the endpoint semantics down before coding — this is the #1 off-by-one source.
Code Examples¶
Count divisible-by-K in [L, R] (remainder accumulator) — Go¶
package main
import "fmt"
func countDivisible(N string, K int) int64 {
if N == "-1" { // sentinel for f(-1)
return 0
}
d := len(N)
// memo[pos][rem]; free states only.
memo := make([][]int64, d)
for i := range memo {
memo[i] = make([]int64, K)
for j := range memo[i] {
memo[i][j] = -1
}
}
var solve func(pos int, tight bool, rem int) int64
solve = func(pos int, tight bool, rem int) int64 {
if pos == d {
if rem == 0 {
return 1
}
return 0
}
if !tight && memo[pos][rem] != -1 {
return memo[pos][rem]
}
limit := 9
if tight {
limit = int(N[pos] - '0')
}
var total int64
for dig := 0; dig <= limit; dig++ {
total += solve(pos+1, tight && dig == limit, (rem*10+dig)%K)
}
if !tight {
memo[pos][rem] = total
}
return total
}
return solve(0, true, 0)
}
// decString returns the decimal string of (value(s) - 1) for s != "0".
func decString(s string) string {
b := []byte(s)
i := len(b) - 1
for i >= 0 && b[i] == '0' {
b[i] = '9'
i--
}
b[i]--
// strip leading zeros
k := 0
for k < len(b)-1 && b[k] == '0' {
k++
}
return string(b[k:])
}
func main() {
// count multiples of 3 in [10, 25]
L, R, K := "10", "25", 3
var lowered string
if L == "0" {
lowered = "-1"
} else {
lowered = decString(L)
}
ans := countDivisible(R, K) - countDivisible(lowered, K)
fmt.Println("multiples of 3 in [10,25]:", ans) // 12,15,18,21,24 -> 5
}
Count divisible-by-K in [L, R] — Java¶
public class DivisibleRange {
static int D, K;
static int[] digits;
static long[][] memo;
static long solve(int pos, boolean tight, int rem) {
if (pos == D) return rem == 0 ? 1 : 0;
if (!tight && memo[pos][rem] != -1) return memo[pos][rem];
int limit = tight ? digits[pos] : 9;
long total = 0;
for (int dig = 0; dig <= limit; dig++) {
total += solve(pos + 1, tight && dig == limit, (rem * 10 + dig) % K);
}
if (!tight) memo[pos][rem] = total;
return total;
}
static long f(String N, int k) {
if (N.equals("-1")) return 0;
D = N.length();
K = k;
digits = new int[D];
for (int i = 0; i < D; i++) digits[i] = N.charAt(i) - '0';
memo = new long[D][K];
for (long[] row : memo) java.util.Arrays.fill(row, -1);
return solve(0, true, 0);
}
static String decString(String s) {
char[] b = s.toCharArray();
int i = b.length - 1;
while (i >= 0 && b[i] == '0') { b[i] = '9'; i--; }
b[i]--;
int k = 0;
while (k < b.length - 1 && b[k] == '0') k++;
return new String(b, k, b.length - k);
}
public static void main(String[] args) {
String L = "10", R = "25";
int k = 3;
String lowered = L.equals("0") ? "-1" : decString(L);
System.out.println("multiples of 3 in [10,25]: " + (f(R, k) - f(lowered, k))); // 5
}
}
Count divisible-by-K in [L, R] — Python¶
from functools import lru_cache
def f(N: str, K: int) -> int:
if N == "-1":
return 0
digits = [int(c) for c in N]
D = len(digits)
@lru_cache(maxsize=None)
def solve(pos: int, tight: bool, rem: int) -> int:
if pos == D:
return 1 if rem == 0 else 0
limit = digits[pos] if tight else 9
total = 0
for dig in range(limit + 1):
total += solve(pos + 1, tight and dig == limit, (rem * 10 + dig) % K)
return total
res = solve(0, True, 0)
solve.cache_clear()
return res
def dec_string(s: str) -> str:
b = list(s)
i = len(b) - 1
while i >= 0 and b[i] == "0":
b[i] = "9"
i -= 1
b[i] = str(int(b[i]) - 1)
return "".join(b).lstrip("0") or "0"
def count_divisible_range(L: str, R: str, K: int) -> int:
lowered = "-1" if L == "0" else dec_string(L)
return f(R, K) - f(lowered, K)
if __name__ == "__main__":
print(count_divisible_range("10", "25", 3)) # 5: 12,15,18,21,24
No two equal adjacent digits (started + last) — Python¶
from functools import lru_cache
def count_no_adjacent_equal(N: str) -> int:
digits = [int(c) for c in N]
D = len(digits)
@lru_cache(maxsize=None)
def solve(pos: int, tight: bool, started: bool, last: int) -> int:
if pos == D:
return 1 # every fully built number that survived the constraint is valid
limit = digits[pos] if tight else 9
total = 0
for dig in range(limit + 1):
if started and dig == last:
continue # adjacent equal forbidden
nstarted = started or dig != 0
nlast = dig if nstarted else -1
total += solve(pos + 1, tight and dig == limit, nstarted, nlast)
return total
res = solve(0, True, False, -1)
solve.cache_clear()
return res
if __name__ == "__main__":
print(count_no_adjacent_equal("100")) # numbers in [0,100] with no two equal adjacent digits
Error Handling¶
| Scenario | What goes wrong | Correct approach |
|---|---|---|
| Tight and free share a cache slot | Key omits tight; counts silently wrong. | Either key on tight or skip caching when tight. |
L − 1 on a string mishandled | Borrow logic wrong → wrong lower bound. | Use a tested decString; handle all-zeros borrow. |
L = 0 calls DP with "-1" | DP indexes N[pos] on a bad string. | Treat f(-1) = 0 before recursing. |
| Leading zeros counted as real | Adjacency / digit-count / mask corrupted. | Gate the accumulator update on started. |
Stale cache across f(R) and f(L−1) | Python lru_cache shared between bounds. | cache_clear() per call, or build memo per N. |
| Accumulator out of declared range | rem or sum overflows table dimension. | Size table to K / 9D+1; prune impossible states. |
0 wrongly included/excluded | isValid ignores started. | Decide 0's validity explicitly and encode it. |
Performance Analysis¶
| Accumulator | M (states) | Cost O(D · M · base) for D=18 | Notes |
|---|---|---|---|
| Forbidden digits (none) | O(1) | ~18 × 2 × 10 ≈ 360 | started doubles it |
Remainder mod K (K=1000) | 1000 | ~18 × 1000 × 10 ≈ 180 000 | scales with K |
Digit sum (≤ 9D) | ~162 | ~18 × 162 × 10 ≈ 29 000 | tiny |
| Last digit (adjacency) | ~11 | ~18 × 11 × 2 × 10 ≈ 4 000 | started×last |
| Digit bitmask | 1024 | ~18 × 1024 × 2 × 10 ≈ 370 000 | heaviest common one |
The dominant cost is M × base per position, repeated over D positions. The single biggest performance lever is shrinking the accumulator range: prune impossible accumulators (sum > S), use mod K instead of the raw value, and drop the started dimension when the predicate does not need it.
import time
def benchmark(N_str, K):
start = time.perf_counter()
_ = f(N_str, K)
return time.perf_counter() - start
# Typical: N = "1" + "0"*18 (10^18), K = 1000 -> well under 0.1s in CPython.
Because the cost depends on the digit count and the accumulator range, not on N, a 60-digit N (string input) costs barely more than an 18-digit one.
Best Practices¶
- Decide the four state fields explicitly for each problem: which of
started/ accumulator does the predicate actually need? Drop the unused ones. - Skip caching tight states (
if not tight) — the smallest correct cache and the idiomatic form. - Gate accumulator updates on
startedwhenever leading zeros could be mistaken for real digits. - Implement
f(X)once, build[L, R]asf(R) − f(L−1), and reuse a testeddecStringfor the lower bound. - Always test against the brute-force oracle on every
Nand parameter up to a few thousand. - Prune impossible accumulators early to keep
M(and the table) small. - Treat
Nas a string everywhere; never let it pass through anint.
A Worked Leading-Zero Failure¶
Nothing cements the started flag like watching it break. Take "count integers in [0, 100] with no two equal adjacent digits" and a buggy version that omits started (treating leading zeros as real digits).
The bound is N = "100", so we build 3-wide candidates. Consider the number 5, padded as 005:
buggy run (no started): digits are 0, 0, 5
position 0: place 0
position 1: place 0 → adjacent to previous 0 → WRONGLY rejected ("00")
the number 5 is never counted!
The two padding zeros look like an adjacent-equal pair, so every single-digit number 1..9 (padded 00d) is wrongly rejected, and so is every two-digit number starting after a padded zero. The count comes out far too low.
correct run (with started):
position 0: place 0 → started stays false, last = "none" (-1)
position 1: place 0 → started still false (no real digit yet), last still -1
→ NOT compared, NOT rejected
position 2: place 5 → started becomes true, last = 5, number = 5 ✓ counted
Run both against the brute-force oracle:
def brute_no_adj(N):
def ok(x):
s = str(x)
return all(s[i] != s[i+1] for i in range(len(s)-1))
return sum(1 for x in range(N+1) if ok(x))
# brute_no_adj(100) = 91 (only 11, 22, ..., 99 are rejected: 9 numbers; 101 numbers in [0,100] minus... )
# the buggy DP returns far less; the correct DP returns 91.
The lesson, made concrete: for any predicate whose per-digit rule looks at previous real digits (adjacency, monotonicity) or counts/sets of real digits (digit-frequency, masks), the started flag is not optional — it is the only thing that prevents padding zeros from being mistaken for the number's actual digits. The digit-sum predicate is the lone common exception, because adding a padding zero changes nothing.
Debugging checklist when a Digit DP is wrong¶
When your count disagrees with the oracle, check in this order — these are ranked by how often they are the culprit:
- Are tight states being cached? Add the
if not tightguard around both the read and the write. (Over-counts.) - Is
nextTightexactlytight && dig == limit? A common typo isdig == 9or dropping the&& tight. (Corrupts the bound.) - Is
startedhandled? If the predicate looks at previous real digits, padding zeros must not participate. (Under- or over-counts depending on the rule.) - Is the range
f(R) − f(L−1), notf(R) − f(L)? And isf(−1) = 0? (Off-by-one at the endpoint.) - Is
L − 1computed correctly on the string? Borrow across trailing zeros, strip leading zeros. (Wrong lower bound.) - Is the memo cleared/rebuilt between the two
fcalls? (Stale counts.)
Fix the smallest failing N first — the bug is almost always visible at N ≤ 30.
Visual Animation¶
See
animation.htmlfor an interactive view.The middle-level animation highlights: - The bound
N's digits with the tight branch (capped atN[pos]) drawn distinctly from the free branch. - The accumulator (digit sum) and thestartedflag updating as each digit is chosen. - How counts from the leaves bubble up and how free subtrees reuse memoized results.
Summary¶
Middle-level Digit DP is about getting the edges right and the family general. The leading-zero (started) flag is mandatory for any constraint where padding zeros could masquerade as real digits — adjacency, monotonicity, digit-frequency, masks — and optional only for the digit sum. Ranges decompose as f(R) − f(L−1), and because L and R can be 100-digit strings, the only big-number arithmetic you need is a careful string decrement. The accumulator is just "whatever finite state the predicate needs": a remainder mod K for divisibility (rem' = (rem·base + dig) mod K), a bitmask for digit-set constraints, the last digit for adjacency/monotonicity, or a running count when you sum a quantity over the range. And the tight states stay out of the cache — they are unique per position, so caching them gains nothing and, if keyed carelessly, silently corrupts the free-state counts. Master the four-field skeleton with these accumulators and the whole digit-constraint counting family collapses into one well-tested function.