Digit DP
Count numbers in [0, N] with a digit property by scanning N's digits with a "tight" flag and a small property state.
Overview
Digit DP answers "how many integers in [L, R] satisfy a property of their decimal (or binary) digits?" for R up to 10^18 — far too many to enumerate. The trick is to build numbers digit by digit from the most significant position, tracking (1) pos — how many digits are placed, (2) tight — whether the prefix so far equals N's prefix (so the next digit is capped at N[pos]), (3) started — whether a non-zero digit has appeared (to handle leading zeros), and (4) a small property state such as digit sum mod k, last digit, a mask of used digits, or "contains 13 so far".
Answer [L, R] as count(R) - count(L - 1). States are ≈ 19 × 2 × 2 × |property|; transitions try 10 digits; total work is tiny. The technique is almost always written top-down with Memoization (Top-Down DP) because the tight branch makes bottom-up iteration awkward — and note that memo entries with tight = true are visited at most once per position, so caching only the tight = false states is enough.
Members: count numbers with no repeated digits, numbers whose digit sum is divisible by k, numbers without a given substring (no "13"), numbers with at most k odd digits, sum of digits of all numbers ≤ N, numbers with monotone digits, "count of 1s in all numbers ≤ N".
Intuition
A mental model before the formal terms.
Imagine typing a number on a keypad while looking at N. As long as every digit you typed matches N so far you are "tight": the next key can be at most N's next digit. The moment you press a smaller key, you are free — anything goes for the rest, and the count of completions depends only on how many positions remain and the property state, not on the exact prefix. That "free" count is what the memo stores and reuses across thousands of prefixes.
Leading zeros are the other subtlety: 007 is the number 7, so the "no repeated digits" property must ignore zeros before the first real digit. The started flag says whether we are still in the leading-zero zone.
How it works
- State:
f(pos, tight, started, prop)= number of valid completions from positionposgiven the flags and the property accumulatorprop. - Transition:
hi = N[pos] if tight else 9; fordin0..hi: new tight =tight and d == hi; new started =started or d != 0; new prop =update(prop, d)(skipping the update while not started if leading zeros must not count); sum the results, pruning early whenpropalready violates the property. - Base case:
pos == len(N): return 1 ifpropsatisfies the property (and, if required,started), else 0. - Order: implicit via recursion + memo on
(pos, tight, started, prop). Answer:f(0, true, false, initial); for a range subtractcount(L-1). - Variants: to compute a sum rather than a count, return a pair
(count, sum)and combine assum += d · 10^(remaining) · count_child + sum_child.
Why it works
Every integer in [0, N] corresponds to exactly one root-to-leaf path in the digit tree that never exceeds N's prefix, so summing over the constrained digit choices counts exactly those integers.
Once tight is false the future is independent of the prefix except through prop, so states with equal (pos, started, prop) have equal counts — the memo is sound.
Recognition
How to tell a problem wants this.
- Count (or sum over) numbers in a range up to 10^9–10^18 satisfying a condition stated in terms of digits.
- "How many numbers ≤ N have…", "in the range [L, R], count numbers whose digits…".
- A property that updates digit by digit with small memory (sum mod k, last digit, set of used digits ≤ 2^10, a small automaton state).
Interactive visualization
Play, step, change the input. ← → and space work too.
No interactive visualization for this topic yet
Related visualizations are linked under Related.
Pseudocode
1# count numbers in [0, N] with no two adjacent equal digits2digits = decimal digits of N3f(pos, tight, started, last):4 if pos == len(digits): return 15 if not tight and memo has (pos, started, last): return it6 hi = digits[pos] if tight else 97 total = 08 for d in 0..hi:9 if started and d == last: continue10 nstarted = started or d != 011 nlast = d if nstarted else -112 total += f(pos+1, tight and d == hi, nstarted, nlast)13 if not tight: memo[(pos, started, last)] = total14 return total15return f(0, true, false, -1)Implementations
1from functools import lru_cache2 3# Digit DP: count numbers in a range satisfying a property, by building them4# one decimal digit at a time. The state carries "am I still hugging the upper5# bound" (tight) and "have I placed a non-zero digit yet" (started), plus6# whatever the property needs. Representative example: count numbers in7# [0, n] whose digits sum to a given value, and count those with no repeats.8 9 101 · lru_cache does the memoisation; tight is part of the key, which is safe11def count_digit_sum(n: int, target: int) -> int:12 digits = str(n)13 142 · tight means every digit so far equalled the bound, so the next is capped15 @lru_cache(maxsize=None)16 def go(pos: int, total: int, tight: bool, started: bool) -> int:17 if total > target:18 return 019 if pos == len(digits):20 return 1 if started and total == target else 0213 · Including tight in the key is correct but caches fewer states;22 # only O(len) states are ever tight, so the waste is negligible23 limit = int(digits[pos]) if tight else 924 return sum(25 go(pos + 1, total + d, tight and d == limit, started or d > 0)26 for d in range(limit + 1)27 )28 29 result = go(0, 0, True, False)30 go.cache_clear() # the cache is per-bound, so do not leak it between calls31 return result32 33 344 · Range queries are two prefix counts: f(hi) - f(lo - 1)35def count_in_range(lo: int, hi: int, target: int) -> int:36 a = count_digit_sum(hi, target)37 return a if lo == 0 else a - count_digit_sum(lo - 1, target)38 39 405 · A different property, same skeleton: digits must all be distinct41def count_distinct_digits(n: int) -> int:42 s = str(n)43 44 @lru_cache(maxsize=None)45 def go(pos: int, mask: int, tight: bool, started: bool) -> int:46 if pos == len(s):47 return 1 if started else 048 limit = int(s[pos]) if tight else 949 total = 050 for d in range(limit + 1):51 if started and mask & (1 << d):52 continue # digit already used53 ns = started or d > 054 nmask = (mask | (1 << d)) if ns else 055 total += go(pos + 1, nmask, tight and d == limit, ns)56 return total57 58 result = go(0, 0, True, False)59 go.cache_clear()60 return result@lru_cacheremoves the manual memo table entirely — the four state components become the cache key automatically.- Including
tightin the key is *correct* (unlike hand-rolled memoisation, where mixing tight and non-tight states in one table is the classic bug), because the key distinguishes them. Only O(len(digits)) tight states ever exist, so the extra entries cost nothing. go.cache_clear()after each call is essential: the closure capturesdigits, so a cache retained across two different bounds would return answers for the wrong number.int(digits[pos])converts the character; Python has no- '0'idiom because characters are strings, not integers.- The digit-sum version uses a generator inside
sum(), which reads as the mathematical definition; the mask version needs an explicit loop for thecontinue.
Python integers are unbounded, so this works for arbitrarily large n — the only language here with no range ceiling.
functools.lru_cacheon a closure is the idiomatic Python memoisation and makes digit DP dramatically shorter than the manual-table versions.cache_clear()is mandatory when the cached function closes over per-call data — forgetting it is a subtle cross-call contamination bug.functools.cache(3.9+) islru_cache(maxsize=None)with a shorter name.- Arbitrary-precision integers mean
count_digit_sum(10**100, 5)works, which no other language here can do without a bignum library.
- Omitting
cache_clear(), so a second call with a different bound reads the first call's cached answers. - Decorating a module-level
gothat takesdigitsas a parameter — correct, but the string then becomes part of every cache key and the memo hit rate collapses. - Forgetting the
startedflag and mis-counting leading zeros.
- Memoisation: Python
@lru_cachehandles the state key automatically (and makes includingtightsafe), while C++ and JS/TS hand-roll a table and must *exclude* tight states to stay correct — the same algorithm with opposite advice. - Range: Python integers are unbounded, C++ handles 10^18 with
long long, and JS/TS cap at 2^53 unless converted toBigInt. - Character-to-digit conversion: C++
c - '0', JS/TScharCodeAt(pos) - 48, Pythonint(digits[pos])— only Python has no code-point arithmetic, because its characters are strings. - Cache lifetime is a Python-specific hazard:
lru_cacheon a closure persists across calls, socache_clear()is required where the other languages simply allocate a fresh table.
Complexity
Effectively constant for a single query; a range query is two calls.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Counting or summing over huge integer ranges by a digit-defined property.
- The property can be tracked with a small state as digits are appended.
- Multiple range queries — memo the
tight = falsestates once per N, or precompute free counts by remaining length.
- The range is small (≤ 10^7) — brute force is simpler and less error-prone.
- The property depends on the number's value in a non-digit way (primality, divisibility by a large modulus) — the state explodes; use math instead.
- A closed-form combinatorial count exists (numbers with digit sum
swithout an upper bound: stars and bars).
Alternatives
Common mistakes
- Forgetting
started, so leading zeros are treated as real zeros (breaks "no repeated digits", "last digit" properties). - Caching states that include
tight = trueunder a key that ignorestight— wrong answers; either includetightin the key or skip caching it. - Computing
count(L-1)whenL = 0(underflow) — special-case it. - Off-by-one on whether
0itself counts as a valid number for the problem. - Overflow in 32-bit languages when counts approach 10^18 — use 64-bit.
Interview patterns
- Count numbers with unique digits / numbers at most N given a digit set.
- Number of 1 bits or digit 1 occurrences in all numbers ≤ N.
- Count numbers whose digit sum is divisible by k; count "stepping numbers" in a range.
- Numbers with repeated digits (= N − count of unique-digit numbers).
- Coin ChangeIntermediate