Digit DP
“How many integers between 1 and have no two adjacent equal digits?” There are candidates and one second of budget, so iteration is dead on arrival. But the answer does not depend on the numbers — it depends on their digits, and there are only 18 of those. Digit DP is the technique that turns a range of size into a walk over 18 positions with a handful of states at each one.
The whole pattern rests on a single observation, and everything else is bookkeeping: as you build a number digit by digit from the most significant end, there is only ever one prefix still equal to the bound’s prefix. Every other prefix has already dropped below the bound and is therefore free to use any digit at all. One tight branch, one free bucket. That is the state.
What you’ll learn
Section titled “What you’ll learn”- The tight/free split and why it collapses an exponential search into columns.
- The memoized-recursion template you can write from memory in an interview, and where each problem’s own logic plugs into it.
- Why
started(the leading-zero flag) is a separate piece of state fromtight, and which problems need it. - How to answer a range query when the technique only counts .
- The two bugs that produce almost-right answers: memoizing across
tight, and dropping the final+ tight.
The cue
Section titled “The cue”Visual intuition
Section titled “Visual intuition”Two rows: the single tight prefix on top, the accumulated free prefixes below. Watch how nothing ever flows back up — once free, always free.
The tight row never holds more than 1, because exactly one prefix can equal the bound's prefix. That single fact is why an interval of size 10^18 costs 18 columns. The number under each node is how many prefixes of that length are in that state.
And here is the case that makes the tight branch’s fragility visible — a bound
whose own digit is the forbidden one:
At position 0 matching the bound digit would place a forbidden 4, so tight drops to 0 and stays there. From that column on, every count comes from the free row -- and the final '+ tight' correctly adds nothing, because 440 is not one of the numbers being counted.
The template
Section titled “The template”Two forms. The iterative one matches the pictures above and is the one to reason with; the memoized recursive one is what you actually write in an interview, because extra state is a parameter rather than another array.
from functools import lru_cache
def count_no_digit_iterative(bound: str, bad: str) -> int:
"""Numbers in [0, bound] that never use the digit `bad`. Leading zeros allowed."""
tight, free = 1, 0 # one tight prefix: the empty one
for ch in bound:
d = int(ch)
below = sum(1 for x in range(d) if str(x) != bad)
free = free * 9 + tight * below # free stays free; tight can break free
tight = tight if ch != bad else 0 # matching the bound may be illegal
return tight + free # the surviving tight prefix IS `bound`
def count_no_digit(bound: str, bad: str) -> int:
"""The same thing as memoized recursion -- the shape that generalises."""
n = len(bound)
@lru_cache(maxsize=None)
def go(pos: int, tight: bool) -> int:
if pos == n:
return 1 # a complete, legal digit string
total = 0
hi = int(bound[pos]) if tight else 9 # `tight` caps this position
for d in range(hi + 1):
if str(d) == bad: # <-- the ONLY problem-specific line
continue
total += go(pos + 1, tight and d == hi)
return total
result = go(0, True)
go.cache_clear() # LeetCode reuses the instance
return result
print(count_no_digit_iterative("325", "4"), count_no_digit("325", "4")) # 266 266
print(count_no_digit_iterative("440", "4"), count_no_digit("440", "4")) # 324 324
print(count_no_digit_iterative("1000", "4")) # 730The recursive form is the template worth memorising, because every problem in the family is this skeleton with one or two extra parameters:
@lru_cache(maxsize=None)
def go(pos, tight, started, extra):
# pos how many digits placed
# tight still pinned to the bound's prefix
# started a non-zero digit has been placed (drop this if leading zeros are fine)
# extra the problem: a digit mask, a remainder, the previous digit, a count
if pos == n:
return 1 if started else 0 # or: return extra, for a sum/tally
total = 0
hi = int(bound[pos]) if tight else 9
for d in range(hi + 1):
if not started and d == 0: # still in the leading-zero run
total += go(pos + 1, tight and d == hi, False, extra)
continue
if forbidden(d, extra): # the problem's own rule
continue
total += go(pos + 1, tight and d == hi, True, update(extra, d))
return totalRange queries need no new machinery. The counter answers , so:
def count_in_range(lo: int, hi: int) -> int:
return f(hi) - f(lo - 1) # and f(-1) == 0, so guard lo == 0Dry run
Section titled “Dry run”bound = "325", forbidden digit 4. The columns are the two pictures above,
written out. Read free as “how many 3-digit strings so far are already strictly
below 325’s prefix and legal”.
| position | bound digit | allowed digits below it | free | tight |
|---|---|---|---|---|
| — (start) | — | — | 0 | 1 |
| 0 | 3 | 0,1,2 → 3 | 1 | |
| 1 | 2 | 0,1 → 2 | 1 | |
| 2 | 5 | 0,1,2,3 → 4, not 5 | 1 |
Answer .
Three things this trace shows that the code hides:
- The
× 9, not× 10. A free prefix has all ten digits available minus the forbidden one. Writingfree * 10is the most common typo and it inflates the answer smoothly — no crash, no obvious pattern, just wrong. allowed digits belowis not the bound digit. At position 2 the bound digit is5, but only 4 digits below it are legal, because4is excluded. The count of choices and the digit’s value are different quantities that happen to coincide when nothing is forbidden.tightstays exactly 1 the whole way, then becomes the answer’s+1. It is counting one thing: the string325itself. Check it by hand — there are 326 integers in and 60 of them contain a 4, so . Drop the+ tightand you get 265, which is off by one only when N itself is legal — the reason this bug survives casual testing. Withbound = "440"the tight branch dies at position 0 and the answer, 324, has no+1in it at all.
Complexity
Section titled “Complexity”Let len(str(N)) — that is 18 or fewer for any 64-bit bound, which is
the point of the whole technique.
| Quantity | Cost |
|---|---|
| States | , where is the extra state’s size |
| Transitions per state | (or in base ) |
| Time | |
| Space | for the memo, recursion depth |
| Iterative form | time, space |
Concretely: no forbidden-digit state at all is steps. A digit mask () is . A remainder mod is . Everything in this family is microseconds; the only way to make digit DP slow is to put something huge in the extra state, such as the numeric prefix itself.
The variant map
Section titled “The variant map”| Problem | Extra state | The one thing that changes |
|---|---|---|
| No forbidden digit | none | skip d == bad in the loop |
| LC 233 Number of Digit One | running count of 1s | return the count at pos == n instead of 1, so the DP sums a tally rather than counting strings |
| LC 357 Count Numbers with Unique Digits | 10-bit mask of used digits | skip d if mask >> d & 1; needs started so leading zeros do not consume bit 0 |
| LC 2376 Count Special Integers | 10-bit mask | LC 357 with an arbitrary bound instead of |
| LC 1012 Numbers with Repeated Digits | 10-bit mask | count the numbers with no repeat, then subtract from N — complement counting is far easier than tracking “has repeated” |
| LC 600 Non-negative Integers without Consecutive Ones | previous bit | base 2 instead of base 10: hi = bit if tight else 1 |
| LC 788 Rotated Digits | two flags: “all digits rotatable” and “at least one digit changes” | a good number needs both conditions, so two booleans, not one |
| LC 902 Numbers At Most N Given Digit Set | none | the digit loop iterates the given set, and started handles shorter numbers |
Digit sum divisible by k | remainder mod k | update = (rem + d) % k, accept when rem == 0 |
| Range | — | f(R) - f(L - 1) |
| Sum, not count | the partial value | return two values per state — the count and the sum — or the sum with the count as a multiplier |
Pitfalls
Section titled “Pitfalls”- Dropping the final
+ tight(recursively: returning 0 instead of 1 atpos == non the tight path). Off by exactly one, and only whenNitself satisfies the property, so half your test cases pass. - Memoizing across
tight. Iftightis not part of the cache key, a state computed with the bound capping the position gets reused where the position is unconstrained, and the answer comes out too small. It is a silent undercount, never a crash. Either includetightin the key, or cache only when it is false. - Leaving the cache alive between calls. On LeetCode the same
Solutioninstance handles many test cases; anlru_cacheon a closure overboundis fine, but aself.memodict keyed only on(pos, tight)returns the previous bound’s answers. Build the cache inside the call, or clear it. - Confusing
tightwithstarted. They are independent:007is not tight after two zeros if the bound was325, but it is also not started. Merging them breaks problems with a digit mask, because the leading zeros consume bit 0 and every answer drops. free * 10instead offree * (number of legal digits)in the iterative form. The tight row is easy to get right and the free row is where the arithmetic hides.- Building the bound with
intarithmetic.N // 10 ** i % 10works but reverses your mental left-to-right order and produces index bugs. Convert once withstr(N)and index forward. - Counting 0 when the problem says “positive integers”. With leading zeros
allowed, the all-zeros string is counted as the number 0. If the problem starts
at 1, subtract one or use
started. - Reaching for digit DP when the range is small. For a
forloop with astr()check is three lines and cannot be wrong. Say that out loud in an interview before writing the DP.
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
| “Why is this not exponential?” | Whether you understand tight | Because at every depth exactly one prefix is still pinned to the bound; all the others are interchangeable and collapse into one counted bucket. The branching factor lives in the count, not in the recursion |
| “Now answer for the range ” | Whether you know the standard reduction | , where counts . Guard , and note the bound is inclusive precisely because of the + tight term |
| “What if has 18 digits?” | Whether you spot that nothing changes | Nothing changes — the cost is in len(str(N)), so 18 digits is 18 columns. Take as a string so you never rely on it fitting in an int |
| “Give me the sum of the qualifying numbers, not the count” | Whether the state can carry more than a flag | Return a pair per state: the count and the sum. When you prepend digit d at position pos, the sum becomes d * 10^(n-pos-1) * count + sum — the count is needed to place the new digit’s contribution |
| “Do it in base 2” (LC 600) | Generality | Replace 10 with 2 and str(N) with the binary string. The template is base-agnostic; only hi and the loop range change |
| “Where would you memoize, and where not?” | Depth of understanding | Cache on (pos, started, extra) and only when not tight — the tight path is a single chain of length , so caching it buys nothing and forgetting to key on it costs correctness |
| “How do you find the -th such number?” | Whether you can compose techniques | Binary search on the value, using the counter as the predicate: counter calls. Or build the answer digit by digit, subtracting counts as you fix each digit |
| “Test it” | Engineering judgement | Brute-force the same property for every up to a few thousand and compare. Digit DP is exactly the kind of code whose bugs are small silent offsets, and a 5-line oracle finds all of them |
Practice
Section titled “Practice”Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.
- 357Count Numbers with Unique Digitsmedium
- 788Rotated Digitsmedium
- 233Number of Digit Onehard
- 600Non-negative Integers without Consecutive Oneshard
- 902Numbers At Most N Given Digit Sethard
- 1012Numbers With Repeated Digitshard
- 2376Count Special Integershard
Exercises
Section titled “Exercises”LC 2376 — Count Special Integers · Hard
Section titled “LC 2376 — Count Special Integers · Hard”LC 233 — Number of Digit One · Hard
Section titled “LC 233 — Number of Digit One · Hard”LC 902 — Numbers At Most N Given Digit Set · Hard
Section titled “LC 902 — Numbers At Most N Given Digit Set · Hard”Self-check
Section titled “Self-check”-
Why does the tight row of the state machine never hold a count greater than 1?
This is the whole technique in one sentence. The branching factor moves into the *count* stored in the free bucket, and the recursion itself stays linear in the number of digits.
pch.quizShowAnswer
B — Because exactly one prefix of each length can equal the bound's prefix — every other prefix is already strictly below it, and therefore free — This is the whole technique in one sentence. The branching factor moves into the *count* stored in the free bucket, and the recursion itself stays linear in the number of digits.
-
You omit `tight` from the memo key. What happens?
A tight state has fewer choices available, so its value is smaller. Reusing it for free states undercounts, with no crash and no obvious pattern. Either key on `tight` or cache only when it is false.
pch.quizShowAnswer
B — The answer comes out too small, silently — a state computed with the bound capping that position gets reused where the position is unconstrained — A tight state has fewer choices available, so its value is smaller. Reusing it for free states undercounts, with no crash and no obvious pattern. Either key on `tight` or cache only when it is false.
-
In the iterative form, a free prefix multiplies by 9 when one digit is forbidden. Why not 10?
The multiplier is the number of *legal* digits, which is a different quantity from the base. Writing 10 inflates the answer smoothly, which makes it a hard typo to notice.
pch.quizShowAnswer
B — Because a free prefix may extend by any of the ten digits except the forbidden one — The multiplier is the number of *legal* digits, which is a different quantity from the base. Writing 10 inflates the answer smoothly, which makes it a hard typo to notice.
-
Why does the count of [0, 325] end with `+ tight` rather than just the free total?
265 free + 1 tight = 266, and 326 − 60 numbers containing a 4 confirms it. Dropping the term is wrong only when N itself qualifies — so the bug passes many test cases. With N = 440 and 4 forbidden, tight is already 0 and the term adds nothing.
pch.quizShowAnswer
B — Because the surviving tight prefix is the bound itself, and the range is inclusive of N — 265 free + 1 tight = 266, and 326 − 60 numbers containing a 4 confirms it. Dropping the term is wrong only when N itself qualifies — so the bug passes many test cases. With N = 440 and 4 forbidden, tight is already 0 and the term adds nothing.
-
`tight` and `started` — why are they separate pieces of state?
If padding zeros are treated as placed digits, they consume bit 0 of a used-digit mask and every number shorter than the bound is rejected. LC 2376 is the standard place this bug shows up.
pch.quizShowAnswer
B — They are independent: a prefix of leading zeros is not started, and against a bound like 325 it is also not tight — merging them breaks any problem whose state is a digit mask — If padding zeros are treated as placed digits, they consume bit 0 of a used-digit mask and every number shorter than the bound is rejected. LC 2376 is the standard place this bug shows up.
-
The interviewer changes the bound to N ≤ 10^6. What is the right first move?
10^6 iterations is well inside a second. Naming the simpler correct solution before reaching for the clever one is exactly the judgement the question is testing — and it costs nothing, because you then write the DP anyway.
pch.quizShowAnswer
B — Say that a plain loop over the range with a string check is three lines and cannot be wrong, then offer the digit DP as the version that scales — 10^6 iterations is well inside a second. Naming the simpler correct solution before reaching for the clever one is exactly the judgement the question is testing — and it costs nothing, because you then write the DP anyway.
Recall card
Section titled “Recall card”- Cue — count the numbers in a huge range with a digit property; the bound arrives as a string or as .
- State —
(pos, tight, started, extra).tight= still equal to the bound’s prefix,started= a non-zero digit has been placed,extra= the problem (mask, remainder, previous digit, tally). - Transition —
hi = int(bound[pos]) if tight else 9; loopdin0..hi; the next state is tight only iftight and d == hi. - Base case —
pos == nreturns 1 (orextrafor a tally), gated onstarted. - Cost — with : always fast unless the extra state is huge.
- Range —
f(R) − f(L−1). Inclusive because the tight path counts itself. - Two bugs — dropping the tight contribution (off by one, only when N
qualifies) and memoizing across
tight(silent undercount).
- Digit DP counts over a range of size in the length of the bound, because as you build a number left to right only one prefix is still pinned to the bound and all the rest are interchangeable.
- The state is
(pos, tight)plus whatever the specific problem needs. Memorise the skeleton once and the whole family — forbidden digits, unique digits, digit sums, adjacent digits, restricted digit sets — is a two-line edit each. startedis nottight. Conflating them breaks every mask problem, quietly.- Ranges are
f(R) − f(L−1); sums need the count carried alongside the sum; other bases need only a differenthi. - Verify with a brute-force oracle over small bounds. The failures in this pattern are small silent offsets, not exceptions.
Next: Bitmask and Tree DP — the same “state is not an index” idea, with a subset in the state rather than a digit position.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading