Digit DP
Count or sum over all integers up to N by placing digits from most to least significant, memoising on position plus a small digit-derived state once the prefix is already below N.
Instinct Count numbers in [L, R] (R up to 10^9 or 10^18) whose digits obey a rule - build the number digit by digit with a "still tight to N" flag.
Memory hack Walk the digits on a leash - stay tight to N or break free, and once free you never go back. Remember PTLS - Position, Tight, Leading zero, State.
At a glance
When to use it
- You must count integers in a range whose bound reaches 10^9 or 10^18
- The rule is about the digits, such as distinct digits, a digit sum or an allowed digit set
- You need a total of some digit statistic over every number up to N
- Looping over every number is impossible but N has only a few digits
Key idea
Build numbers digit by digit from the left with a tight flag, a started flag and a small digit summary, memoising only the free states; this turns an O(N) scan into about O(digits x states x 10) work, and ranges come from f(R) - f(L - 1).
Typical complexity
O(digits x states x 10) time, O(digits x states) space
| # | Common problems | Level | Our solution | Practice |
|---|---|---|---|---|
| 1 | Count Numbers with Unique Digits | Medium | Read solution | LeetCode ↗ |
| 2 | Numbers At Most N Given Digit Set | Hard | Read solution | LeetCode ↗ |
| 3 | Number of Digit One | Hard | Read solution | LeetCode ↗ |
The instinct
You cannot loop to 10^18. But numbers are just strings of at most 19 digits, and a property like "no repeated digit" or "digit sum divisible by k" only needs a summary of the digits already placed. So fill the digits left to right. While your prefix equals N's prefix you are tight: the next digit cannot exceed N's digit. The moment you place a smaller digit you are free: every remaining position can be 0-9, and all those free branches with the same summary have identical answers - memoise them.
Ranges are handled by subtraction: count(L..R) = f(R) - f(L - 1).
Recognise it
| Signal in the problem | Why it points here |
|---|---|
"How many integers in [1, n] / [low, high] ..." with n up to 10^9 or 10^18 |
Too big to iterate; digits are few |
| "... whose digits are all distinct" (Count Numbers with Unique Digits) | State = mask of digits already used |
| "Total number of digit 1 appearing in all numbers ≤ n" (Number of Digit One) | Aggregate a digit statistic over every number |
| "Numbers you can write using only these digits that are ≤ n" (Numbers At Most N Given Digit Set) | Restricted digit alphabet + tight bound |
| "Digit sum equals / is divisible by k", "number divisible by its digit sum" | State = running sum or remainder |
| "No two adjacent digits equal", "no consecutive 1s in binary" | State = previous digit (works in any base) |
| Input given as a string with up to 100 digits | Digit DP over the string, answer mod 1e9+7 |
Not this pattern if...
- You need to construct the next/previous number with a property (often greedy, e.g. Next Greater Element III).
- The property depends on the value as a whole in a non-local way you cannot summarise (e.g. primality of the number).
nis small enough (≤ 10^6) to just loop and check - do that first and mention digit DP as the scalable version.
Memory hack
PTLS: Position, Tight, Leading zero, State. Those are the four arguments of almost every digit-DP function. Tight is the leash: you stay on it while you copy N's digits and you drop it forever the first time you go lower.
Analogy: filling in a combination lock that must not exceed a target code. While every wheel so far equals the target, the next wheel is capped. Once one wheel is lower, the rest of the wheels spin freely - and every free spin of "3 wheels left, same history" behaves the same, so you count it once.
DP framing:
- State -
(pos, summary, tight, started);summaryis the problem-specific memory (used-digit mask, digit sum mod k, previous digit, count of ones). - Choice - the digit placed at
pos:0 .. (tight ? N[pos] : 9), possibly restricted to an allowed set. - Base case - at
pos == len, return 1 if the built number is valid (or the collected statistic for sum-type problems). - Order - most significant digit first; memoise only the free (non-tight) states.
Say it out loud in the interview:
- "I count numbers ≤ N, then answer ranges with f(R) - f(L-1)."
- "Walking digits from the left, I track tight, leading-zero, and this summary: ..."
- "Only non-tight states are memoised, so the table is positions × summaries."
How it works
For N = 325 the recursion tree branches like this:
flowchart TD
S["pos 0, tight, limit 3"] --> A["digit 0..2: free"]
S --> B["digit 3: still tight"]
A --> A2["remaining 2 positions: 0..9 each, memoised"]
B --> C["pos 1, tight, limit 2"]
C --> C1["digit 0..1: free"]
C --> C2["digit 2: still tight"]
C2 --> D["pos 2, tight, limit 5: digits 0..5"]
Only one path ever stays tight (it spells N itself), so almost all work happens in free states, and there are just positions × summaries of those. Leading zeros matter whenever zero is "special": the number 7 written as 007 must not count as using digit 0 three times, so a started flag says whether a non-zero digit has been placed yet.
Java template
class DigitDp { private char[] d; private Long[][] memo; // memo[pos][state] for free, started states private static final int STATES = 1 << 10; // problem-specific: here a used-digit mask long countUpTo(long n) { if (n < 0) return 0; d = Long.toString(n).toCharArray(); memo = new Long[d.length][STATES]; // reset: pos meaning depends on N's length return dfs(0, 0, true, false); } long countRange(long lo, long hi) { return countUpTo(hi) - countUpTo(lo - 1); } private long dfs(int pos, int state, boolean tight, boolean started) { if (pos == d.length) return started && accept(state) ? 1 : 0; // the number 0 is excluded if (!tight && started && memo[pos][state] != null) return memo[pos][state]; int limit = tight ? d[pos] - '0' : 9; long res = 0; for (int dig = 0; dig <= limit; dig++) { boolean nowStarted = started || dig != 0; if (nowStarted && !allowed(state, dig)) continue; int next = nowStarted ? step(state, dig) : state; // leading zeros change nothing res += dfs(pos + 1, next, tight && dig == limit, nowStarted); } if (!tight && started) memo[pos][state] = res; return res; } // Problem-specific hooks (shown: all digits distinct). private boolean allowed(int mask, int dig) { return (mask & (1 << dig)) == 0; } private int step(int mask, int dig) { return mask | (1 << dig); } private boolean accept(int mask) { return true; } } class DigitStatistic { private char[] d; private Integer[][] memo; // Sum-type digit DP: total count of digit 1 over all numbers in [0, n]. int countDigitOne(int n) { d = Integer.toString(n).toCharArray(); memo = new Integer[d.length][d.length + 1]; return dfs(0, 0, true); } private int dfs(int pos, int ones, boolean tight) { if (pos == d.length) return ones; // each finished number contributes its ones if (!tight && memo[pos][ones] != null) return memo[pos][ones]; int limit = tight ? d[pos] - '0' : 9, res = 0; for (int dig = 0; dig <= limit; dig++) { res += dfs(pos + 1, ones + (dig == 1 ? 1 : 0), tight && dig == limit); } if (!tight) memo[pos][ones] = res; return res; } }
Complexity
- Time: O(len × S × B) - positions × summary states × digit choices (B = 10 for decimal, 2 for binary). The tight chain adds only O(len × B).
- Space: O(len × S) for the memo plus O(len) recursion depth.
- With a used-digit mask (S = 1024) and 10 positions that is about 10^5 operations - instant.
Variations
- Filter count - count numbers whose digits pass a test. Count Numbers with Unique Digits, Count Special Integers.
- Aggregate statistic - return a sum instead of 0/1. Number of Digit One, total digit sum over a range.
- Restricted digit set - iterate only over allowed digits; numbers shorter than N are counted with
|set|^kand only full-length numbers need the tight walk. Numbers At Most N Given Digit Set. - Remainder / sum state - digit sum mod k, or value mod k. Count of Integers (digit sum within bounds).
- Binary digits - same idea in base 2 with "previous bit" state. Non-negative Integers without Consecutive Ones.
- Two bounds at once - carry
tightLowandtightHighflags instead of subtracting, handy when L - 1 is awkward (string inputs).
Common mistakes
- Treating leading zeros as real digits, so 7 looks like "007" and fails a "distinct digits" check.
- Forgetting the edges of
f(R) - f(L - 1): L = 0, and whether 0 itself should be counted. - Reusing a memo indexed by
pos(from the left) across two different N lengths - reset it, or index by digits remaining. - Overflowing
intwhen counts approach 10^18; uselongor the required modulus. - Writing
limit = 9when the problem restricts digits, or forgetting the restricted set when tight.
Never memoise a state while tight is true (or merge tight and free states under one key). A tight state's answer depends on the remaining digits of N, so caching it poisons every free branch that later reads the same (pos, state) slot.
All practice problems
3 problems on this site use this pattern, easiest first. Solve them in order and say the instinct out loud before you code.