Digit DP

Dynamic Programming & RecursionPriority 2 of 5 — Niche — read once, revisit only if a company is known to askNiche Updated Sep 18, 2026

Scope — Counting how many numbers in a range satisfy a digit-level property: the tight/started/position state, the universal top-down template, and the count(R) - count(L-1) range trick. See also: dp.md — where digit DP sits among the DP patterns; math.md — closed-form counting when no DP is needed.

LeetCode Problem Lists

Overview

Key Properties

  • Complexity: O(D * S * 10) time, O(D * S) space — D = number of digits (~18 for a 64-bit bound), S = the number of problem-specific states.
  • Core Idea: never iterate the range. Build the number digit by digit, left to right, and memoise on (position, tight, started, extra).
  • When to Use: “how many integers in [L, R] have some property of their digits”, where R is far too large to loop over.

The three universal state variables

State Meaning Why it exists
pos which digit position is being filled the recursion index
tight is the prefix still equal to the bound’s prefix? if yes this digit is capped at bound[pos]; if no, 0..9 is free
started has a non-zero digit been placed yet? separates a genuine leading digit from padding zeros

Range trick: answer(L, R) = count(R) - count(L - 1).

References

  • dp.md — where digit DP sits among the DP patterns
  • math.md — closed-form counting when no DP is needed

Templates & Algorithms

Core Concept

Digit DP is a technique for counting numbers in range [L, R] that satisfy certain digit-based constraints. It builds numbers digit-by-digit using DP with states tracking:

  • Current position in number
  • Constraints (tight bound, leading zeros, etc.)
  • Problem-specific state (sum of digits, previous digit, etc.)

Key Insight:

text
Count(L, R) = Count(0, R) - Count(0, L-1)

Build numbers digit-by-digit left to right:
For each position, try all valid digits (0-9 or constrained by upper bound)
Use memoization to avoid recalculating same states

Common Use Cases:

  • Count numbers with digit sum = K
  • Count numbers with no repeated consecutive digits
  • Count numbers with all distinct digits
  • Count numbers with specific digit patterns

Time Complexity: O(digits × states × 10) typically O(18 × states × 10) for 64-bit integers Space Complexity: O(digits × states) for memoization


Basic Digit DP Template

Standard State Variables:

  1. pos: Current digit position (0 = leftmost)
  2. tight: Whether current number is still bounded by upper limit
  3. started: Whether non-zero digits have appeared (for handling leading zeros)
  4. Problem-specific state: Sum, count, previous digit, etc.
python
# python
# IDEA: the universal (pos, tight, started, state) digit DP skeleton
# time = O(D * S * 10), space = O(D * S)
def count_numbers(n):
    """
    Count numbers from 0 to n satisfying certain constraints.

    Time: O(digits × states × 10)
    Space: O(digits × states)
    """
    if n < 0:
        return 0

    digits = [int(d) for d in str(n)]
    memo = {}

    def dp(pos, tight, started, state):
        """
        pos: current position (0-indexed from left)
        tight: if True, current digit is bounded by digits[pos]
        started: if True, we've placed a non-zero digit
        state: problem-specific state (sum, count, etc.)
        """
        # Base case: processed all digits
        if pos == len(digits):
            return 1 if check_valid(state) else 0

        # Check memo
        if (pos, tight, started, state) in memo:
            return memo[(pos, tight, started, state)]

        # Determine max digit we can place
        limit = digits[pos] if tight else 9

        result = 0
        for digit in range(0, limit + 1):
            # Handle leading zeros
            new_started = started or (digit > 0)

            # Update problem-specific state
            new_state = update_state(state, digit, new_started)

            # Recursively count
            result += dp(
                pos + 1,
                tight and (digit == limit),
                new_started,
                new_state
            )

        memo[(pos, tight, started, state)] = result
        return result

    return dp(0, True, False, initial_state)

def count_range(L, R):
    """Count numbers in range [L, R]."""
    return count_numbers(R) - count_numbers(L - 1)

Guard the lower end. count_range(0, R) calls count_numbers(-1), and str(-1) makes the digit parse blow up on '-'. Every count_numbers in this file therefore opens with if n < 0: return 0.


Example 1: LC 902 - Numbers At Most N Given Digit Set

Problem: Given digit set D, count numbers ≤ N using only digits from D.

python
# python
# IDEA: only digits from the allowed set may be placed; `started` handles shorter numbers
# time = O(D * 2 * 2 * |digits|), space = O(D)
# LC 902 - Numbers At Most N Given Digit Set
def atMostNGivenDigitSet(digits, n):
    """
    Count numbers using only digits from set, at most n.

    Time: O(log n × |D|) where D is digit set
    Space: O(log n)
    """
    str_n = str(n)
    n_digits = len(str_n)
    digit_set = set(digits)

    # Count numbers with fewer digits (always valid)
    count = sum(len(digits) ** i for i in range(1, n_digits))

    # DP for numbers with exactly n_digits digits
    @lru_cache(None)
    def dp(pos, tight):
        # Base case: formed complete number
        if pos == n_digits:
            return 1

        # Determine max digit
        limit = int(str_n[pos]) if tight else 9

        result = 0
        for d in digits:
            digit_val = int(d)
            if digit_val > limit:
                break  # Can't use this digit

            # Continue building
            result += dp(pos + 1, tight and (digit_val == limit))

        return result

    return count + dp(0, True)
java
// java
// LC 902 - Numbers At Most N Given Digit Set
// IDEA: closed-form counting — all shorter lengths, then the same-length prefix walk
/**
 * time = O(log N × |D|)
 * space = O(log N)
 */
class Solution {
    private String strN;
    private String[] digits;
    private Map<String, Integer> memo;

    public int atMostNGivenDigitSet(String[] digits, int n) {
        this.strN = String.valueOf(n);
        this.digits = digits;
        this.memo = new HashMap<>();

        int nDigits = strN.length();
        int count = 0;

        // Count numbers with fewer digits
        int base = digits.length;
        for (int i = 1; i < nDigits; i++) {
            count += Math.pow(base, i);
        }

        // Add numbers with exactly nDigits digits
        count += dp(0, true);

        return count;
    }

    private int dp(int pos, boolean tight) {
        if (pos == strN.length()) {
            return 1;
        }

        String key = pos + "," + tight;
        if (memo.containsKey(key)) {
            return memo.get(key);
        }

        int limit = tight ? strN.charAt(pos) - '0' : 9;
        int result = 0;

        for (String d : digits) {
            int digit = Integer.parseInt(d);
            if (digit > limit) break;

            result += dp(pos + 1, tight && (digit == limit));
        }

        memo.put(key, result);
        return result;
    }
}

Example 2: Count Numbers with Digit Sum = K

Problem: Count numbers in [1, N] where sum of digits equals K.

python
# python
# IDEA: carry the running digit sum as the extra state
# time = O(D * k * 10), space = O(D * k)
# Count numbers with digit sum = K
def count_digit_sum_k(n, k):
    """
    Count numbers from 1 to n where digit sum = k.

    State: (pos, tight, sum)
    """
    if n < 0:
        return 0

    digits = [int(d) for d in str(n)]
    memo = {}

    def dp(pos, tight, current_sum):
        # Base case
        if pos == len(digits):
            return 1 if current_sum == k else 0

        # Memo check
        if (pos, tight, current_sum) in memo:
            return memo[(pos, tight, current_sum)]

        # Determine limit
        limit = digits[pos] if tight else 9

        result = 0
        for digit in range(0, limit + 1):
            # Pruning: skip if sum will exceed k
            if current_sum + digit > k:
                break

            result += dp(
                pos + 1,
                tight and (digit == limit),
                current_sum + digit
            )

        memo[(pos, tight, current_sum)] = result
        return result

    # Subtract 1 to exclude 0
    return dp(0, True, 0) - 1

# Example: count_digit_sum_k(100, 5)
# Numbers: 5, 14, 23, 32, 41, 50 → 6 numbers

Example 3: LC 233 - Number of Digit One

Problem: Count total number of digit ‘1’ appearing in all integers from 1 to n.

python
# python
# IDEA: count the 1s contributed at each position, not the numbers themselves
# time = O(D * D * 10), space = O(D * D)
# LC 233 - Number of Digit One
def countDigitOne(n):
    """
    Count occurrences of digit 1 in range [1, n].

    State: (pos, tight, count_of_ones)
    """
    if n < 0:
        return 0

    digits = [int(d) for d in str(n)]
    memo = {}

    def dp(pos, tight, started, count_ones):
        if pos == len(digits):
            return count_ones

        if (pos, tight, started, count_ones) in memo:
            return memo[(pos, tight, started, count_ones)]

        limit = digits[pos] if tight else 9
        result = 0

        for digit in range(0, limit + 1):
            new_started = started or (digit > 0)

            # Count this digit if it's 1 and we've started
            new_count = count_ones + (1 if digit == 1 and new_started else 0)

            result += dp(
                pos + 1,
                tight and (digit == limit),
                new_started,
                new_count
            )

        memo[(pos, tight, started, count_ones)] = result
        return result

    return dp(0, True, False, 0)
java
// java
// LC 233 - Number of Digit One
// IDEA: per-position closed form — high/current/low decomposition
/**
 * time = O(log N × log N)
 * space = O(log N × log N)
 */
class Solution {
    private int[] digits;
    private Map<String, Integer> memo;

    public int countDigitOne(int n) {
        String strN = String.valueOf(n);
        digits = new int[strN.length()];
        for (int i = 0; i < strN.length(); i++) {
            digits[i] = strN.charAt(i) - '0';
        }

        memo = new HashMap<>();
        return dp(0, true, false, 0);
    }

    private int dp(int pos, boolean tight, boolean started, int countOnes) {
        if (pos == digits.length) {
            return countOnes;
        }

        String key = pos + "," + tight + "," + started + "," + countOnes;
        if (memo.containsKey(key)) {
            return memo.get(key);
        }

        int limit = tight ? digits[pos] : 9;
        int result = 0;

        for (int digit = 0; digit <= limit; digit++) {
            boolean newStarted = started || (digit > 0);
            int newCount = countOnes + ((digit == 1 && newStarted) ? 1 : 0);

            result += dp(
                pos + 1,
                tight && (digit == limit),
                newStarted,
                newCount
            );
        }

        memo.put(key, result);
        return result;
    }
}

Example 4: Count Numbers with No Consecutive Same Digits

python
# python
# IDEA: previous digit is the extra state; `started` keeps padding zeros out of the rule
# time = O(D * 10 * 2 * 2), space = O(D * 10)
# Count numbers without consecutive same digits
def count_no_consecutive(n):
    """
    Count numbers from 1 to n with no two adjacent identical digits.

    State: (pos, tight, prev_digit)
    """
    if n < 0:
        return 0

    digits = [int(d) for d in str(n)]
    memo = {}

    # NOTE !!! `started` is not optional here. Without it, 1 is built as 0,0,1 under a
    #          3-digit bound and the two padding zeros trip the "same as previous" rule.
    def dp(pos, tight, started, prev_digit):
        if pos == len(digits):
            return 1 if started else 0      # `started` also drops the all-zeros number

        key = (pos, tight, started, prev_digit)
        if key in memo:
            return memo[key]

        limit = digits[pos] if tight else 9
        result = 0

        for digit in range(0, limit + 1):
            # Skip if same as previous digit — but only once the number has begun
            if started and digit == prev_digit:
                continue

            new_started = started or digit > 0
            result += dp(
                pos + 1,
                tight and (digit == limit),
                new_started,
                digit if new_started else -1  # padding zeros never become `prev_digit`
            )

        memo[key] = result
        return result

    return dp(0, True, False, -1)

# Example: count_no_consecutive(100) == 90
# Valid: 1..9, then 10, 12, 13, ..., 21, 23, 24, ..., 100 (exclude 11, 22, ...)

Classic LeetCode Problems

Problem LC# Difficulty State Variables Key Insight
Numbers At Most N Given Digit Set 902 Hard pos, tight Count valid digit combinations
Number of Digit One 233 Hard pos, tight, count Count digit occurrences
Numbers With Repeated Digits 1012 Hard pos, tight, mask Track used digits with bitmask
Count Special Integers 2376 Hard pos, tight, mask All distinct digits
Count Integers With Even Digit Sum 2180 Medium pos, tight, sum Digit sum parity
Count Numbers with Unique Digits 357 Medium pos, mask Permutation counting

Visual Example: Building Numbers Digit-by-Digit

text
Problem: Count numbers ≤ 523 with digit sum = 10

Digits of 523: [5, 2, 3]

Decision Tree (simplified):

Position 0: Can use 0-5
├─ Use 0: sum=0, tight=False → dp(1, False, 0)
│  ├─ Next positions have limit=9
│  └─ Count all with sum=10
│
├─ Use 1: sum=1, tight=False → dp(1, False, 1)
│  └─ Count numbers 1XX with digit sum = 10
│
├─ Use 2: sum=2, tight=False → dp(1, False, 2)
│  └─ Count numbers 2XX with digit sum = 10
│
├─ Use 3: sum=3, tight=False → dp(1, False, 3)
│  └─ Count numbers 3XX with digit sum = 10
│
├─ Use 4: sum=4, tight=False → dp(1, False, 4)
│  └─ Count numbers 4XX with digit sum = 10
│
└─ Use 5: sum=5, tight=True → dp(1, True, 5)
   ├─ Position 1: Can use 0-2 (tight bound)
   │  ├─ Use 0: sum=5, tight=False
   │  ├─ Use 1: sum=6, tight=False
   │  └─ Use 2: sum=7, tight=True
   │     └─ Position 2: Can use 0-3 (tight)
   │        ├─ Use 3: sum=10 ✓ (523 included!)
   │        └─ ...

Valid numbers: 109, 118, 127, ..., 505, 514, 523

Interview Tips

1. Recognition Patterns:

text
"Count numbers in range with..."
"How many numbers from L to R satisfy..."
"Numbers where digits..."
→ Think Digit DP

Keywords: "digit sum", "consecutive digits", "distinct digits",
         "digit constraints", "count numbers"

2. Common State Variables:

text
Always needed:
- pos: current digit position
- tight: bounded by upper limit

Often needed:
- started: handle leading zeros
- prev_digit: for consecutive/adjacent constraints
- sum: for digit sum problems
- mask: for tracking which digits used (bitmask)

3. Template Checklist:

python
# python
# IDEA: the shape to reproduce from memory in an interview
def digit_dp(n):
    digits = [int(d) for d in str(n)]
    memo = {}

    def dp(pos, tight, started, state):
        # 1. Base case
        if pos == len(digits):
            return check_condition(state)

        # 2. Memoization
        if (pos, tight, started, state) in memo:
            return memo[(pos, tight, started, state)]

        # 3. Determine limit
        limit = digits[pos] if tight else 9

        # 4. Try all valid digits
        result = 0
        for digit in range(0, limit + 1):
            new_started = started or (digit > 0)
            new_state = update_state(state, digit, new_started)

            result += dp(
                pos + 1,
                tight and (digit == limit),
                new_started,
                new_state
            )

        # 5. Save and return
        memo[(pos, tight, started, state)] = result
        return result

    return dp(0, True, False, initial_state)

4. Common Mistakes:

  • Forgetting to handle leading zeros (use started flag)
  • Wrong tight update: should be tight and (digit == limit)
  • Not converting string to digit array correctly
  • Off-by-one in range queries: count(R) - count(L-1)

5. Optimization Tips:

python
# python
# IDEA: two small wins — prune dead states, and let lru_cache own the memo
# Pruning: Skip impossible states
for digit in range(0, limit + 1):
    if current_sum + digit > target:
        break  # Remaining digits can't help

# Use @lru_cache for cleaner code
from functools import lru_cache

@lru_cache(None)
def dp(pos, tight, state):
    ...        # the per-problem transition goes here

6. Talking Points:

  • “Digit DP builds numbers digit-by-digit with memoization”
  • “tight flag tracks whether we’re bounded by upper limit”
  • “Count(L, R) = Count(0, R) - Count(0, L-1) transformation”
  • “Complexity is O(digits × states × 10), very efficient”

Advanced: Range Query Optimization

Sketch, not runnable code. check_valid / initial_state are placeholders and the transition is left as pass — this shows the shape of a two-bound digit DP only. In practice count(R) - count(L - 1) is what you should write; the single-pass form below is rarely worth the extra state.

text
# pseudocode — optimized range query
def count_range_optimized(L, R):
    """
    Handle range queries efficiently.

    Instead of count(R) - count(L-1), we can process both ends
    simultaneously to avoid redundant computation.
    """
    def count(n, lower_bound=None):
        digits = [int(d) for d in str(n)]
        memo = {}

        def dp(pos, tight_upper, tight_lower, state):
            # tight_upper: bounded by n
            # tight_lower: bounded by lower_bound (if exists)
            if pos == len(digits):
                return check_valid(state)

            # ... implementation with both bounds
            pass

        return dp(0, True, lower_bound is not None, initial_state)

    # Single call handles both L and R
    return count(R, L)

Summary

text
count_range(L, R) = count(R) - count(L - 1)        # and count(n) returns 0 for n < 0

count(n):
    digits = decimal digits of n, most significant first
    dfs(pos, tight, started, extra):
        pos == len(digits)  ->  1 if started else 0
        limit = digits[pos] if tight else 9
        sum over d in 0..limit of
            dfs(pos+1, tight and d == limit, started or d > 0, update(extra, d))
Trap Symptom Fix
No started flag padding zeros are treated as real digits (1 becomes 001) thread started, and apply digit rules only when it is true
started missing from the memo key wrong answers that change with the bound’s length key on (pos, tight, started, extra)
count(L - 1) with L == 0 crash parsing '-' in str(-1) return 0 for a negative bound
Memoising while tight is true undercounting either exclude tight states from the cache or keep tight in the key